Powered by AppSignal & Oban Pro

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

guides/relationships_guide.livemd

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

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

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