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

View Source

ASH FORM BUILDER - TODO APP INTEGRATION GUIDE

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

Complete step-by-step guide: From mix.exs to LiveView CRUD

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

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

STEP 1: ADD DEPENDENCIES (mix.exs)

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

defmodule TodoApp.MixProject do use Mix.Project

def project do

[
  app: :todo_app,
  version: "0.1.0",
  elixir: "~> 1.17",
  elixirc_paths: elixirc_paths(Mix.env()),
  start_permanent: Mix.env() == :prod,
  aliases: aliases(),
  deps: deps()
]

end

def application do

[
  mod: {TodoApp.Application, []},
  extra_applications: [:logger, :runtime_tools]
]

end

defp elixircpaths(:test), do: ["lib", "test/support"] defp elixirc_paths(), do: ["lib"]

defp deps do

[
  # Core Ash Framework
  {:ash, "~> 3.0"},
  {:ash_phoenix, "~> 2.0"},
  {:ash_postgres, "~> 2.0"},

  # Phoenix
  {:phoenix, "~> 1.7.14"},
  {:phoenix_html, "~> 4.0"},
  {:phoenix_live_reload, "~> 1.2", only: :dev},
  {:phoenix_live_view, "~> 1.0.0"},
  {:floki, ">= 0.30.0", only: :test},
  {:phoenix_live_reload, "~> 1.2", only: :dev},

  # ASH FORM BUILDER ⭐
  {:ash_form_builder, path: "../ash_form_builder"},  # Local path
  # OR from git: {:ash_form_builder, git: "https://github.com/nagieeb0/ash_form_builder.git"},
  # OR from hex (when published): {:ash_form_builder, "~> 0.1.0"}

  # UI Components (Optional - for MishkaTheme)
  {:mishka_chelekom, "~> 0.0.8"},

  # Database
  {:ecto_sql, "~> 3.10"},
  {:postgrex, ">= 0.0.0"},

  # Other
  {:telemetry_metrics, "~> 1.0"},
  {:telemetry_poller, "~> 1.0"},
  {:gettext, "~> 0.20"},
  {:jason, "~> 1.2"},
  {:dns_cluster, "~> 0.1.1"},
  {:bandit, "~> 1.5"}
]

end

defp aliases do

[
  setup: ["deps.get", "ecto.setup", "assets.setup", "assets.build"],
  "ecto.setup": ["ecto.create", "ecto.migrate", "run priv/repo/seeds.exs"],
  "ecto.reset": ["ecto.drop", "ecto.setup"],
  test: ["ecto.create --quiet", "ecto.migrate --quiet", "test"]
]

end end

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

STEP 2: CONFIGURATION (config/config.exs)

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

import Config

Configure AshFormBuilder theme

config :ash_form_builder, :theme, AshFormBuilder.Theme.MishkaTheme

OR use default: config :ash_form_builder, :theme, AshFormBuilder.Themes.Default

Configure Ash

config :ash, :include_embedded_source_by_default?, true

Configure your endpoint

config :todo_app, TodoAppWeb.Endpoint, url: [host: "localhost"], adapter: Bandit.PhoenixAdapter, render_errors: [

formats: [html: TodoAppWeb.ErrorHTML, json: TodoAppWeb.ErrorJSON],
layout: false

], pubsub_server: TodoApp.PubSub, live_view: [signing_salt: "your-signing-salt"]

Configure your Repo

config :todoapp, TodoApp.Repo, database: Path.expand("../todoapp_dev.db", Path.dirname(__ENV.file)), stacktrace: true, show_sensitive_data_on_connection_error: true, pool_size: 10, pool: Ecto.Adapters.SQL.Sandbox

Configure Ash domains

config :todo_app, ash_domains: [TodoApp.Todos]

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

STEP 3: ASH DOMAIN (lib/todo_app/todos.ex)

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

defmodule TodoApp.Todos do @moduledoc """ Todos domain - manages tasks, categories, and tags.

## Code Interfaces

This domain generates form helper functions via define :form_to_*:

  • TodoApp.Todos.Task.Form.for_create/1
  • TodoApp.Todos.Task.Form.for_update/2

These helpers integrate seamlessly with AshFormBuilder. """

use Ash.Domain

resources do

# Task resource with form code interfaces
resource TodoApp.Todos.Task do
  # Standard CRUD
  define :list_tasks, action: :read
  define :get_task, action: :read, get_by: [:id]
  define :destroy_task, action: :destroy

  # ⭐ Form Code Interfaces - generates Form helpers
  define :form_to_create_task, action: :create
  define :form_to_update_task, action: :update
end

# Category resource
resource TodoApp.Todos.Category do
  define :list_categories, action: :read
  define :search_categories, action: :read
  define :form_to_create_category, action: :create
end

# Tag resource (creatable on-the-fly)
resource TodoApp.Todos.Tag do
  define :list_tags, action: :read
  define :search_tags, action: :read
  define :form_to_create_tag, action: :create
end

end end

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

STEP 4: ASH RESOURCES

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

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

4.1 TASK RESOURCE (Main Todo Item)

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

defmodule TodoApp.Todos.Task do @moduledoc """ Task resource - represents a single todo item.

Features:

  • Title, description, due date
  • Priority and status enums
  • Many-to-many with Categories
  • Many-to-many with Tags (creatable!) """

use Ash.Resource,

domain: TodoApp.Todos,
data_layer: AshPostgres.DataLayer,
extensions: [AshFormBuilder]  # ⭐ Required for AshFormBuilder

postgres do

table "tasks"
repo TodoApp.Repo

end

# ─────────────────────────────────────────────────────────────────────────── # Attributes # ───────────────────────────────────────────────────────────────────────────

attributes do

uuid_primary_key :id

attribute :title, :string do
  allow_nil? false
  constraints min_length: 1, max_length: 200
end

attribute :description, :text do
  allow_nil? true
end

attribute :completed, :boolean do
  default false
end

attribute :priority, :atom do
  constraints one_of: [:low, :medium, :high, :urgent]
  default :medium
end

attribute :status, :atom do
  constraints one_of: [:pending, :in_progress, :done]
  default :pending
end

attribute :due_date, :date do
  allow_nil? true
end

timestamps()

end

# ─────────────────────────────────────────────────────────────────────────── # Relationships # ───────────────────────────────────────────────────────────────────────────

relationships do

# Many-to-many with Categories (select from existing)
many_to_many :categories, TodoApp.Todos.Category do
  through TodoApp.Todos.TaskCategory
  source_attribute_on_join_resource :task_id
  destination_attribute_on_join_resource :category_id
end

# Many-to-many with Tags (creatable on-the-fly!)
many_to_many :tags, TodoApp.Todos.Tag do
  through TodoApp.Todos.TaskTag
  source_attribute_on_join_resource :task_id
  destination_attribute_on_join_resource :tag_id
end

end

# ─────────────────────────────────────────────────────────────────────────── # Actions # ───────────────────────────────────────────────────────────────────────────

actions do

defaults [:read]

create :create do
  accept [:title, :description, :completed, :priority, :status, :due_date]
  # Manage relationships
  manage_relationship :categories, :categories, type: :append_and_remove
  manage_relationship :tags, :tags, type: :append_and_remove
end

update :update do
  accept [:title, :description, :completed, :priority, :status, :due_date]
  manage_relationship :categories, :categories, type: :append_and_remove
  manage_relationship :tags, :tags, type: :append_and_remove
end

destroy :destroy do
  primary? true
  require_atomic? false
end

end

# ─────────────────────────────────────────────────────────────────────────── # Validations # ───────────────────────────────────────────────────────────────────────────

validations do

validate present([:title])
validate string_length(:title, min: 1, max: 200)
validate expression(:due_date, fn task, _ ->
  if task.due_date && Date.compare(task.due_date, Date.utc_today()) == :lt do
    {:error, "due date cannot be in the past"}
  else
    :ok
  end
end)

end

# ─────────────────────────────────────────────────────────────────────────── # Policies # ───────────────────────────────────────────────────────────────────────────

policies do

policy action_type(:create) do
  authorize_if actor_present()
end

policy action_type(:update) do
  authorize_if actor_present()
end

policy action_type(:destroy) do
  authorize_if actor_present()
end

policy action_type(:read) do
  authorize_if always()
end

end

# =========================================================================== # ASH FORM BUILDER DSL - Form Configuration # ===========================================================================

form do

action :create
submit_label "Create Task"
wrapper_class "space-y-6"

# ────────────────────────────────────────────────────────────────────────
# Standard Fields
# ────────────────────────────────────────────────────────────────────────

field :title do
  label "Task Title"
  placeholder "e.g., Complete project documentation"
  required true
  hint "Keep it concise but descriptive"
end

field :description do
  label "Description"
  type :textarea
  placeholder "Add any additional details..."
  rows 4
  hint "Optional: Add more context about this task"
end

field :priority do
  label "Priority"
  type :select
  options [
    {"Low", :low},
    {"Medium", :medium},
    {"High", :high},
    {"Urgent", :urgent}
  ]
  hint "How important is this task?"
end

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

field :due_date do
  label "Due Date"
  type :date
  hint "When should this be completed?"
end

field :completed do
  label "Completed"
  type :checkbox
  hint "Mark as complete"
end

# ────────────────────────────────────────────────────────────────────────
# Many-to-Many: Categories (NON-CREATABLE)
# ────────────────────────────────────────────────────────────────────────
# Users can only select from existing categories

field :categories do
  type :multiselect_combobox
  label "Categories"
  placeholder "Search categories..."
  required false

  opts [
    search_event: "search_categories",
    debounce: 300,
    label_key: :name,
    value_key: :id,
    hint: "Organize your task into categories"
  ]
end

# ────────────────────────────────────────────────────────────────────────
# Many-to-Many: Tags (CREATABLE!) ⭐
# ────────────────────────────────────────────────────────────────────────
# Users can create new tags on-the-fly

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

  opts [
    # ★ Enable creatable functionality
    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

end end

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

4.2 CATEGORY RESOURCE

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

defmodule TodoApp.Todos.Category do @moduledoc "Category for organizing tasks (e.g., Work, Personal, Shopping)"

use Ash.Resource,

domain: TodoApp.Todos,
data_layer: AshPostgres.DataLayer

postgres do

table "categories"
repo TodoApp.Repo

end

attributes do

uuid_primary_key :id

attribute :name, :string do
  allow_nil? false
  unique true
end

attribute :color, :string do
  default "blue"
  constraints one_of: ["red", "blue", "green", "yellow", "purple", "orange"]
end

attribute :icon, :string do
  allow_nil? true
end

timestamps()

end

relationships do

many_to_many :tasks, TodoApp.Todos.Task do
  through TodoApp.Todos.TaskCategory
  source_attribute_on_join_resource :category_id
  destination_attribute_on_join_resource :task_id
end

end

actions do

defaults [:read, :destroy]

create :create do
  accept [:name, :color, :icon]
end

update :update do
  accept [:name, :color, :icon]
end

end

validations do

validate present([:name])

end end

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

4.3 TAG RESOURCE (Creatable On-the-Fly)

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

defmodule TodoApp.Todos.Tag do @moduledoc "Tag for labeling tasks (e.g., #urgent, #waiting, #5min)"

use Ash.Resource,

domain: TodoApp.Todos,
data_layer: AshPostgres.DataLayer

postgres do

table "tags"
repo TodoApp.Repo

end

attributes do

uuid_primary_key :id

attribute :name, :string do
  allow_nil? false
  unique true
end

timestamps()

end

relationships do

many_to_many :tasks, TodoApp.Todos.Task do
  through TodoApp.Todos.TaskTag
  source_attribute_on_join_resource :tag_id
  destination_attribute_on_join_resource :task_id
end

end

actions do

defaults [:read, :destroy]

create :create do
  accept [:name]
end

update :update do
  accept [:name]
end

end

validations do

validate present([:name])
validate string_length(:name, min: 1, max: 50)

end end

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

4.4 JOIN RESOURCES

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

defmodule TodoApp.Todos.TaskCategory do use Ash.Resource,

domain: TodoApp.Todos,
data_layer: AshPostgres.DataLayer

postgres do

table "tasks_categories"
repo TodoApp.Repo

end

attributes do

uuid_primary_key :id
attribute :task_id, :uuid, allow_nil?: false
attribute :category_id, :uuid, allow_nil?: false

end

relationships do

belongs_to :task, TodoApp.Todos.Task
belongs_to :category, TodoApp.Todos.Category

end end

defmodule TodoApp.Todos.TaskTag do use Ash.Resource,

domain: TodoApp.Todos,
data_layer: AshPostgres.DataLayer

postgres do

table "tasks_tags"
repo TodoApp.Repo

end

attributes do

uuid_primary_key :id
attribute :task_id, :uuid, allow_nil?: false
attribute :tag_id, :uuid, allow_nil?: false

end

relationships do

belongs_to :task, TodoApp.Todos.Task
belongs_to :tag, TodoApp.Todos.Tag

end end

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

STEP 5: PHOENIX LIVEVIEW - TASK FORM

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

defmodule TodoAppWeb.TaskLive.Form do @moduledoc """ LiveView for creating and updating tasks.

Features:

  • Zero manual AshPhoenix.Form calls
  • Automatic form generation via AshFormBuilder
  • Searchable combobox for categories
  • Creatable combobox for tags
  • Real-time validation """

use TodoAppWeb, :live_view

alias TodoApp.Todos alias TodoApp.Todos.Task

# ─────────────────────────────────────────────────────────────────────────── # MOUNT - Initialize Form # ───────────────────────────────────────────────────────────────────────────

@impl true def mount(%{"id" => id} = _params, _session, socket) do

# EDIT MODE: Update existing task
task = Todos.get_task!(id, load: [:categories, :tags], actor: socket.assigns.current_user)

form = Task.Form.for_update(task, actor: socket.assigns.current_user)

{:ok,
 socket
 |> assign(:page_title, "Edit Task")
 |> assign(:form, form)
 |> assign(:task, task)
 |> assign(:mode, :edit)
 |> assign(:category_options, load_options(task.categories))
 |> assign(:tag_options, load_options(task.tags))}

end

def mount(_params, _session, socket) do

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

{:ok,
 socket
 |> assign(:page_title, "New Task")
 |> assign(:form, form)
 |> assign(:task, nil)
 |> assign(:mode, :create)
 |> assign(:category_options, [])
 |> assign(:tag_options, [])}

end

# ─────────────────────────────────────────────────────────────────────────── # RENDER - Form UI # ───────────────────────────────────────────────────────────────────────────

@impl true def render(assigns) do

~H"""
<div class="max-w-2xl mx-auto px-4 py-8">
  <h1 class="text-3xl font-bold mb-6 text-gray-900"><%= @page_title %></h1>

  <div class="bg-white rounded-lg shadow-md p-6">
    <%!--
      AshFormBuilder.FormComponent:
      - Renders all fields from the form DSL
      - Uses configured theme (MishkaTheme)
      - Handles validation errors
      - Manages combobox search & create events
    --%>
    <.live_component
      module={AshFormBuilder.FormComponent}
      id="task-form"
      resource={Task}
      form={@form}
    />
  </div>

  <div class="mt-6 flex justify-between">
    <.link
      href={~p"/tasks"}
      class="text-gray-600 hover:text-gray-900"
    >
      ← Back to Tasks
    </.link>
  </div>
</div>
"""

end

# ─────────────────────────────────────────────────────────────────────────── # SEARCH HANDLERS - Combobox # ───────────────────────────────────────────────────────────────────────────

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

categories =
  TodoApp.Todos.Category
  |> Ash.Query.filter(contains(name: ^query))
  |> Todos.read!(actor: socket.assigns.current_user)

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

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

end

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

# For creatable combobox - search existing tags
# Users can still create new ones via the create button
tags =
  TodoApp.Todos.Tag
  |> Ash.Query.filter(contains(name: ^query))
  |> 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

# ─────────────────────────────────────────────────────────────────────────── # SUCCESS HANDLER - Form Submission # ───────────────────────────────────────────────────────────────────────────

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

message = case socket.assigns.mode do
  :create -> "Task created successfully! 🎉"
  :update -> "Task updated successfully! ✅"
end

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

end

# ─────────────────────────────────────────────────────────────────────────── # PRIVATE HELPERS # ───────────────────────────────────────────────────────────────────────────

defp load_options(records) do

Enum.map(records, &{&1.name, &1.id})

end end

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

STEP 6: PHOENIX LIVEVIEW - TASK INDEX (List View)

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

defmodule TodoAppWeb.TaskLive.Index do @moduledoc "Lists all tasks with create button"

use TodoAppWeb, :live_view

alias TodoApp.Todos alias TodoApp.Todos.Task

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

{:ok, stream(socket, :tasks, Todos.list_tasks())}

end

@impl true def render(assigns) do

~H"""
<div class="max-w-6xl mx-auto px-4 py-8">
  <div class="flex justify-between items-center mb-6">
    <h1 class="text-3xl font-bold text-gray-900">Tasks</h1>
    <.link
      href={~p"/tasks/new"}
      class="bg-blue-600 text-white px-4 py-2 rounded-md hover:bg-blue-700"
    >
      + New Task
    </.link>
  </div>

  <div class="bg-white rounded-lg shadow overflow-hidden">
    <table class="min-w-full divide-y divide-gray-200">
      <thead class="bg-gray-50">
        <tr>
          <th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">Title</th>
          <th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">Priority</th>
          <th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">Status</th>
          <th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">Due Date</th>
          <th class="px-6 py-3 text-right text-xs font-medium text-gray-500 uppercase">Actions</th>
        </tr>
      </thead>
      <tbody class="bg-white divide-y divide-gray-200" id="tasks" phx-update="stream">
        <tr :for={{id, task} <- @streams.tasks} class="hover:bg-gray-50">
          <td class="px-6 py-4 whitespace-nowrap">
            <div class="text-sm font-medium text-gray-900"><%= task.title %></div>
          </td>
          <td class="px-6 py-4 whitespace-nowrap">
            <span class={priority_class(task.priority)}>
              <%= String.capitalize(to_string(task.priority)) %>
            </span>
          </td>
          <td class="px-6 py-4 whitespace-nowrap">
            <span class={status_class(task.status)}>
              <%= String.capitalize(to_string(task.status)) %>
            </span>
          </td>
          <td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
            <%= if task.due_date, do: task.due_date, else: "-" %>
          </td>
          <td class="px-6 py-4 whitespace-nowrap text-right text-sm font-medium">
            <.link href={~p"/tasks/#{task.id}"} class="text-blue-600 hover:text-blue-900 mr-3">
              View
            </.link>
            <.link href={~p"/tasks/#{task.id}/edit"} class="text-green-600 hover:text-green-900">
              Edit
            </.link>
          </td>
        </tr>
      </tbody>
    </table>
  </div>
</div>
"""

end

defp priority_class(:urgent), do: "px-2 py-1 text-xs rounded-full bg-red-100 text-red-800" defp priority_class(:high), do: "px-2 py-1 text-xs rounded-full bg-orange-100 text-orange-800" defp priority_class(:medium), do: "px-2 py-1 text-xs rounded-full bg-yellow-100 text-yellow-800" defp priority_class(:low), do: "px-2 py-1 text-xs rounded-full bg-green-100 text-green-800"

defp status_class(:done), do: "px-2 py-1 text-xs rounded-full bg-green-100 text-green-800" defp status_class(:in_progress), do: "px-2 py-1 text-xs rounded-full bg-blue-100 text-blue-800" defp status_class(:pending), do: "px-2 py-1 text-xs rounded-full bg-gray-100 text-gray-800" end

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

STEP 7: PHOENIX LIVEVIEW - TASK SHOW (View Single Task)

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

defmodule TodoAppWeb.TaskLive.Show do @moduledoc "Displays a single task with details"

use TodoAppWeb, :live_view

alias TodoApp.Todos

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

task = Todos.get_task!(id, load: [:categories, :tags])

{:ok,
 socket
 |> assign(:page_title, task.title)
 |> assign(:task, task)}

end

@impl true def render(assigns) do

~H"""
<div class="max-w-3xl mx-auto px-4 py-8">
  <div class="bg-white rounded-lg shadow-md p-6">
    <div class="flex justify-between items-start mb-4">
      <h1 class="text-3xl font-bold text-gray-900"><%= @task.title %></h1>
      <div class="flex gap-2">
        <.link
          href={~p"/tasks/#{@task.id}/edit"}
          class="bg-green-600 text-white px-4 py-2 rounded-md hover:bg-green-700"
        >
          Edit
        </.link>
      </div>
    </div>

    <div class="space-y-4">
      <div>
        <h3 class="text-sm font-medium text-gray-500">Description</h3>
        <p class="mt-1 text-gray-900"><%= @task.description || "No description" %></p>
      </div>

      <div class="grid grid-cols-2 gap-4">
        <div>
          <h3 class="text-sm font-medium text-gray-500">Priority</h3>
          <p class="mt-1"><%= String.capitalize(to_string(@task.priority)) %></p>
        </div>

        <div>
          <h3 class="text-sm font-medium text-gray-500">Status</h3>
          <p class="mt-1"><%= String.capitalize(to_string(@task.status)) %></p>
        </div>

        <div>
          <h3 class="text-sm font-medium text-gray-500">Due Date</h3>
          <p class="mt-1"><%= if @task.due_date, do: @task.due_date, else: "Not set" %></p>
        </div>

        <div>
          <h3 class="text-sm font-medium text-gray-500">Completed</h3>
          <p class="mt-1"><%= if @task.completed, do: "Yes ✅", else: "No" %></p>
        </div>
      </div>

      <div :if={length(@task.categories) > 0}>
        <h3 class="text-sm font-medium text-gray-500">Categories</h3>
        <div class="mt-2 flex flex-wrap gap-2">
          <span
            :for={category <- @task.categories}
            class="px-3 py-1 text-sm rounded-full bg-blue-100 text-blue-800"
          >
            <%= category.name %>
          </span>
        </div>
      </div>

      <div :if={length(@task.tags) > 0}>
        <h3 class="text-sm font-medium text-gray-500">Tags</h3>
        <div class="mt-2 flex flex-wrap gap-2">
          <span
            :for={tag <- @task.tags}
            class="px-3 py-1 text-sm rounded-full bg-purple-100 text-purple-800"
          >
            #<%= tag.name %>
          </span>
        </div>
      </div>
    </div>
  </div>

  <div class="mt-6">
    <.link href={~p"/tasks"} class="text-gray-600 hover:text-gray-900">
      ← Back to Tasks
    </.link>
  </div>
</div>
"""

end end

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

STEP 8: ROUTES (lib/todo_app_web/router.ex)

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

defmodule TodoAppWeb.Router do use TodoAppWeb, :router

pipeline :browser do

plug :accepts, ["html"]
plug :fetch_session
plug :fetch_live_flash
plug :put_root_layout, html: {TodoAppWeb.Layouts, :root}
plug :protect_from_forgery
plug :put_secure_browser_headers

end

scope "/", TodoAppWeb do

pipe_through :browser

# Task routes
live "/tasks", TaskLive.Index, :index
live "/tasks/new", TaskLive.Form, :new
live "/tasks/:id", TaskLive.Show, :show
live "/tasks/:id/edit", TaskLive.Form, :edit

end end

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

STEP 9: RUNNING THE APP

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

1. Get dependencies:

$ mix deps.get

2. Create and migrate database:

$ mix ecto.setup

3. Start the server:

$ mix phx.server

4. Visit: http://localhost:4000/tasks

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

STEP 10: TESTING

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

test/todo_app_web/live/task_live_test.exs

defmodule TodoAppWeb.TaskLiveTest do use TodoAppWeb.ConnCase

import Phoenix.LiveViewTest import TodoApp.TodosFixtures

alias TodoApp.Todos

describe "Index" do

test "lists all tasks", %{conn: conn} do
  {:ok, _index_live, html} = live(conn, ~p"/tasks")

  assert html =~ "Tasks"
  assert html =~ "New Task"
end

end

describe "Create Task" do

test "renders form", %{conn: conn} do
  {:ok, _index_live, html} = live(conn, ~p"/tasks/new")

  assert html =~ "New Task"
  assert html =~ "Task Title"
end

test "creates task and redirects", %{conn: conn} do
  {:ok, live, _html} = live(conn, ~p"/tasks/new")

  assert form(live, "#task-form", task: %{
    title: "Test Task",
    description: "Test description",
    priority: "high"
  }) |> render_submit()
end

test "creates tag on-the-fly via creatable combobox", %{conn: conn} do
  {:ok, live, _html} = live(conn, ~p"/tasks/new")

  # Simulate creating a new tag
  {:noreply, _updated_socket} =
    AshFormBuilder.FormComponent.handle_event(
      "create_combobox_item",
      %{
        "field" => "tags",
        "resource" => "Elixir.TodoApp.Todos.Tag",
        "action" => "create",
        "creatable_value" => "Create \"urgent\""
      },
      live.socket
    )

  # Verify tag was created
  assert %TodoApp.Todos.Tag{name: "urgent"} =
           Ash.read_one!(TodoApp.Todos.Tag, name: "urgent")
end

end

describe "Edit Task" do

setup [:create_task]

test "renders edit form", %{conn: conn, task: task} do
  {:ok, _index_live, html} = live(conn, ~p"/tasks/#{task.id}/edit")

  assert html =~ "Edit Task"
  assert html =~ task.title
end

test "updates task and redirects", %{conn: conn, task: task} do
  {:ok, live, _html} = live(conn, ~p"/tasks/#{task.id}/edit")

  assert form(live, "#task-form", task: %{
    title: "Updated Task"
  }) |> render_submit()
end

defp create_task(_) do
  task = task_fixture()
  %{task: task}
end

end end

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

END OF TODO APP GUIDE

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