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
# =============================================================================