AshFormBuilder (AshFormBuilder v0.2.0)

View Source

A Spark DSL extension for Ash Framework that automatically generates Phoenix LiveView forms from resource definitions.

Key Features

  • Auto-Inference Engine - Automatically infers form fields from your resource's accept list, including many_to_many relationships
  • Domain Code Interface Integration - Works seamlessly with Ash's form_to_<action> pattern for clean LiveViews
  • Customizable Themes - Built-in MishkaChelekom theme with advanced searchable combobox support for many-to-many relationships

Installation

Add the extension to your resource:

defmodule MyApp.Billing.Clinic do
  use Ash.Resource,
    domain: MyApp.Billing,
    extensions: [AshFormBuilder]

  attributes do
    uuid_primary_key :id
    attribute :name, :string, allow_nil?: false
    attribute :address, :string
  end

  relationships do
    many_to_many :doctors, MyApp.Billing.Doctor do
      through MyApp.Billing.ClinicDoctor
      source_attribute_on_join_resource :clinic_id
      destination_attribute_on_join_resource :doctor_id
    end
  end

  actions do
    defaults [:create, :read, :update, :destroy]
  end

  # Create form - auto-infers fields from :create action
  form do
    action :create
    submit_label "Create Clinic"
  end

  # Update form - separate configuration for :update action
  form do
    action :update
    submit_label "Save Changes"
  end
end

Domain Code Interface Setup

Configure your Domain with form-specific code interfaces:

defmodule MyApp.Billing do
  use Ash.Domain

  resources do
    resource MyApp.Billing.Clinic do
      # Form helpers for create and update actions
      define :form_to_create_clinic, action: :create
      define :form_to_update_clinic, action: :update
    end
  end
end

Then use in your LiveView:

# Create form
form = MyApp.Billing.form_to_create_clinic(%{}, actor: current_user)

# Update form (passes existing record)
form = MyApp.Billing.form_to_update_clinic(clinic, actor: current_user)

Creating a Form in LiveView

Create Form:

defmodule MyAppWeb.ClinicLive.Form do
  use MyAppWeb, :live_view

  @impl true
  def mount(_params, _session, socket) do
    # No manual AshPhoenix.Form calls needed!
    form = MyApp.Billing.Clinic.Form.for_create(
      actor: socket.assigns.current_user
    )
    {:ok, assign(socket, form: form, mode: :create)}
  end

  @impl true
  def render(assigns) do
    ~H"""
    <.live_component
      module={AshFormBuilder.FormComponent}
      id="clinic-form"
      resource={MyApp.Billing.Clinic}
      form={@form}
    />
    """
  end

  @impl true
  def handle_info({:form_submitted, MyApp.Billing.Clinic, clinic}, socket) do
    {:noreply, push_navigate(socket, to: ~p"/clinics/" <> clinic.id)}
  end
end

Update Form:

defmodule MyAppWeb.ClinicLive.Edit do
  use MyAppWeb, :live_view

  @impl true
  def mount(%{"id" => id}, _params, _session, socket) do
    clinic = MyApp.Billing.get_clinic!(id, actor: socket.assigns.current_user)
    # for_update/2 automatically preloads required relationships
    form = MyApp.Billing.Clinic.Form.for_update(clinic, actor: socket.assigns.current_user)
    {:ok, assign(socket, form: form, mode: :edit)}
  end

  @impl true
  def render(assigns) do
    ~H"""
    <.live_component
      module={AshFormBuilder.FormComponent}
      id="clinic-edit-form"
      resource={MyApp.Billing.Clinic}
      form={@form}
    />
    """
  end

  @impl true
  def handle_info({:form_submitted, MyApp.Billing.Clinic, clinic}, socket) do
    {:noreply, push_navigate(socket, to: ~p"/clinics/" <> clinic.id)}
  end
end

Auto-Inference Engine

The AshFormBuilder.Infer module automatically maps:

Ash TypeUI Type
:string:text_input
:integer:number
:boolean:checkbox
:date:date
:datetime:datetime
:enum:select
many_to_many:multiselect_combobox

Many-to-Many with Searchable Combobox

Auto-inferred many_to_many relationships use a searchable combobox. Customize the search behavior:

form do
  action :create

  field :doctors do
    type :multiselect_combobox
    opts [
      search_event: "search_doctors",
      search_param: "query",
      debounce: 300,
      label_key: :full_name,
      value_key: :id
    ]
  end
end

Update forms automatically preload many_to_many relationships so existing selections are displayed:

def mount(%{"id" => id}, _params, _session, socket) do
  clinic = MyApp.Billing.get_clinic!(id, actor: socket.assigns.current_user)
  # for_update/2 auto-preloads required relationships
  form = MyApp.Billing.Clinic.Form.for_update(clinic, actor: socket.assigns.current_user)
  {:ok, assign(socket, form: form)}
end

Handle search in your LiveView:

def handle_event("search_doctors", %{"query" => query}, socket) do
  doctors =
    MyApp.Billing.Doctor
    |> Ash.Query.filter(name_contains: query)
    |> MyApp.Billing.read!(actor: socket.assigns.current_user)
    |> Enum.map(&{&1.full_name, &1.id})

  {:noreply, push_event(socket, "update_combobox_options", %{
    field: "doctors",
    options: doctors
  })}
end

Theme Configuration

Configure the theme in config/config.exs:

# Default HTML theme
config :ash_form_builder, :theme, AshFormBuilder.Themes.Default

# MishkaChelekom theme (requires mishka_chelekom dependency)
config :ash_form_builder, :theme, AshFormBuilder.Theme.MishkaTheme

Domain-Driven Validation Assurance

Using the Domain Code Interface ensures:

  • Policy Enforcement - All Ash policies are checked automatically
  • Full Validations - Server-side validations run on every submit
  • Atomic Updates - Actions execute within transactions
  • Error Rendering - Validation errors render through the theme

DSL Reference

form Section

optiontypedefaultdescription
actionatomrequiredAsh action to target
submit_labelstring"Submit"Submit button label
moduleatomOverride generated module name
form_idstringHTML id for the <form>
wrapper_classstring"space-y-4"CSS class on fields wrapper

field Options

optiontypedefaultdescription
labelstringInput label
typeatom:text_inputInput type
placeholderstringPlaceholder text
requiredbooleanfalseRequired indicator
optionslist[]Select options
optskeyword[]Custom UI options

Field Types: :text_input, :textarea, :select, :multiselect_combobox, :checkbox, :number, :email, :password, :date, :datetime, :hidden, :url, :tel

:multiselect_combobox opts:

  • search_event - Event name for searching
  • search_param - Query param name (default: "query")
  • debounce - Search debounce in ms (default: 300)
  • label_key - Field for labels (default: :name)
  • value_key - Field for values (default: :id)
  • creatable - Allow creating new items via combobox (default: false)
  • create_action - Action to use for creating new items (default: :create)
  • create_label - Label template for create button (default: "Create "{value}"")

nested Options

optiontypedefaultdescription
relationshipatom:nameRelationship name
cardinalityatom:many:many or :one
labelstringFieldset legend
add_labelstring"Add"Add-button label
remove_labelstring"Remove"Remove-button label
create_actionatom:createNested create action
update_actionatom:updateNested update action
classstringFieldset CSS class

Introspection

Access the inferred form schema:

MyApp.Billing.Clinic.Form.schema()
# => %{fields: [...], nested_forms: [...]}

Get nested forms configuration:

MyApp.Billing.Clinic.Form.nested_forms()
# => [doctors: [type: :list, resource: MyApp.Billing.Doctor, ...]]

Modules

Summary

Functions

form(body)

(macro)