Dev Series – Phoenix Part 3 – CRUD Generators
Dev Series – Phoenix Part 3 – CRUD Generators
After getting our authentication setup, we will now implement our first feature: CRUD for our exercises.
What are Phoenix Generators?
Phoenix generators are powerful tools that help you quickly scaffold common patterns in web applications. They generate:
- Contexts (business logic layer)
- Schemas (database models)
- Controllers and views
- Templates
- Tests
- Migrations
Planning Our Exercise Feature
For our workout tracking app, we need to manage exercises. An exercise should have:
- Name (string) - e.g., “Push-ups”, “Squats”
- Type (string) - e.g., “timed”, “distance”, “reps”
- Description (text) - detailed description
- Shared (boolean) - whether it’s shared publicly
- User association - belongs to a user
Generating the Exercise Resource
Let’s use Phoenix’s HTML generator to create our Exercise resource:
mix phx.gen.html Repz Workout name:string type:string description:text shared:boolean user_id:references:users
This command generates:
- Context:
lib/repz_exercise/repz.ex
- Schema:
lib/repz_exercise/repz/workout.ex
- Migration:
priv/repo/migrations/*_create_workouts.rb
- Controller:
lib/repz_exercise_web/controllers/workout_controller.ex
- Views:
lib/repz_exercise_web/views/workout_view.ex
- Templates:
lib/repz_exercise_web/templates/workout/
- Tests: Various test files
Examining the Generated Schema
Let’s look at the generated schema in lib/repz_exercise/repz/workout.ex
:
defmodule RepzExercise.Repz.Workout do
use Ecto.Schema
import Ecto.Changeset
@workout_types ["timed", "distance", "reps"]
schema "workouts" do
field :name, :string
field :type, :string
field :description, :string
field :shared, :boolean, default: false
belongs_to :user, RepzExercise.Users.User
timestamps()
end
@doc false
def changeset(workout, attrs) do
workout
|> cast(attrs, [:name, :type, :description, :shared, :user_id])
|> validate_required([:name, :type, :user_id])
|> validate_length(:name, min: 4, max: 100)
|> validate_inclusion(:type, @workout_types,
message: "Invalid workout type. Must be one of: #{Enum.join(@workout_types, ", ")}")
end
end
Understanding the Generated Migration
The migration file creates our workouts table:
defmodule RepzExercise.Repo.Migrations.CreateWorkouts do
use Ecto.Migration
def change do
create table(:workouts) do
add :name, :string
add :type, :string
add :description, :text
add :shared, :boolean, default: false, null: false
add :user_id, references(:users, on_delete: :nothing)
timestamps()
end
create index(:workouts, [:user_id])
end
end
Run the migration:
mix ecto.migrate
Adding Routes
Add the workout routes to your router.ex
:
scope "/", RepzExerciseWeb do
pipe_through [:browser, :protected]
resources "/workouts", WorkoutController
end
Examining the Generated Context
The context in lib/repz_exercise/repz.ex
provides our business logic layer:
defmodule RepzExercise.Repz do
@moduledoc """
The Repz context.
"""
import Ecto.Query, warn: false
alias RepzExercise.Repo
alias RepzExercise.Repz.Workout
@doc """
Returns the list of workouts.
"""
def list_workouts do
Repo.all(Workout)
end
@doc """
Gets a single workout.
"""
def get_workout!(id), do: Repo.get!(Workout, id)
@doc """
Creates a workout.
"""
def create_workout(attrs \\ %{}) do
%Workout{}
|> Workout.changeset(attrs)
|> Repo.insert()
end
@doc """
Updates a workout.
"""
def update_workout(%Workout{} = workout, attrs) do
workout
|> Workout.changeset(attrs)
|> Repo.update()
end
@doc """
Deletes a workout.
"""
def delete_workout(%Workout{} = workout) do
Repo.delete(workout)
end
@doc """
Returns an `%Ecto.Changeset{}` for tracking workout changes.
"""
def change_workout(%Workout{} = workout, attrs \\ %{}) do
Workout.changeset(workout, attrs)
end
end
Scoping Workouts to Current User
We need to modify our context to scope workouts to the current user. Update the context functions:
def list_workouts(user) do
Workout
|> where([w], w.user_id == ^user.id)
|> Repo.all()
end
def get_workout!(id, user) do
Workout
|> where([w], w.user_id == ^user.id)
|> Repo.get!(id)
end
def create_workout(attrs, user) do
%Workout{}
|> Workout.changeset(Map.put(attrs, "user_id", user.id))
|> Repo.insert()
end
Updating the Controller
Modify the generated controller to work with the current user:
defmodule RepzExerciseWeb.WorkoutController do
use RepzExerciseWeb, :controller
alias RepzExercise.Repz
alias RepzExercise.Repz.Workout
def index(conn, _params) do
current_user = Pow.Plug.current_user(conn)
workouts = Repz.list_workouts(current_user)
render(conn, "index.html", workouts: workouts)
end
def show(conn, %{"id" => id}) do
current_user = Pow.Plug.current_user(conn)
workout = Repz.get_workout!(id, current_user)
render(conn, "show.html", workout: workout)
end
def new(conn, _params) do
changeset = Repz.change_workout(%Workout{})
render(conn, "new.html", changeset: changeset)
end
def create(conn, %{"workout" => workout_params}) do
current_user = Pow.Plug.current_user(conn)
case Repz.create_workout(workout_params, current_user) do
{:ok, workout} ->
conn
|> put_flash(:info, "Workout created successfully.")
|> redirect(to: Routes.workout_path(conn, :show, workout))
{:error, %Ecto.Changeset{} = changeset} ->
render(conn, "new.html", changeset: changeset)
end
end
# ... other actions follow similar pattern
end
Customizing Templates
The generated templates provide a good starting point. Let’s customize the index template:
<h1>My Workouts</h1>
<%= link "New Workout", to: Routes.workout_path(@conn, :new), class: "btn btn-primary mb-4" %>
<div class="grid gap-4">
<%= for workout <- @workouts do %>
<div class="card bg-base-100 shadow-xl">
<div class="card-body">
<h2 class="card-title">
<%= workout.name %>
<div class="badge badge-secondary"><%= workout.type %></div>
<%= if workout.shared do %>
<div class="badge badge-accent">Shared</div>
<% end %>
</h2>
<p><%= workout.description %></p>
<div class="card-actions justify-end">
<%= link "View", to: Routes.workout_path(@conn, :show, workout), class: "btn btn-primary btn-sm" %>
<%= link "Edit", to: Routes.workout_path(@conn, :edit, workout), class: "btn btn-secondary btn-sm" %>
<%= link "Delete", to: Routes.workout_path(@conn, :delete, workout), method: :delete,
data: [confirm: "Are you sure?"], class: "btn btn-error btn-sm" %>
</div>
</div>
</div>
<% end %>
</div>
![Workout Index Page][phoenix-part-3/workout-index-page]
Form Template
The form template handles both new and edit actions:
<%= form_for @changeset, @action, fn f -> %>
<%= if @changeset.action do %>
<div class="alert alert-error">
<p>Oops, something went wrong! Please check the errors below.</p>
</div>
<% end %>
<div class="form-control w-full max-w-xs">
<%= label f, :name, class: "label" %>
<%= text_input f, :name, class: "input input-bordered w-full max-w-xs" %>
<%= error_tag f, :name %>
</div>
<div class="form-control w-full max-w-xs">
<%= label f, :type, class: "label" %>
<%= select f, :type, ["timed", "distance", "reps"],
{prompt: "Choose a type"}, class: "select select-bordered w-full max-w-xs" %>
<%= error_tag f, :type %>
</div>
<div class="form-control">
<%= label f, :description, class: "label" %>
<%= textarea f, :description, class: "textarea textarea-bordered h-24" %>
<%= error_tag f, :description %>
</div>
<div class="form-control">
<label class="label cursor-pointer">
<%= checkbox f, :shared, class: "checkbox" %>
<span class="label-text">Share this workout publicly</span>
</label>
<%= error_tag f, :shared %>
</div>
<div class="mt-4">
<%= submit "Save", class: "btn btn-primary" %>
</div>
<% end %>
![Workout Form][phoenix-part-3/workout-form]
Testing Our CRUD Operations
Start your server and test the functionality:
mix phx.server
Viewing the Workout List
Navigate to /workouts
to see the list of workouts:
Creating a New Workout
Click “New Workout” to access the creation form:
Form Validation
The form includes validation. If you try to submit without required fields, you’ll see validation errors:
Workout Type Selection
The type field uses a dropdown with predefined options:
- Create: Navigate to
/workouts/new
and create a new workout - Read: View the list at
/workouts
and individual workouts - Update: Edit existing workouts
- Delete: Remove workouts you no longer need
What’s Next?
In the next part of this series, we’ll:
- Improve the UI using TailwindCSS
- Add more sophisticated styling
- Implement responsive design
- Add interactive elements
Conclusion
We’ve successfully implemented CRUD operations for our workout exercises! Phoenix generators provided us with:
- A solid foundation with proper separation of concerns
- Database schema with validations
- Complete CRUD interface
- User-scoped data access
The generated code follows Phoenix conventions and best practices, giving us a maintainable codebase that we can build upon.
In Part 4, we’ll enhance the user interface using TailwindCSS to make our application more visually appealing and user-friendly.