Dev Series – Phoenix part 3 – CRUD Generators

Published at September 05, 2021

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!