AshFormBuilder ๐Ÿš€

View Source

Hex.pm Hex.pm Hex.pm Documentation

Latest Version: 0.3.0 | Changelog

AshFormBuilder = AshPhoenix.Form + Auto UI + Smart Components + Themes + Full CRUD Generator

A declarative form generation engine for Ash Framework and Phoenix LiveView.

Define your form structure in 1-3 lines inside your Ash Resource, and get a complete, policy-compliant LiveView form with:

  • โœ… Full CRUD LiveView generator โ€” one command, production-grade scaffold (New in v0.3.0)
  • โœ… Auto-inferred fields from your action's accept list
  • โœ… Searchable combobox for many-to-many relationships
  • โœ… Creatable combobox (create related records on-the-fly)
  • โœ… Dynamic nested forms for has_many relationships
  • โœ… Pluggable theme system (Default, Glassmorphism, Shadcn, MishkaChelekom, or custom)
  • โœ… Full Ash policy and validation enforcement

๐ŸŽฏ The Pitch: Why AshFormBuilder?

LayerAshPhoenix.FormAshFormBuilder
CRUD ScaffoldโŒ Write it all yourselfโœ… mix ash_form_builder.gen.live
Data TableโŒ Build your ownโœ… Cinder (filter, sort, paginate, URL sync)
Form Stateโœ… Provides AshPhoenix.Formโœ… Uses AshPhoenix.Form
Field InferenceโŒ Manual field definitionโœ… Auto-infers from action.accept
UI ComponentsโŒ You render everythingโœ… Smart components per field type
ThemesโŒ No themingโœ… Pluggable theme system
ComboboxโŒ Build your ownโœ… Searchable + Creatable built-in
Nested FormsโŒ Manual setupโœ… Auto nested forms with add/remove
Lines of Code~20-50 lines~1-3 lines (or one command)

In short: AshPhoenix.Form gives you the engine. AshFormBuilder gives you the complete car โ€” factory-delivered.


โšก 3-Line Quick Start

1. Add to mix.exs

{:ash_form_builder, "~> 0.3.0"}

2. Configure Theme (config/config.exs)

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

3. Add Extension to Resource

defmodule MyApp.Todos.Task do
  use Ash.Resource,
    domain: MyApp.Todos,
    extensions: [AshFormBuilder]  # โ† Add this

  attributes do
    uuid_primary_key :id
    attribute :title, :string, allow_nil?: false
    attribute :description, :text
    attribute :completed, :boolean, default: false
  end

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

  # Create form - auto-infers fields from :create action
  form do
    action :create  # โ† That's it! Fields auto-inferred
    submit_label "Create Task"
  end

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

4. Use in LiveView

Create Form:

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

  def mount(_params, _session, socket) do
    form = MyApp.Todos.Task.Form.for_create(actor: socket.assigns.current_user)
    {:ok, assign(socket, form: form, mode: :create)}
  end

  def render(assigns) do
    ~H"""
    <.live_component
      module={AshFormBuilder.FormComponent}
      id="task-form"
      resource={MyApp.Todos.Task}
      form={@form}
    />
    """
  end

  def handle_info({:form_submitted, MyApp.Todos.Task, task}, socket) do
    {:noreply, push_navigate(socket, to: ~p"/tasks/#{task.id}")}
  end
end

Update Form:

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

  def mount(%{"id" => id}, _session, socket) do
    task = MyApp.Todos.get_task!(id, actor: socket.assigns.current_user)
    form = MyApp.Todos.Task.Form.for_update(task, actor: socket.assigns.current_user)
    {:ok, assign(socket, form: form, mode: :edit)}
  end

  def render(assigns) do
    ~H"""
    <.live_component
      module={AshFormBuilder.FormComponent}
      id="task-edit-form"
      resource={MyApp.Todos.Task}
      form={@form}
    />
    """
  end

  def handle_info({:form_submitted, MyApp.Todos.Task, task}, socket) do
    {:noreply, push_navigate(socket, to: ~p"/tasks/#{task.id}")}
  end
end

Result: Complete create and update forms with auto-inferred fields - all from 2 form blocks.


โœจ Key Features

๐Ÿ” Searchable Many-to-Many Combobox

Automatically renders searchable multi-select for relationships:

relationships do
  many_to_many :tags, MyApp.Todos.Tag do
    through MyApp.Todos.TaskTag
  end
end

actions do
  create :create do
    accept [:title]
    manage_relationship :tags, :tags, type: :append_and_remove
  end
end

form do
  action :create
  
  field :tags do
    type :multiselect_combobox
    opts [
      search_event: "search_tags",
      debounce: 300,
      label_key: :name,
      value_key: :id
    ]
  end
end

LiveView Search Handler:

def handle_event("search_tags", %{"query" => query}, socket) do
  tags = MyApp.Todos.Tag
           |> Ash.Query.filter(contains(name: ^query))
           |> MyApp.Todos.read!()
  
  {:noreply, push_event(socket, "update_combobox_options", %{
    field: "tags",
    options: Enum.map(tags, &{&1.name, &1.id})
  })}
end

โœจ Creatable Combobox (Create On-the-Fly)

Allow users to create new related records without leaving the form:

form do
  field :tags do
    type :multiselect_combobox
    opts [
      creatable: true,              # โ† Enable creating
      create_action: :create,
      create_label: "Create \"",
      search_event: "search_tags"
    ]
  end
end

What happens:

  1. User types "Urgent" in combobox
  2. No results found โ†’ "Create 'Urgent'" button appears
  3. Click โ†’ Creates new Tag record via Ash
  4. New tag automatically added to selection
  5. All Ash validations and policies enforced

๐Ÿ”— Dynamic Nested Forms (has_many)

Manage child records with dynamic add/remove:

relationships do
  has_many :subtasks, MyApp.Todos.Subtask
end

form do
  nested :subtasks do
    label "Subtasks"
    cardinality :many
    add_label "Add Subtask"
    remove_label "Remove"
    
    field :title, required: true
    field :completed, type: :checkbox
  end
end

Renders:

  • Fieldset with "Subtasks" legend
  • Existing subtasks rendered with all fields
  • "Add Subtask" button โ†’ adds new subtask form
  • "Remove" button on each subtask โ†’ removes from form
  • Full validation support for nested fields

๐Ÿ“ File Uploads

AshFormBuilder provides declarative file upload support that bridges Phoenix LiveView's native upload lifecycle with Ash Framework's file handling.

Basic File Upload

defmodule MyApp.Users.User do
  use Ash.Resource,
    domain: MyApp.Users,
    extensions: [AshFormBuilder]

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

  actions do
    create :create do
      accept [:name]
      argument :avatar, :string, allow_nil?: true
      
      # Store the uploaded file path in the avatar_path attribute
      change fn changeset, _ ->
        case Ash.Changeset.get_argument(changeset, :avatar) do
          nil -> changeset
          path -> Ash.Changeset.change_attribute(changeset, :avatar_path, path)
        end
      end
    end
  end

  form do
    action :create
    submit_label "Create User"

    field :name do
      label "Full Name"
      required true
    end

    field :avatar do
      type :file_upload
      label "Profile Photo"
      hint "JPEG or PNG, max 5 MB"
      
      opts upload: [
        cloud: MyApp.Buckets.Cloud,      # Buckets.Cloud module for storage
        max_entries: 1,                   # Allow only 1 file
        max_file_size: 5_000_000,         # 5 MB max
        accept: ~w(.jpg .jpeg .png)       # Accepted file types
      ]
    end
  end
end

Upload Configuration Options

OptionTypeDefaultDescription
cloudmodulerequiredModule implementing Buckets.Cloud behaviour
max_entriesinteger1Maximum number of files allowed
max_file_sizeinteger8_000_000Maximum file size in bytes
acceptlist:anyAccepted file extensions or MIME types
bucket_nameatomnilOptional bucket name for storage

How It Works

  1. Mount: FormComponent automatically calls allow_upload/3 for each :file_upload field
  2. Upload: User selects file โ†’ Phoenix LiveView handles the upload progress
  3. Submit: On form submission:
    • consume_uploaded_entries/3 is called for each upload field
    • Files are stored via the configured Buckets.Cloud module
    • Final file paths are injected into Ash action parameters
    • Ash action receives the stored file paths

Multiple File Uploads

field :attachments do
  type :file_upload
  label "Attachments"
  hint "Upload multiple documents (max 5)"
  
  opts upload: [
    cloud: MyApp.Buckets.Cloud,
    max_entries: 5,
    max_file_size: 10_000_000,
    accept: ~w(.pdf .doc .docx)
  ]
end

Using in LiveView

defmodule MyAppWeb.UserLive.Create do
  use MyAppWeb, :live_view

  def mount(_params, _session, socket) do
    form = MyApp.Users.User.Form.for_create(actor: socket.assigns.current_user)
    {:ok, assign(socket, form: form)}
  end

  def render(assigns) do
    ~H"""
    <.live_component
      module={AshFormBuilder.FormComponent}
      id="user-form"
      resource={MyApp.Users.User}
      form={@form}
    />
    """
  end

  def handle_info({:form_submitted, MyApp.Users.User, user}, socket) do
    {:noreply, push_navigate(socket, to: ~p"/users/#{user.id}")}
  end
end

Theme Support

File uploads are styled according to your configured theme:

  • Default Theme: Clean HTML5 file input with progress bar
  • MishkaTheme: Styled with Tailwind CSS, includes image previews
  • Custom Themes: Implement render_file_upload/1 in your theme module

๐Ÿ”„ Create vs Update Forms

AshFormBuilder supports both create and update forms with separate form blocks for each action.

Multiple Form Blocks Per Resource

You can define multiple form blocks in the same resource - each targeting a different action:

defmodule MyApp.Todos.Task do
  use Ash.Resource,
    domain: MyApp.Todos,
    extensions: [AshFormBuilder]

  # ... attributes and relationships

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

  # CREATE form configuration
  form do
    action :create
    submit_label "Create Task"

    field :title do
      label "Task Title"
      placeholder "Enter task title"
      required true
    end
  end

  # UPDATE form configuration (separate block)
  form do
    action :update
    submit_label "Save Changes"

    # Can have different field customizations for update
    field :title do
      label "Task Title"
      hint "Changing the title will notify collaborators"
    end
  end
end

Update Forms Auto-Preload Relationships

For update forms, many_to_many relationships are automatically preloaded so the form displays existing selections:

# In your LiveView
def mount(%{"id" => id}, _session, socket) do
  # for_update/2 automatically preloads required relationships
  task = MyApp.Todos.Task |> MyApp.Todos.get_task!(id)
  form = MyApp.Todos.Task.Form.for_update(task, actor: socket.assigns.current_user)
  {:ok, assign(socket, form: form)}
end

Behind the scenes: The generated Form.for_update/2 helper detects which relationships need preloading (based on your many_to_many fields) and loads them automatically.

Domain Code Interface with Update Forms

When using Domain Code Interfaces, update forms work seamlessly:

# Domain configuration
defmodule MyApp.Todos do
  use Ash.Domain

  resources do
    resource MyApp.Todos.Task do
      define :form_to_create_task, action: :create
      define :form_to_update_task, action: :update  # โ† Update form helper
    end
  end
end

# LiveView usage
form = MyApp.Todos.form_to_update_task(task, actor: current_user)

๐ŸŽจ Theme System

Built-in Themes

AshFormBuilder.Themes.Default (Recommended)

Production-ready Tailwind CSS styling with zero configuration.

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

AshFormBuilder.Themes.Glassmorphism (New in 0.2.3)

Premium glass-effect UI with backdrop blur, smooth animations, and dark mode.

config :ash_form_builder, :theme, AshFormBuilder.Themes.Glassmorphism

AshFormBuilder.Themes.Shadcn (New in 0.2.3)

Clean, minimal design inspired by shadcn/ui with crisp borders and focus rings.

config :ash_form_builder, :theme, AshFormBuilder.Themes.Shadcn

AshFormBuilder.Theme.MishkaTheme

MishkaChelekom component integration (requires mishka_chelekom dependency).

config :ash_form_builder, :theme, AshFormBuilder.Theme.MishkaTheme

Custom Themes

Create your own theme by implementing the AshFormBuilder.Theme behaviour. See the Theme Customization Guide for a complete tutorial with examples for Tailwind, Bootstrap, and more.

defmodule MyAppWeb.CustomTheme do
  @behaviour AshFormBuilder.Theme
  use Phoenix.Component

  @impl AshFormBuilder.Theme
  def render_field(assigns, opts) do
    case assigns.field.type do
      :text_input -> render_text_input(assigns)
      :multiselect_combobox -> render_combobox(assigns)
      # ... etc
    end
  end

  defp render_text_input(assigns) do
    ~H"""
    <div class="form-group">
      <label for={Phoenix.HTML.Form.input_id(@form, @field.name)}>
        {@field.label}
      </label>
      <input
        type="text"
        id={Phoenix.HTML.Form.input_id(@form, @field.name)}
        class="form-control"
      />
    </div>
    """
  end
end

๐Ÿ“š Documentation

Core Documentation

Guides


๐Ÿ“ฆ Installation

Requirements

  • Elixir ~> 1.17
  • Phoenix ~> 1.7
  • Phoenix LiveView ~> 1.0
  • Ash ~> 3.0
  • AshPhoenix ~> 2.0

Steps

  1. Add dependency to mix.exs:

    defp deps do
      [
        {:ash, "~> 3.0"},
        {:ash_phoenix, "~> 2.0"},
        {:ash_form_builder, "~> 0.3.0"},
    
        # Required if using mix ash_form_builder.gen.live
        {:cinder, "~> 0.12"},
    
        # Optional: For MishkaChelekom theme
        {:mishka_chelekom, "~> 0.0.8"}
      ]
    end
  2. Fetch dependencies:

    mix deps.get
    
  3. Configure theme in config/config.exs:

    config :ash_form_builder, :theme, AshFormBuilder.Themes.Default
  4. Add extension to your Ash Resource:

    use Ash.Resource,
      domain: MyApp.Todos,
      extensions: [AshFormBuilder]

๐Ÿš€ Magic Generators (New in v0.3.0)

One command. Full CRUD. Production-grade LiveView in seconds.

mix ash_form_builder.gen.live Accounts User

That's it. The generator outputs two ready-to-use files wired with Cinder for the data table and AshFormBuilder inside a Phoenix modal for create/edit โ€” no boilerplate to write.

What Gets Generated

FileContents
lib/my_app_web/live/user_live/index.exFull LiveView: mount, handle_params, handle_info, handle_event
lib/my_app_web/live/user_live/index.html.heexHEEx template: Cinder table + modal with AshFormBuilder.FormComponent

Generated index.ex โ€” the LiveView

defmodule MyAppWeb.UserLive.Index do
  use MyAppWeb, :live_view
  use Cinder.UrlSync         # injects handle_info for URL sync automatically

  alias MyApp.Accounts.User

  @collection_id "user-collection"

  def mount(_params, _session, socket) do
    {:ok, assign(socket, url_state: false, record: nil, form: nil)}
  end

  def handle_params(params, uri, socket) do
    socket = Cinder.UrlSync.handle_params(params, uri, socket)
    {:noreply, apply_action(socket, socket.assigns.live_action, params)}
  end

  # Triggered by AshFormBuilder.FormComponent after a successful Ash action
  def handle_info({:form_submitted, User, _result}, socket) do
    {:noreply,
     socket
     |> put_flash(:info, "User saved successfully.")
     |> Cinder.refresh_table(@collection_id)   # async re-query, no page reload
     |> push_patch(to: ~p"/users")}
  end

  def handle_event("delete", %{"id" => id}, socket) do
    User |> Ash.get!(id, actor: socket.assigns[:current_user])
         |> Ash.destroy!(actor: socket.assigns[:current_user])
    {:noreply, socket |> put_flash(:info, "User deleted.") |> Cinder.refresh_table(@collection_id)}
  end
end

Every callback is fully wired:

CallbackBehaviour
mount/3Initialises url_state, record, form assigns
handle_params/3Delegates to Cinder.UrlSync; routes :new / :edit live actions
apply_action :newBuilds AshPhoenix.Form.for_create
apply_action :editAsh.get! + AshPhoenix.Form.for_update
handle_info :form_submittedFlash + Cinder.refresh_table + push_patch to index
handle_event "delete"Ash.get! + Ash.destroy! + Cinder.refresh_table

Generated index.html.heex โ€” the template

<.header>
  Users
  <:actions>
    <.link patch={~p"/users/new"}><.button>New User</.button></.link>
  </:actions>
</.header>

<%!-- Cinder.collection: filterable, sortable, paginated โ€” state synced to URL --%>
<Cinder.collection
  id="user-collection"
  resource={MyApp.Accounts.User}
  actor={assigns[:current_user]}
  url_state={@url_state}
  page_size={25}
  empty_message="No users found."
>
  <%!-- TODO: Replace with your resource's real attributes (see Customising Columns) --%>
  <:col :let={user} field="id" sort label="ID">{user.id}</:col>

  <:col :let={user} label="Actions">
    <.link patch={~p"/users/#{user}/edit"}>Edit</.link>
    <.link phx-click="delete" phx-value-id={user.id}
           data-confirm="Delete this user? This cannot be undone.">Delete</.link>
  </:col>
</Cinder.collection>

<%!-- Modal: mounts only for :new and :edit live_actions --%>
<.modal :if={@live_action in [:new, :edit]} id="user-modal" show
        on_cancel={JS.patch(~p"/users")}>
  <.live_component
    module={AshFormBuilder.FormComponent}
    id={if @record, do: "user-edit-#{@record.id}", else: "user-new"}
    resource={MyApp.Accounts.User}
    form={@form}
    submit_label={if @live_action == :new, do: "Create User", else: "Save Changes"}
  />
</.modal>

Router Entries (printed by the generator)

scope "/", MyAppWeb do
  pipe_through :browser

  live "/users",          UserLive.Index, :index
  live "/users/new",      UserLive.Index, :new
  live "/users/:id/edit", UserLive.Index, :edit
end

Generator Options

OptionDefaultDescription
--page-size / -p25Rows per page in the Cinder table
--out / -olib/<app>_web/live/<resource>_liveOutput directory override
mix ash_form_builder.gen.live Inventory Product --page-size 50
mix ash_form_builder.gen.live Accounts User --out lib/my_app_web/live/admin

Customising Columns

After generation, open index.html.heex and replace the placeholder <:col> slots with your resource's real attributes:

<:col :let={user} field="name"        filter sort>{user.name}</:col>
<:col :let={user} field="email"       filter>{user.email}</:col>
<:col :let={user} field="role"        filter={:select}>{user.role}</:col>
<:col :let={user} field="inserted_at" sort>{user.inserted_at}</:col>

Cinder column attributes:

  • filter โ€” text filter input for that column
  • filter={:select} โ€” dropdown filter for enum/atom fields
  • sort โ€” enables column sort toggle
  • search โ€” includes field in the global search bar (if configured)

Prerequisites

The generator requires Cinder for the data table. Add it to your mix.exs:

{:cinder, "~> 0.12"}

๐Ÿ”ง Field Type Inference

AshFormBuilder automatically maps Ash types to UI components:

Ash TypeConstraintUI TypeExample
:string-:text_inputText fields
:text-:textareaMulti-line text
:boolean-:checkboxToggle switches
:integer / :float-:numberNumeric inputs
:date-:dateDate picker
:datetime-:datetimeDateTime picker
:atomone_of::selectDropdown
:enum module-:selectEnum dropdown
many_to_many-:multiselect_comboboxSearchable multi-select
has_many-:nested_formDynamic nested forms

๐Ÿงช Testing

defmodule MyAppWeb.TaskLiveTest do
  use MyAppWeb.ConnCase

  import Phoenix.LiveViewTest

  test "renders form with auto-inferred fields", %{conn: conn} do
    {:ok, _view, html} = live_isolated(conn, MyAppWeb.TaskLive.Form)
    
    assert html =~ "Task Title"
    assert html =~ "Description"
    assert html =~ "Completed"
  end

  test "creates task and redirects", %{conn: conn} do
    {:ok, view, _html} = live_isolated(conn, MyAppWeb.TaskLive.Form)
    
    assert form(view, "#task-form", task: %{
      title: "Test Task",
      description: "Test description"
    }) |> render_submit()
    
    assert_redirect(view, ~p"/tasks/*")
  end
end

โš ๏ธ Version Status

v0.3.0 - Production-Ready

This version includes:

  • โœ… Full CRUD LiveView generator (mix ash_form_builder.gen.live)
  • โœ… Deep Cinder integration (data table, URL sync, async refresh)
  • โœ… Zero-config field inference
  • โœ… Searchable/creatable combobox
  • โœ… Dynamic nested forms
  • โœ… Glassmorphism, Shadcn, Default, and MishkaChelekom themes
  • โœ… Full Ash policy enforcement
  • โœ… 180 tests, 0 failures
  • โœ… Clean mix credo --strict and mix dialyzer

Known Limitations:

  • Deeply nested forms (3+ levels) require manual path handling
  • i18n support planned for a future release
  • Field-level permissions planned for a future release

๐Ÿค Contributing

Contributions welcome! Please:

  1. Fork the repository
  2. Create a feature branch (git checkout -b feature/amazing-feature)
  3. Make your changes
  4. Add tests
  5. Run mix test and mix format
  6. Submit a pull request

Development Setup

git clone https://github.com/nagieeb0/ash_form_builder.git
cd ash_form_builder
mix deps.get
mix test

๐Ÿ“„ License

MIT License - see LICENSE file for details.


๐Ÿ™ Acknowledgments


Built with โค๏ธ using Ash Framework and Phoenix LiveView