# ============================================================================= # 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 elixirc_paths(: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 :todo_app, TodoApp.Repo, database: Path.expand("../todo_app_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"""
| Title | Priority | Status | Due Date | Actions |
|---|---|---|---|---|
|
<%= task.title %>
|
<%= String.capitalize(to_string(task.priority)) %> | <%= String.capitalize(to_string(task.status)) %> | <%= if task.due_date, do: task.due_date, else: "-" %> | <.link href={~p"/tasks/#{task.id}"} class="text-blue-600 hover:text-blue-900 mr-3"> View <.link href={~p"/tasks/#{task.id}/edit"} class="text-green-600 hover:text-green-900"> Edit |
<%= @task.description || "No description" %>
<%= String.capitalize(to_string(@task.priority)) %>
<%= String.capitalize(to_string(@task.status)) %>
<%= if @task.due_date, do: @task.due_date, else: "Not set" %>
<%= if @task.completed, do: "Yes ✅", else: "No" %>