=============================================================================

View Source

ASH FORM BUILDER - RELATIONSHIPS GUIDE

=============================================================================

has_many vs many_to_many: Dynamic forms, filtering, and conditions

=============================================================================

=============================================================================

PART 1: HAS_MANY (Nested Forms - Dynamic Add/Remove)

=============================================================================

#

Use has_many when:

- Child records have their own lifecycle

- You need to manage child attributes (not just the relationship)

- Examples: Task → Subtasks, Order → OrderItems, Post → Comments

#

=============================================================================

defmodule MyApp.Todos.Task do use Ash.Resource,

domain: MyApp.Todos,
data_layer: AshPostgres.DataLayer,
extensions: [AshFormBuilder]

attributes do

uuid_primary_key :id
attribute :title, :string, allow_nil?: false
attribute :status, :atom, constraints: [one_of: [:pending, :in_progress, :done]]

end

relationships do

# ─────────────────────────────────────────────────────────────────────────
# HAS_MANY: Subtasks
# ─────────────────────────────────────────────────────────────────────────
# Each subtask is a full record with its own attributes
has_many :subtasks, MyApp.Todos.Subtask do
  destination_attribute_on_join_resource :task_id
  # Optionally set default on_create actions
end

# HAS_MANY: Checklists (another example)
has_many :checklist_items, MyApp.Todos.ChecklistItem

end

actions do

create :create do
  accept [:title, :status]
  # Manage nested records
  manage_relationship :subtasks, :subtasks, type: :create
  manage_relationship :checklist_items, :checklist_items, type: :create
end

update :update do
  accept [:title, :status]
  manage_relationship :subtasks, :subtasks, type: :create_and_destroy
  manage_relationship :checklist_items, :checklist_items, type: :create_and_destroy
end

end

# =========================================================================== # FORM DSL - HAS_MANY WITH NESTED FORMS # ===========================================================================

form do

action :create

field :title do
  label "Task Title"
  required true
end

field :status do
  type :select
  options: [{"Pending", :pending}, {"In Progress", :in_progress}, {"Done", :done}]
end

# ─────────────────────────────────────────────────────────────────────────
# NESTED FORM: Subtasks (has_many)
# ─────────────────────────────────────────────────────────────────────────
#
# Features:
# - Dynamic add/remove buttons
# - Each subtask is a full form with its own fields
# - Can set min/max items
# - Can pre-populate with existing records
# ─────────────────────────────────────────────────────────────────────────

nested :subtasks do
  label "Subtasks"
  cardinality :many  # ← :many = add/remove buttons, :one = single nested form

  # Button labels
  add_label "Add Subtask"
  remove_label "Remove"

  # Optional: Limit number of items
  # min_items 1       # ← At least 1 subtask required
  # max_items 10      # ← Maximum 10 subtasks

  # Optional: CSS customization
  # class "nested-subtasks border rounded p-4"

  # Subtask fields
  field :title do
    label "Subtask"
    placeholder "e.g., Research competitors"
    required true
  end

  field :description do
    label "Notes"
    type :textarea
    rows 2
  end

  field :completed do
    label "Done?"
    type :checkbox
  end

  field :priority do
    label "Priority"
    type :select
    options: [
      {"Low", :low},
      {"Medium", :medium},
      {"High", :high}
    ]
  end
end

# ─────────────────────────────────────────────────────────────────────────
# NESTED FORM: Checklist Items (has_many - simple)
# ─────────────────────────────────────────────────────────────────────────

nested :checklist_items do
  label "Checklist"
  cardinality :many
  add_label "Add Item"

  field :text do
    label "Item"
    required true
  end

  field :checked do
    label "Checked"
    type :checkbox
  end
end

end end

─────────────────────────────────────────────────────────────────────────────

SUBTASK RESOURCE

─────────────────────────────────────────────────────────────────────────────

defmodule MyApp.Todos.Subtask do use Ash.Resource,

domain: MyApp.Todos,
data_layer: AshPostgres.DataLayer

attributes do

uuid_primary_key :id
attribute :title, :string, allow_nil?: false
attribute :description, :text
attribute :completed, :boolean, default: false
attribute :priority, :atom, constraints: [one_of: [:low, :medium, :high]]
attribute :position, :integer, default: 0  # For ordering

end

relationships do

belongs_to :task, MyApp.Todos.Task

end

actions do

create :create do
  accept [:title, :description, :completed, :priority, :position]
end

update :update do
  accept [:title, :description, :completed, :priority, :position]
end

destroy :destroy do
  primary? true
end

end end

=============================================================================

PART 2: MANY_TO_MANY (Combobox - Select from Existing)

=============================================================================

#

Use many_to_many when:

- Linking to existing records

- The related record has independent lifecycle

- Examples: Task → Users (assignees), Task → Tags, Product → Categories

#

=============================================================================

defmodule MyApp.Todos.Task do # ... (previous code)

relationships do

# ─────────────────────────────────────────────────────────────────────────
# MANY_TO_MANY: Assignees (Users)
# ─────────────────────────────────────────────────────────────────────────
# Select from existing users, cannot create users from task form
many_to_many :assignees, MyApp.Accounts.User do
  through MyApp.Todos.TaskAssignee
  source_attribute_on_join_resource :task_id
  destination_attribute_on_join_resource :user_id
end

# ─────────────────────────────────────────────────────────────────────────
# MANY_TO_MANY: Tags (Creatable!)
# ─────────────────────────────────────────────────────────────────────────
# Select existing OR create new tags on-the-fly
many_to_many :tags, MyApp.Todos.Tag do
  through MyApp.Todos.TaskTag
  source_attribute_on_join_resource :task_id
  destination_attribute_on_join_resource :tag_id
end

# ─────────────────────────────────────────────────────────────────────────
# MANY_TO_MANY: Related Tasks
# ─────────────────────────────────────────────────────────────────────────
# Self-referential: tasks can link to other tasks
many_to_many :related_tasks, MyApp.Todos.Task do
  through MyApp.Todos.TaskRelation
  source_attribute_on_join_resource :from_task_id
  destination_attribute_on_join_resource :to_task_id
end

end

# =========================================================================== # FORM DSL - MANY_TO_MANY FIELDS # ===========================================================================

form do

action :create

# ─────────────────────────────────────────────────────────────────────────
# STANDARD COMBOBOX: Select from existing users
# ─────────────────────────────────────────────────────────────────────────

field :assignees do
  type :multiselect_combobox
  label "Assignees"
  placeholder "Search users..."

  opts [
    # Search event for LiveView handler
    search_event: "search_users",
    debounce: 300,

    # Field mappings
    label_key: :name,     # Display user.name
    value_key: :id,       # Use user.id as value

    # Optional: Preload options (for small datasets < 100)
    # preload_options: fn -> MyApp.Accounts.list_users() |> Enum.map(&{&1.name, &1.id}) end

    hint: "Who should work on this task?"
  ]
end

# ─────────────────────────────────────────────────────────────────────────
# CREATABLE COMBOBOX: Tags (create on-the-fly)
# ─────────────────────────────────────────────────────────────────────────

field :tags do
  type :multiselect_combobox
  label "Tags"
  placeholder "Search or create tags..."

  opts [
    # ★ Enable creating new tags
    creatable: true,
    create_action: :create,
    create_label: "Create \"",

    search_event: "search_tags",
    debounce: 300,
    label_key: :name,
    value_key: :id,

    hint: "Add tags or create new ones instantly"
  ]
end

# ─────────────────────────────────────────────────────────────────────────
# FILTERED COMBOBOX: Related Tasks
# ─────────────────────────────────────────────────────────────────────────
# Only show tasks that meet certain criteria

field :related_tasks do
  type :multiselect_combobox
  label "Related Tasks"
  placeholder "Search tasks..."

  opts [
    search_event: "search_related_tasks",
    debounce: 300,
    label_key: :title,
    value_key: :id,

    # Pass metadata for filtering
    filter_params: [
      exclude_completed: true,
      exclude_self: true  # Don't show current task
    ],

    hint: "Link to related tasks"
  ]
end

end end

=============================================================================

PART 3: LIVEVIEW - HANDLING SEARCH & FILTERING

=============================================================================

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

alias MyApp.Todos alias MyApp.Todos.Task

# ─────────────────────────────────────────────────────────────────────────── # MOUNT - With Preloaded Options # ───────────────────────────────────────────────────────────────────────────

def mount(%{"id" => id}, _session, socket) do

# EDIT: Load existing task with relationships
task = Todos.get_task!(id, load: [:assignees, :tags, :subtasks, :checklist_items])
form = Task.Form.for_update(task, actor: socket.assigns.current_user)

{:ok,
 socket
 |> assign(:form, form)
 |> assign(:task, task)
 # Preload some combobox options if needed
 |> assign(:user_options, load_user_options())}

end

def mount(_params, _session, socket) do

# CREATE: New task
form = Task.Form.for_create(actor: socket.assigns.current_user)

{:ok,
 socket
 |> assign(:form, form)
 |> assign(:user_options, load_user_options())}

end

# ─────────────────────────────────────────────────────────────────────────── # SEARCH HANDLERS - With Filtering Logic # ───────────────────────────────────────────────────────────────────────────

@impl true def handle_event("search_users", %{"query" => query}, socket) do

# FILTER: Only active users, search by name/email
users =
  MyApp.Accounts.User
  |> Ash.Query.filter(status == :active)  # ← Condition 1
  |> Ash.Query.filter(contains(name: ^query) or contains(email: ^query))
  |> Ash.Query.limit(20)  # ← Limit results
  |> Todos.read!(actor: socket.assigns.current_user)

options = Enum.map(users, &{&1.name, &1.id})

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

end

@impl true def handle_event("search_tags", %{"query" => query}, socket) do

# For creatable combobox - search existing tags
tags =
  MyApp.Todos.Tag
  |> Ash.Query.filter(contains(name: ^query))
  |> Ash.Query.limit(50)
  |> Todos.read!(actor: socket.assigns.current_user)

options = Enum.map(tags, &{&1.name, &1.id})

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

end

@impl true def handle_event("search_related_tasks", %{"query" => query, "filter_params" => filter_params}, socket) do

# FILTER: Complex query with multiple conditions
query_builder = MyApp.Todos.Task |> Ash.Query.filter(id != ^socket.assigns.task.id)

# Apply filters from filter_params
query_builder =
  if filter_params["exclude_completed"] == "true" do
    Ash.Query.filter(query_builder, status != :done)
  else
    query_builder
  end

query_builder =
  if filter_params["exclude_self"] == "true" do
    Ash.Query.filter(query_builder, id != ^socket.assigns.task.id)
  else
    query_builder
  end

# Search by title
tasks =
  query_builder
  |> Ash.Query.filter(contains(title: ^query))
  |> Ash.Query.order_by(created_at: :desc)
  |> Ash.Query.limit(20)
  |> Todos.read!(actor: socket.assigns.current_user)

options = Enum.map(tasks, &{&1.title, &1.id})

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

end

# ─────────────────────────────────────────────────────────────────────────── # CREATE NEW ITEM (Creatable Combobox) # ───────────────────────────────────────────────────────────────────────────

@impl true def handle_event("create_combobox_item", %{

"field" => "tags",
"creatable_value" => tag_name

}, socket) do

# Create new tag on-the-fly
case Todos.create_tag(%{name: tag_name}, actor: socket.assigns.current_user) do
  {:ok, new_tag} ->
    # Add to current selection
    form = socket.assigns.form.source
    current_tags = AshPhoenix.Form.value(form, :tags) || []
    updated_tags = Enum.uniq(current_tags ++ [new_tag.id])

    form = AshPhoenix.Form.validate(form, %{tags: updated_tags})

    {:noreply, assign(socket, form: to_form(form))}

  {:error, changeset} ->
    # Handle error (e.g., duplicate name)
    {:noreply, put_flash(socket, :error, "Could not create tag: #{inspect(changeset.errors)}")}
end

end

# ─────────────────────────────────────────────────────────────────────────── # SUCCESS HANDLER # ───────────────────────────────────────────────────────────────────────────

@impl true def handle_info({:form_submitted, Task, task}, socket) do

{:noreply,
 socket
 |> put_flash(:info, "Task saved successfully!")
 |> push_navigate(to: ~p"/tasks/#{task.id}")}

end

# ─────────────────────────────────────────────────────────────────────────── # HELPERS # ───────────────────────────────────────────────────────────────────────────

defp load_user_options do

# Preload active users for small teams
MyApp.Accounts.User
|> Ash.Query.filter(status == :active)
|> Ash.Query.limit(100)
|> Todos.read!()
|> Enum.map(&{&1.name, &1.id})

end end

=============================================================================

PART 4: ADVANCED - CONDITIONAL & DYNAMIC BEHAVIOR

=============================================================================

─────────────────────────────────────────────────────────────────────────────

4.1 CONDITIONAL NESTED FORMS

─────────────────────────────────────────────────────────────────────────────

Show/hide nested forms based on parent field value

defmodule MyApp.Todos.Project do use Ash.Resource,

domain: MyApp.Todos,
extensions: [AshFormBuilder]

attributes do

uuid_primary_key :id
attribute :name, :string, allow_nil?: false
attribute :type, :atom, constraints: [one_of: [:simple, :complex]]

end

relationships do

has_many :phases, MyApp.Todos.Phase
has_many :tasks, MyApp.Todos.Task

end

actions do

create :create do
  accept [:name, :type]
  manage_relationship :phases, :phases, type: :create
  manage_relationship :tasks, :tasks, type: :create
end

end

form do

action :create

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

field :type do
  label "Project Type"
  type :select
  options: [
    {"Simple (No Phases)", :simple},
    {"Complex (With Phases)", :complex}
  ]
  # This will trigger conditional rendering in LiveView
  phx_change: "type_changed"
end

# ────────────────────────────────────────────────────────────────────────
# CONDITIONAL: Only show phases for complex projects
# ────────────────────────────────────────────────────────────────────────
#
# In LiveView, you can conditionally render:
#
# <%= if @form.source.data.type == :complex do %>
#   <.nested_form :for={phase <- @form[:phases]} ... />
# <% end %>
#
# Or use phx-change to show/hide dynamically

nested :phases do
  label "Project Phases"
  cardinality :many

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

  field :order do
    label "Order"
    type :number
  end
end

end end

─────────────────────────────────────────────────────────────────────────────

4.2 DYNAMIC LIMITS - Min/Max Nested Items

─────────────────────────────────────────────────────────────────────────────

defmodule MyApp.Todos.Event do use Ash.Resource,

domain: MyApp.Todos,
extensions: [AshFormBuilder]

attributes do

uuid_primary_key :id
attribute :name, :string, allow_nil?: false

end

relationships do

has_many :speakers, MyApp.Todos.Speaker

end

form do

action :create

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

# ────────────────────────────────────────────────────────────────────────
# NESTED WITH LIMITS
# ────────────────────────────────────────────────────────────────────────

nested :speakers do
  label "Speakers"
  cardinality :many

  # ★ Enforce minimum 1 speaker
  # Note: You'll need to validate this in your action
  # validate present(:speakers) or length(:speakers) >= 1

  # ★ Hide add button after max reached
  # In LiveView:
  # <%= if length(@form[:speakers].value) < 5 do %>
  #   <button phx-click="add_form">Add Speaker</button>
  # <% end %>

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

  field :title do
    label "Title"
    placeholder "e.g., CEO at Acme Corp"
  end
end

end end

─────────────────────────────────────────────────────────────────────────────

4.3 FILTERED OPTIONS - Query-Based Limiting

─────────────────────────────────────────────────────────────────────────────

defmodule MyApp.Todos.Meeting do use Ash.Resource,

domain: MyApp.Todos,
extensions: [AshFormBuilder]

attributes do

uuid_primary_key :id
attribute :title, :string, allow_nil?: false
attribute :meeting_type, :atom, constraints: [one_of: [:internal, :external, :all_hands]]

end

relationships do

# Only show users from same organization
many_to_many :attendees, MyApp.Accounts.User do
  through MyApp.Todos.MeetingAttendee
end

# Only show rooms that are available
many_to_many :rooms, MyApp.Resources.Room do
  through MyApp.Todos.MeetingRoom
end

end

form do

action :create

field :title do
  label "Meeting Title"
  required true
end

field :meeting_type do
  label "Meeting Type"
  type :select
  options: [
    {"Internal", :internal},
    {"External", :external},
    {"All Hands", :all_hands}
  ]
  # Trigger filter update when changed
  phx_change: "meeting_type_changed"
end

# ────────────────────────────────────────────────────────────────────────
# FILTERED: Attendees by organization
# ────────────────────────────────────────────────────────────────────────

field :attendees do
  type :multiselect_combobox
  label "Attendees"
  placeholder "Search attendees..."

  opts [
    search_event: "search_attendees",
    debounce: 300,
    label_key: :name,
    value_key: :id,
    # Pass filter params to search handler
    filter_params: [
      organization_id: :current_user_org,  # Special value resolved in LiveView
      active_only: true
    ]
  ]
end

# ────────────────────────────────────────────────────────────────────────
# FILTERED: Rooms by capacity and availability
# ────────────────────────────────────────────────────────────────────────

field :rooms do
  type :multiselect_combobox
  label "Rooms"
  placeholder "Search rooms..."

  opts [
    search_event: "search_rooms",
    debounce: 300,
    label_key: :name,
    value_key: :id,
    filter_params: [
      min_capacity: 10,  # Could be dynamic based on attendee count
      available: true
    ]
  ]
end

end end

LiveView handler for filtered attendees

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

@impl true def handle_event("search_attendees", %{"query" => query}, socket) do

# Get current user's organization
current_user = socket.assigns.current_user
org_id = current_user.organization_id

# Filter by organization and active status
users =
  MyApp.Accounts.User
  |> Ash.Query.filter(organization_id == ^org_id)
  |> Ash.Query.filter(status == :active)
  |> Ash.Query.filter(contains(name: ^query) or contains(email: ^query))
  |> Ash.Query.limit(50)
  |> MyApp.Accounts.read!()

options = Enum.map(users, &{&1.name, &1.id})

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

end

@impl true def handle_event("search_rooms", %{"query" => query}, socket) do

# Get filter params from form
# In real app, calculate based on attendee count
min_capacity = 10

rooms =
  MyApp.Resources.Room
  |> Ash.Query.filter(capacity >= ^min_capacity)
  |> Ash.Query.filter(available == true)
  |> Ash.Query.filter(contains(name: ^query))
  |> Ash.Query.limit(20)
  |> MyApp.Resources.read!()

options = Enum.map(rooms, &{&1.name, &1.id})

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

end end

─────────────────────────────────────────────────────────────────────────────

4.4 DYNAMIC PRELOADING - Load Options on Mount

─────────────────────────────────────────────────────────────────────────────

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

@impl true def mount(_params, _session, socket) do

form = Task.Form.for_create(actor: socket.assigns.current_user)

# Preload options for small datasets
# This avoids initial empty combobox
{:ok,
 socket
 |> assign(:form, form)
 |> assign(:initial_tag_options, preload_tags(""))
 |> assign(:initial_user_options, preload_active_users(""))}

end

@impl true def handle_event("search_tags", %{"query" => query}, socket) do

# For creatable combobox, always allow creating even if no results
tags = preload_tags(query)
options = Enum.map(tags, &{&1.name, &1.id})

{:noreply, push_event(socket, "update_combobox_options", %{
  field: "tags",
  options: options,
  # Tell frontend this is creatable
  creatable: true
})}

end

defp preload_tags(query) do

MyApp.Todos.Tag
|> Ash.Query.filter(contains(name: ^query))
|> Ash.Query.limit(50)
|> MyApp.Todos.read!()

end

defp preload_active_users(query) do

MyApp.Accounts.User
|> Ash.Query.filter(status == :active)
|> Ash.Query.filter(contains(name: ^query))
|> Ash.Query.limit(100)
|> MyApp.Accounts.read!()

end end

=============================================================================

PART 5: SUMMARY - HAS_MANY vs MANY_TO_MANY

=============================================================================

#

┌─────────────────────────────────────────────────────────────────────────┐

│ HAS_MANY (Nested Forms) │

├─────────────────────────────────────────────────────────────────────────┤

│ • Child records have independent lifecycle │

│ • You manage child attributes in the form │

│ • Dynamic add/remove with nested forms │

│ • Examples: Subtasks, OrderItems, Comments │

│ │

│ Usage: │

│ nested :subtasks do │

│ cardinality :many │

│ field :title, required: true │

│ end │

└─────────────────────────────────────────────────────────────────────────┘

#

┌─────────────────────────────────────────────────────────────────────────┐

│ MANY_TO_MANY (Combobox) │

├─────────────────────────────────────────────────────────────────────────┤

│ • Link to existing independent records │

│ • Select from searchable dropdown │

│ • Can be creatable (create on-the-fly) │

│ • Examples: Tags, Assignees, Categories │

│ │

│ Usage: │

│ field :tags do │

│ type :multiselect_combobox │

│ opts [creatable: true, search_event: "search_tags"] │

│ end │

└─────────────────────────────────────────────────────────────────────────┘

#

┌─────────────────────────────────────────────────────────────────────────┐

│ FILTERING & LIMITING │

├─────────────────────────────────────────────────────────────────────────┤

│ • Search handlers filter results via Ash.Query │

│ • Pass filter_params through combobox opts │

│ • Limit results with Ash.Query.limit() │

│ • Conditional rendering in LiveView based on field values │

│ • Min/max items enforced in UI and validated in actions │

└─────────────────────────────────────────────────────────────────────────┘

#

=============================================================================

END OF RELATIONSHIPS GUIDE

=============================================================================