Dev Series – Phoenix part 3 – CRUD Generators
Published at September 05, 2021
After getting our authentication setup. We will now implement our first feature. CRUD for our exercises.
Our Workout model will have:
- Name – which is self explanatory.
- Description – which is optional here you can describe if this exercise is 10 miles run etc.
- Type – either running, lifting, stretching, yoga, calisthenics, etc
- User – Owner of the exercise.
- Shared – This is if the owner wants to share their progress and it will show on the shared page of the app.
GENERATORS
If you know Ruby on Rails maybe you are familiar with the 15 minutes create a blog demo which uses scaffolding to easily create those features.
Phoenix also provided us with helper generators and we will use it to build our first feature the Workouts.
Let’s run:
$ mix phx.gen.html Exercise Workout workouts name:string type:string description:text shared:boolean user_id:references:users
This will create files that will handle the schema, controller and view for our workout feature.
Now, we need to run the migration to create the workouts table in our database. Run:
$ mix ecto.migrate
SOME HOUSE KEEPING
We then need to add a route that points to /workouts but we have to make sure that it can only be accessed by logged in users.
Pow provide us with Pow.Plug.RequireAuthenticated that we can use in our pipeline to ensure that the user is logged in.
pipeline :protected do
plug Pow.Plug.RequireAuthenticated, error_handler: RepzWeb.AuthErrorHandler
end
scope "/", RepzWeb do
pipe_through [:browser, :protected]
resources "/workouts", WorkoutController
end
We also need to create the module for the error_handler
:
defmodule RepzWeb.AuthErrorHandler do
use RepzWeb, :controller
def call(conn, :not_authenticated) do
conn
|> put_flash(:error, "You must be signed in to access that page.")
|> redirect(to: Routes.pow_session_path(conn, :new))
end
end
Here we pattern match if its :not_authenticated then we raise an error message then redirect it to sign in page.
WORKOUTS
Make sure you we are logged in then visit localhost:4000/workouts:
Yay! It even provide us with a New Workout Form.
It also included validation how cool is that!
Checks and improvements
Here are the checklist that I think we need to improve on this feature:
- Improve the validation and make sure description is not requited.
- Update the form for type to be a dropdown select so we can limit the choices.
- Lastly, we need to associate the creation of workouts to signed in user.
Let’s improve the validation! Phoenix provide us with a schema were we can update and improve our validations:
defmodule Repz.Exercise.Workout do
use Ecto.Schema
import Ecto.Changeset
schema "workouts" do
field :description, :string
field :name, :string
field :shared, :boolean, default: false
field :type, :string
field :user_id, :id
timestamps()
end
@doc false
def changeset(workout, attrs) do
workout
|> cast(attrs, [:name, :type, :description, :shared])
|> validate_required([:name, :type, :description, :shared])
end
end
- we will remove the :description on the validate_required method.
- create an enum for type and make sure it is included on the list.
- make sure name is at least minimum 4 characters long and maximum 100 which is too long to be honest.
defmodule Repz.Exercise.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, Repz.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: #{@workout_types |> Enum.join(", ")}"
)
end
end
Now it doesn’t require description and now only accept types if it is “timed“, “distance”, or “reps”.
But would it be better if the type is a dropdown?
# other form fields
<%= label f, :type %>
<%= select f, :type, ["timed", "distance", "reps"] %>
# other form fields
Lastly we need to update the controller and include the relationship to the current user.
alias Repz.Repo
alias Repz.Exercise.Workout
import Ecto.Query, only: [from: 2]
def index(conn, _params) do
user_id = Pow.Plug.current_user(conn).id
workouts = Repo.all(from w in Workout, where: w.user_id == ^user_id)
render(conn, "index.html", workouts: workouts)
end
def create(conn, %{"workout" => workout_params}) do
user = Pow.Plug.current_user(conn)
workout_params = Map.put(workout_params, "user_id", user.id)
case Exercise.create_workout(workout_params) 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
Here we make sure that current_user is passed on workout_params.
We also added a filter on index to only show owner related workouts.
It also shows a Delete button that completes our CRUD feature!
Summary
We have learned a lot of things building this feature.
- We successfully implemented CRUD operations for workouts.
- We manage and added authentication to our routes so that only logged in user can access the workout feature.
- We improve our form validations and updated our form field.
- Finally, we ensure that each user can only view their own workouts.
Our UI is still rough, so in the next blog, we will be installing Bootstrap, a popular CSS framework, and styling our workout feature. We can even add better navigation! See you in the next article.
Keep on Coding!