AshFormBuilder ๐
View SourceLatest 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
acceptlist - โ 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?
| Layer | AshPhoenix.Form | AshFormBuilder |
|---|---|---|
| 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.Default3. 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
end4. 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
endUpdate 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
endResult: 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
endLiveView 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
endWhat happens:
- User types "Urgent" in combobox
- No results found โ "Create 'Urgent'" button appears
- Click โ Creates new Tag record via Ash
- New tag automatically added to selection
- 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
endRenders:
- 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
endUpload Configuration Options
| Option | Type | Default | Description |
|---|---|---|---|
cloud | module | required | Module implementing Buckets.Cloud behaviour |
max_entries | integer | 1 | Maximum number of files allowed |
max_file_size | integer | 8_000_000 | Maximum file size in bytes |
accept | list | :any | Accepted file extensions or MIME types |
bucket_name | atom | nil | Optional bucket name for storage |
How It Works
- Mount: FormComponent automatically calls
allow_upload/3for each:file_uploadfield - Upload: User selects file โ Phoenix LiveView handles the upload progress
- Submit: On form submission:
consume_uploaded_entries/3is called for each upload field- Files are stored via the configured
Buckets.Cloudmodule - 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)
]
endUsing 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
endTheme 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/1in 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
endUpdate 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)}
endBehind 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.DefaultAshFormBuilder.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.GlassmorphismAshFormBuilder.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.ShadcnAshFormBuilder.Theme.MishkaTheme
MishkaChelekom component integration (requires mishka_chelekom dependency).
config :ash_form_builder, :theme, AshFormBuilder.Theme.MishkaThemeCustom 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
- Hex Docs - Complete API reference
- Readme - Getting started guide
- Changelog - Version history and migration notes
Guides
- Theme Customization Guide - Create custom themes
- Todo App Tutorial - Step-by-step integration
- Relationships Guide - has_many vs many_to_many
- File Upload Guide - File upload configuration
- Storage Configuration - S3, GCS, and local storage
๐ฆ Installation
Requirements
- Elixir ~> 1.17
- Phoenix ~> 1.7
- Phoenix LiveView ~> 1.0
- Ash ~> 3.0
- AshPhoenix ~> 2.0
Steps
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"} ] endFetch dependencies:
mix deps.getConfigure theme in
config/config.exs:config :ash_form_builder, :theme, AshFormBuilder.Themes.DefaultAdd 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
| File | Contents |
|---|---|
lib/my_app_web/live/user_live/index.ex | Full LiveView: mount, handle_params, handle_info, handle_event |
lib/my_app_web/live/user_live/index.html.heex | HEEx 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
endEvery callback is fully wired:
| Callback | Behaviour |
|---|---|
mount/3 | Initialises url_state, record, form assigns |
handle_params/3 | Delegates to Cinder.UrlSync; routes :new / :edit live actions |
apply_action :new | Builds AshPhoenix.Form.for_create |
apply_action :edit | Ash.get! + AshPhoenix.Form.for_update |
handle_info :form_submitted | Flash + 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
endGenerator Options
| Option | Default | Description |
|---|---|---|
--page-size / -p | 25 | Rows per page in the Cinder table |
--out / -o | lib/<app>_web/live/<resource>_live | Output 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 columnfilter={:select}โ dropdown filter for enum/atom fieldssortโ enables column sort togglesearchโ 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 Type | Constraint | UI Type | Example |
|---|---|---|---|
:string | - | :text_input | Text fields |
:text | - | :textarea | Multi-line text |
:boolean | - | :checkbox | Toggle switches |
:integer / :float | - | :number | Numeric inputs |
:date | - | :date | Date picker |
:datetime | - | :datetime | DateTime picker |
:atom | one_of: | :select | Dropdown |
:enum module | - | :select | Enum dropdown |
many_to_many | - | :multiselect_combobox | Searchable multi-select |
has_many | - | :nested_form | Dynamic 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 --strictandmix 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:
- Fork the repository
- Create a feature branch (
git checkout -b feature/amazing-feature) - Make your changes
- Add tests
- Run
mix testandmix format - 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
- Ash Framework - The excellent Elixir framework
- Phoenix LiveView - Real-time HTML without JavaScript
- MishkaChelekom - UI component library
Built with โค๏ธ using Ash Framework and Phoenix LiveView