AshFormBuilder ๐
Latest Version:0.4.0 | Changelog
What's new in 0.4.0
- ๐ก iOS-style wheel datepicker in the default theme (modal
<dialog>, three or five scroll-snap wheels, no JS deps)- ๐ฆ First-class
:file_uploadDSL โaccept :images,max_files 3,max_file_size {10, :mb},cloud MyApp.Cloud- ๐ช Auto-generated
for_<action>/Nfor every declared form, not just:create/:update- ๐ท๏ธ Tag combobox upserts on create when the destination resource has an upsert action with an identity
- ๐จ Polished default theme: per-field validation surfacing, animated nested-form add/remove, on/off toggle pill, chips below input
AshFormBuilder = AshPhoenix.Form + Auto UI + Pluggable Themes + Full CRUD Generator
A declarative form generation engine for Ash Framework and Phoenix LiveView.
Define your form structure in a forms do โฆ end block inside your Ash Resource and get a complete, policy-compliant LiveView form with:
-
โ
One form per Ash action, declared with
form :create do โฆ end -
โ
Per-form theming โ
theme :shadcn,accent :teal,transitions :smooth -
โ
Pluggable theme registry โ short atoms (
:default,:shadcn,:glassmorphism,:mishka) or your own - โ Auto-inferred fields with smart defaults โ booleans become toggles, many-to-many becomes a checkbox group
-
โ
Dynamic nested forms for
has_manyrelationships (recursive, any depth) -
โ
Searchable combobox available via
type :multiselect_comboboxfor many-to-many when you want it -
โ
Full CRUD scaffold โ
mix ash_form.gen.live -r MyApp.Resource - โ Full Ash policy and validation enforcement, including atomic updates and changesets
๐ฏ The Pitch: Why AshFormBuilder?
| Layer | AshPhoenix.Form | AshFormBuilder |
|---|---|---|
| CRUD Scaffold | โ Write it all yourself |
โ
mix ash_form.gen.live -r MyApp.Resource |
| 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.
โก Quick Start
1. Add to mix.exs
{:ash_form_builder, "~> 0.4.0"}2. (Optional) configure a theme
# config/config.exs โ short atom or module name
config :ash_form_builder, :theme, :default
# config :ash_form_builder, :theme, :shadcn
# config :ash_form_builder, :theme, AshFormBuilder.Themes.GlassmorphismIf you write your own themes, register them once and refer to them by short name from any form:
config :ash_form_builder, :themes,
my_brand: MyAppWeb.Themes.MyBrand,
retro: MyAppWeb.Themes.Retro
3. Add the extension and a forms do โฆ end block
defmodule MyApp.Todos.Task do
use Ash.Resource,
domain: MyApp.Todos,
extensions: [AshFormBuilder]
attributes do
uuid_primary_key :id
attribute :title, :string, allow_nil?: false, public?: true
attribute :description, :string, public?: true
attribute :completed, :boolean, default: false, public?: true
end
actions do
defaults [:read, :destroy]
create :create do
accept [:title, :description, :completed]
end
update :update do
accept [:title, :description, :completed]
end
end
forms do
form :create do
submit_label "Create Task"
accent :teal
transitions :smooth
field :title do
label "Task Title"
placeholder "Enter task title"
required true
end
end
form :update do
submit_label "Update Task"
accent :indigo
end
end
end
The DSL accepts one form :action do โฆ end entity per Ash action. Each form
keeps its own submit_label, accent, transitions, theme, fields and
nested blocks. Fields are auto-inferred from the action's accept list โ only
declare a field :title do โฆ end to override label/placeholder/required, etc.
4. Use in LiveView
defmodule MyAppWeb.TaskLive.New 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)}
end
def render(assigns) do
~H"""
<.live_component
module={AshFormBuilder.FormComponent}
id="task-create-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
endAshFormBuilder.FormComponent reads the action from form.source and pulls
the right form config (theme, accent, transitions, fields, nested) from the
DSL. You can also override at render time by passing theme={:shadcn},
accent={:rose}, or transitions={:none} directly to the live component.
5. Or scaffold the whole CRUD interface
mix ash_form.gen.live -r MyApp.Todos.Task --accent teal --transitions smooth
Generates a Phoenix LiveView (index.ex) and template (index.html.heex) with
Cinder.collection for the data table and the form modal pre-wired to call
Task.Form.for_create/1 and Task.Form.for_update/2.
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
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
forms do
form :create do
field :tags do
type :multiselect_combobox
opts [
search_event: "search_tags",
debounce: 300,
label_key: :name,
value_key: :id
]
end
end
endMany-to-many relationships render as a
:checkbox_groupby default (vertical list of checkboxes). Settype :multiselect_comboboxexplicitly when you want the searchable / creatable combobox above.
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:
forms do
form :create do
field :tags do
type :multiselect_combobox
opts [
creatable: true, # โ Enable creating
create_action: :create,
create_label: "Create \"",
search_event: "search_tags"
]
end
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
forms do
form :create do
nested :subtasks do
label "Subtasks"
cardinality :many
add_label "Add Subtask"
remove_label "Remove"
field :title do
required true
end
field :done do
type :toggle
end
end
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"
accept :images # or ~w(.jpg .jpeg .png) โ see table below
max_files 1
max_file_size {5, :mb} # also accepts a raw byte integer
cloud MyApp.Buckets.Cloud
end
end
endUpload DSL options (v0.4.0)
Upload options are now first-class DSL fields on field rather than
buried inside opts: [upload: [...]]. The legacy keyword shape is still
honoured, but first-class fields take precedence when both are set.
| Field | Type | Default | Description |
|---|---|---|---|
accept | atom | list | string | :any | :any, a shorthand atom (:images, :documents, :audio, :video), or an explicit list (~w(.jpg .png image/webp)) |
max_files | pos_integer | 1 |
Maximum number of files (Phoenix max_entries) |
max_file_size |
pos_integer | {n, unit} | 8_000_000 |
Bytes, or {10, :mb} / {500, :kb} / {1, :gb} |
cloud | module | nil |
Optional Buckets.Cloud module โ when omitted the upload is consumed in-process |
bucket | string | nil | Optional bucket / prefix passed to the cloud module |
auto_upload | boolean | false |
Mirrors Phoenix LiveView's auto_upload: true |
Shorthand accept values:
| Atom | Expands to |
|---|---|
:images | ~w(.jpg .jpeg .png .gif .webp .svg) |
:documents | ~w(.pdf .doc .docx .txt .md .rtf) |
:audio | ~w(.mp3 .wav .ogg .m4a .flac) |
:video | ~w(.mp4 .mov .webm .avi .mkv) |
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)"
accept :documents
max_files 5
max_file_size {10, :mb}
cloud MyApp.Buckets.Cloud
endCustom action helpers (v0.4.0)
Each declared form :action do โฆ end block generates a matching
Resource.Form.for_<action>/N helper โ including for custom action
names, not just :create / :update. Record-shaped actions
(:update, :destroy) take the record as the first argument.
actions do
create :create do โฆ
update :update do โฆ
update :archive, accept: [] do โฆ
update :publish, accept: [:published_at] do โฆ
end
forms do
form :create do โฆ end
form :update do โฆ end
form :archive do submit_label "Archive" end
form :publish do submit_label "Publish now" end
end# Auto-generated:
MyApp.Posts.Post.Form.for_create() # :create (resource form)
MyApp.Posts.Post.Form.for_update(post) # :update (record form)
MyApp.Posts.Post.Form.for_archive(post, actor: user) # :archive (record form)
MyApp.Posts.Post.Form.for_publish(post, actor: user) # :publish (record form)
# Plus the generic fallback for anything else:
MyApp.Posts.Post.Form.for_action(:custom, record: post, actor: user)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
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 UserThat'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/adminCustomising 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