skills/bllyanos/phoenix-skills/phx-create-live-resource

phx-create-live-resource

SKILL.md

Create LiveView Resource

This skill generates a complete CRUD Phoenix LiveView resource following your project's conventions.

Workflow Overview

  1. Discovery - Analyze existing patterns, check if schema exists
  2. Schema & Context - Generate if needed using mix phx.gen.schema
  3. LiveView Modules - Create index, show, and form component
  4. Templates - Generate HEEx templates for each view
  5. Routes - Add LiveView routes to router.ex
  6. Optional Features - Add search, pagination, export, soft delete
  7. Verify - Run tests and precommit checks

Phase 1: Discovery

Analyze Project Conventions

Before generating code, analyze the existing project structure:

# Find existing LiveView modules
ls lib/**/live/*_live/

# Check component structure
grep -A 20 "defp " lib/**/components/core_components.ex | head -60

# Review route patterns
grep "live " lib/**/router.ex

Analyze for:

  • Module naming conventions (e.g., UserLive.Index vs UsersLive)
  • Form component patterns (stateful vs stateless)
  • Template organization (separate files vs inline)
  • Tailwind utility patterns used
  • How associations are handled in existing forms

Check Schema Existence

Ask the user:

Does the [ResourceName] schema already exist, or should I create it?

If schema doesn't exist, ask for:

  • Resource name (singular): User, Product, Category
  • Context name: Accounts, Catalog, Inventory
  • Field definitions: name:string, email:string unique, age:integer

If schema exists, identify:

  • Schema module path
  • Context module and available functions
  • Existing associations
  • Changeset validations

Phase 2: Schema Generation (if needed)

Run the Phoenix generator:

mix phx.gen.schema Context ResourceName table_name field1:type field2:type

Examples:

mix phx.gen.schema Accounts User users name:string email:string
mix phx.gen.schema Catalog Product products name:string price:decimal sku:string:unique
mix phx.gen.schema Inventory StockItem stock_items quantity:integer product_id:references:products

Then run migrations:

mix ecto.migrate

Handle Associations

For associations, ask the user first:

For belongs_to:

Does [Resource] belong_to another resource? (e.g., User belongs_to Organization)

If yes, add the reference and generate a select dropdown in forms.

For has_many / many_to_many:

Does [Resource] have many or many_to_many associations?

If yes, set up nested form handling or multi-select inputs.

Phase 3: Generate LiveView Modules

File Structure

Create the following files in lib/app_web/live/[resource_name]_live/:

lib/app_web/live/user_live/
├── index.ex
├── show.ex
├── form_component.ex
├── index.html.heex
├── show.html.heex
└── form_component.html.heex

index.ex Template

defmodule AppWeb.[ResourceName]Live.Index do
  use AppWeb, :live_view

  alias App.[Context]
  alias App.[Context].[ResourceName]

  @impl true
  def mount(_params, _session, socket) do
    {:ok, stream(socket, :[resource_names], Context.list_[resource_names]())}
  end

  @impl true
  def handle_params(params, _url, socket) do
    {:noreply, apply_action(socket, socket.assigns.live_action, params)}
  end

  defp apply_action(socket, :edit, %{"id" => id}) do
    [resource_name] = Context.get_[resource_name]!(id)
    socket
    |> assign(:page_title, "Edit [ResourceName]")
    |> assign(:[resource_name], [resource_name])
  end

  defp apply_action(socket, :new, _params) do
    socket
    |> assign(:page_title, "New [ResourceName]")
    |> assign(:[resource_name], %ResourceName{})
  end

  defp apply_action(socket, :index, _params) do
    socket
    |> assign(:page_title, "Listing [ResourceNames]")
    |> assign(:[resource_name], nil)
  end

  @impl true
  def handle_info({AppWeb.[ResourceName]Live.FormComponent, {:saved, [resource_name]}}, socket) do
    {:noreply, stream_insert(socket, :[resource_names], [resource_name])}
  end

  @impl true
  def handle_event("delete", %{"id" => id}, socket) do
    [resource_name] = Context.get_[resource_name]!(id)
    {:ok, _} = Context.delete_[resource_name]([resource_name])

    {:noreply, stream_delete(socket, :[resource_names], [resource_name])}
  end
end

show.ex Template

defmodule AppWeb.[ResourceName]Live.Show do
  use AppWeb, :live_view

  alias App.[Context]
  alias App.[Context].[ResourceName]

  @impl true
  def mount(_params, _session, socket) do
    {:ok, socket}
  end

  @impl true
  def handle_params(%{"id" => id}, _, socket) do
    {:noreply,
     socket
     |> assign(:page_title, page_title(socket.assigns.live_action))
     |> assign(:[resource_name], Context.get_[resource_name]!(id))}
  end

  defp page_title(:show), do: "Show [ResourceName]"
  defp page_title(:edit), do: "Edit [ResourceName]"

  @impl true
  def handle_event("delete", _params, socket) do
    [resource_name] = socket.assigns.[resource_name]
    {:ok, _} = Context.delete_[resource_name]([resource_name])

    {:noreply,
     socket
     |> put_flash(:info, "[ResourceName] deleted successfully")
     |> push_patch(to: socket.assigns.patch)
    }
  end
end

form_component.ex Template

defmodule AppWeb.[ResourceName]Live.FormComponent do
  use AppWeb, :live_component

  alias App.[Context]
  alias App.[Context].[ResourceName]

  @impl true
  def render(assigns) do
    ~H"""
    <div>
      <header>
        <h1 class="font-semibold text-lg"><%= @title %></h1>
      </header>

      <.form
        :let={form}
        for={@changeset}
        id="[resource_name]-form"
        phx-target={@myself}
        phx-change="validate"
        phx-submit="save"
      >
        <.error :if={@check_errors}>
          Oops, something went wrong! Please check the errors below.
        </.error>

        <%= if @current_action == :new do %>
          <.inputs_for :let={fc} field={form[:field_name]}>
            <.input_field form={fc} field={:field} label="Field Label" type="text" />
          </.inputs_for>
        <% end %>

        <:actions>
          <.button phx-disable-with="Saving...">Save [ResourceName]</.button>
        </:actions>
      </.form>
    </div>
    """
  end

  @impl true
  def update(%{[resource_name]: [resource_name]} = assigns, socket) do
    changeset = Context.change_[resource_name]([resource_name])

    {:ok,
     socket
     |> assign(assigns)
     |> assign_form(changeset)}
  end

  @impl true
  def handle_event("validate", %{"[resource_name]" => [resource_name]_params}, socket) do
    changeset =
      socket.assigns.[resource_name]
      |> Context.change_[resource_name]([resource_name]_params)
      |> Map.put(:action, :validate)

    {:noreply, assign_form(socket, changeset)}
  end

  def handle_event("save", %{"[resource_name]" => [resource_name]_params}, socket) do
    save_[resource_name](socket, socket.assigns.action, [resource_name]_params)
  end

  defp save_[resource_name](socket, :edit, [resource_name]_params) do
    case Context.update_[resource_name](socket.assigns.[resource_name], [resource_name]_params) do
      {:ok, [resource_name]} ->
        notify_parent({:saved, [resource_name]})

        {:noreply,
         socket
         |> put_flash(:info, "[ResourceName] updated successfully")
         |> push_patch(to: socket.assigns.patch)}

      {:error, %Ecto.Changeset{} = changeset} ->
        {:noreply, assign_form(socket, changeset)}
    end
  end

  defp save_[resource_name](socket, :new, [resource_name]_params) do
    case Context.create_[resource_name]([resource_name]_params) do
      {:ok, [resource_name]} ->
        notify_parent({:saved, [resource_name]})

        {:noreply,
         socket
         |> put_flash(:info, "[ResourceName] created successfully")
         |> push_patch(to: socket.assigns.patch)}

      {:error, %Ecto.Changeset{} = changeset} ->
        {:noreply, assign_form(socket, changeset)}
    end
  end

  defp assign_form(socket, %Ecto.Changeset{} = changeset) do
    assign(socket, :changeset, changeset)
  end

  defp notify_parent(msg), do: send(self(), {__MODULE__, msg})
end

Phase 4: Generate Templates

index.html.heex Template

<div>
  <.header>
    Listing [ResourceNames]
    <:actions>
      <.link patch={~p"/[resource_path]/new"}>
        <.button>New [ResourceName]</.button>
      </.link>
    </:actions>
  </.header>

  <.table
    id="[resource_names]"
    rows={@streams.[resource_names]}
    row_click={fn {[id], [resource_name]} -> JS.navigate(~p"/[resource_path]/#{[id]}") end}
  >
    <:col :let={{[id], [resource_name]}} label="Field 1">
      {<[resource_name]>.field1}
    </:col>
    <:col :let={{[id], [resource_name]}} label="Field 2">
      {<[resource_name]>.field2}
    </:col>
    <:col :let={{_id, [resource_name]}} label="" align="right">
      <.link
        patch={~p"/[resource_path]/#{[resource_name]}/edit"}
        class="ml-2"
      >
        Edit
      </.link>
      <.link
        href={~p"/[resource_path]/#{[resource_name]}"}
        method="delete"
        data-confirm="Are you sure?"
        class="ml-2 text-red-600"
      >
        Delete
      </.link>
    </:col>
  </.table>
</div>

show.html.heex Template

<div>
  <.header>
    <%= @page_title %>
    <:actions>
      <.link patch={~p"/[resource_path]/#{@[resource_name]}/show/edit"}>
        <.button>Edit [ResourceName]</.button>
      </.link>
    </:actions>
  </.header>

  <.list>
    <:list_item label="Field 1:">
      {@[resource_name].field1}
    </:list_item>
    <:list_item label="Field 2:">
      {@[resource_name].field2}
    </:list_item>
  </.list>

  <.back navigate={~p"/[resource_path]"}>Back to [resource_names]</.back>
</div>

form_component.html.heex Template

<.form
  :let={form}
  for={@changeset}
  id="[resource_name]-form"
  phx-target={@myself}
  phx-change="validate"
  phx-submit="save"
  class="space-y-4"
>
  <.error :if={@check_errors}>
    Please correct the errors below.
  </.error>

  <%= for field <- @form_fields do %>
    <.input
      field={form[field]}
      type={field_type(field)}
      label={field_label(field)}
    />
  <% end %>

  <:actions>
    <.button phx-disable-with="Saving...">Save [ResourceName]</.button>
  </:actions>
</.form>

Phase 5: Add Routes

Add to router.ex:

scope "/", AppWeb do
  live "/[resource_path]", [ResourceName]Live.Index, :index
  live "/[resource_path]/new", [ResourceName]Live.Index, :new
  live "/[resource_path]/:id/edit", [ResourceName]Live.Index, :edit

  live "/[resource_path]/:id", [ResourceName]Live.Show, :show
  live "/[resource_path]/:id/show/edit", [ResourceName]Live.Show, :edit
end

Phase 6: Optional Features

Before proceeding, ask the user:

Which optional features would you like to add?

[ ] Search/Filter - Live search and column filtering
[ ] Pagination - Page through large datasets
[ ] Export - CSV/Excel export functionality  
[ ] Soft Delete - Mark records as deleted instead of removing

Search/Filter Implementation

Add to index.ex:

@impl true
def handle_event("search", %{"search" => search_params}, socket) do
  [resource_names] = Context.search_[resource_names](search_params)
  {:noreply, stream(socket, :[resource_names], [resource_names], at: 0)}
end

Add search input to index.html.heex:

<form phx-change="search" class="mb-4">
  <.input
    type="text"
    name="search"
    placeholder="Search..."
    value={@search}
  />
</form>

Pagination Implementation

Using Scrivener or cursor-based:

defp apply_action(socket, :index, %{"page" => page}) do
  opts = [page: String.to_integer(page), page_size: 20]
  page = Context.paginate_[resource_names](opts)
  
  socket
  |> assign(:page_title, "Listing [ResourceNames]")
  |> assign(:page, page)
  |> assign(:[resource_name], nil)
end

Export Implementation

@impl true
def handle_event("export", _params, socket) do
  [resource_names] = Context.list_[resource_names]()
  csv = App.Export.to_csv([resource_names])
  
  {:noreply,
   socket
   |> put_flash(:info, "Export generated")
   |> put_root_layout(false)
   |> put_headers([{"content-disposition", "attachment; filename=[resource_names].csv"}])
   |> send_resp(200, csv)}
end

Soft Delete Implementation

Add deleted_at field to schema:

def changeset([resource_name], attrs) do
  [resource_name]
  |> cast(attrs, [:field1, :field2, :deleted_at])
  |> validate_required([:field1, :field2])
end

def soft_delete([resource_name]) do
  [resource_name]
  |> change(%{deleted_at: NaiveDateTime.utc_now()})
  |> Repo.update()
end

Phase 7: Verify

Run verification commands:

# Compile the project
mix compile

# Run tests
mix test

# Run precommit if available
mix precommit

If errors occur, fix them before completing.

UI Convention Detection

Before generating templates, analyze:

  1. Component patterns - Check core_components.ex for existing table, list, form patterns
  2. Tailwind utilities - Note what classes are used (avoid DaisyUI if not present)
  3. Form structure - Inline forms vs separate components
  4. Layout patterns - Header usage, action placement

Apply detected patterns to generated code rather than using generic templates.

Association Handling

belongs_to

Add a select dropdown:

<.input
  field={form[:organization_id]}
  type="select"
  label="Organization"
  prompt="Select an organization"
  options={@organizations}
/>

In update/2:

def update(%{[resource_name]: [resource_name]} = assigns, socket) do
  organizations = App.Accounts.list_organizations() |> Enum.map(&{&1.name, &1.id})
  
  changeset = Context.change_[resource_name]([resource_name])

  {:ok,
   socket
   |> assign(assigns)
   |> assign(:organizations, organizations)
   |> assign_form(changeset)}
end

has_many / many_to_many

Use checkbox group or multi-select:

<.input
  field={form[:category_ids]}
  type="checkbox"
  label="Categories"
  options={@categories}
/>

Common Field Types

Map Ecto types to form inputs:

Ecto Type Input Type
:string text
:text textarea
:integer number
:float number (step="0.01")
:decimal number (step="0.01")
:boolean checkbox
:date date
:time time
:naive_datetime datetime-local
:utc_datetime datetime-local
:id hidden
:references select
:array textarea or multi-select

Checklist

Before marking complete, verify:

  • Schema exists (or created)
  • Context functions available
  • LiveView modules created (index, show, form)
  • Templates created (index, show, form)
  • Routes added to router.ex
  • Associations handled correctly
  • Optional features implemented (if requested)
  • Code compiles without errors
  • Tests pass

Examples

Basic Resource

Create a LiveView resource for User with name:string, email:string

With Associations

Create a LiveView resource for Product with name:string, price:decimal, belongs_to Category

With Optional Features

Create a LiveView resource for Order with status:string, total:decimal, created_at:utc_datetime. Add pagination and export.
Weekly Installs
1
First Seen
14 days ago
Installed on
amp1
cline1
opencode1
cursor1
kimi-cli1
codex1