Project Scope

This application is a subset of gist functionality from the github.com website.

  1. allows users to create, read, update, and delete gists.
  2. All gists are public and can be accessed by anyone.
  3. Only the owner of a gist can update or delete it.
  4. Users can comment on gists.
  5. The database is a simple sqlite3 database.
  6. We will not be auto generating the live view files, but we will be creating the schemas, contexts, and migrations.

Preamble

Install hex

mix local.hex

Install phoenix

mix archive.install hex phx_new
mix phx.new --version

Create a new phoenix project

mix phx.new elixir_gist --database sqlite3
cd elixir_gist

Create a new phoenix project with binary_id and without installing dependencies

mix phx.new elixir_gist --no-install --binary-id --database sqlite3
cd elixir_gist
mix deps.get

Setup Authentication

mix phx.gen.auth Accounts User users
mix deps.get
mix ecto.setup

Start the Phoenix server

mix phx.server

or

iex -S mix phx.server

There are a few urls under /dev/ that you can use to test the application, when running from localhost. For example you can view a fake email inbox for the logged in user at /dev/mailbox. Check out the registration email

Create Schemas and Migration files

We will be creating the schemas using phx.gen.context this will create the schema, context, and migration files and some tests.

It will not create the views, controllers, or templates.

Create Gists

mix phx.gen.context Gists Gist gists user_id:references:users name:string description:text markup_test:text

In the migration file, change this line from

add :user_id, references(:users, on_delete: :nothing, type: :binary_id)

to delete gists when a user is deleted.

add :user_id, references(:users, on_delete: :delete_all, type: :binary_id)

Create Saved Gists

mix phx.gen.context Gists SavedGist saved_gists user_id:references:users gist_id:references:gists

WARNING

phoenix will complain that we are creatng SavedGists in the Gists context, and that this is not a good practice. We will ignore this warning for now.

In the migration file, change these lines from

add :user_id, references(:users, on_delete: :nothing, type: :binary_id)
add :gist_id, references(:gists, on_delete: :nothing, type: :binary_id)

to

add :user_id, references(:users, on_delete: :delete_all, type: :binary_id)
add :gist_id, references(:gists, on_delete: :delete_all, type: :binary_id)

Create Comments

mix phx.gen.context Comments Comment comments user_id:references:users gist_id:references:gists markup_text:text

In the migration file, change these lines from

add :user_id, references(:users, on_delete: :nothing, type: :binary_id)
add :gist_id, references(:gists, on_delete: :nothing, type: :binary_id)

to

add :user_id, references(:users, on_delete: :delete_all, type: :binary_id)
add :gist_id, references(:gists, on_delete: :delete_all, type: :binary_id)

Some Pre Migrate User Changes

Schema Changes

Alter the user.ex and add

has_many :gists, ElixirGist.Gists.Gist

Some Pre Migrate Gist Changes

Schema Changes

Alter the gist.ex and change

field :user_id, :binary_id

and

|> cast(attrs, [:name, :description, :markup_test])
|> validate_required([:name, :description, :markup_test])

to

belongs_to :user, ElixirGist.Accounts.User
has_many :comments, ElixirGist.Comments.Comment

and

|> cast(attrs, [:name, :description, :markup_test, user_id])
|> validate_required([:name, :description, :markup_test, user_id])

Repo Changes

Alter the gists.ex and change

def create_gist(attrs \\ %{}) do
    %Gist{}
    |> Gist.changeset(attrs)
    |> Repo.insert()
end
 

to

def create_gist(user, attrs \\ %{}) do
    user
    ## Associates the user with the gist schema
    |> Ecto.build_assoc(:gists)
    |> Gist.changeset(attrs)
    |> Repo.insert()
end

Some Pre Migrate SavedGist Changes

Schema Changes

Alter the saved_gist.ex and change

field :user_id, :binary_id
field :gist_id, :binary_id

and

|> cast(attrs, [])
|> validate_required([])

to

belongs_to :user, ElixirGist.Accounts.User
belongs_to :gist, ElixirGist.Gists.Gist

and

|> cast(attrs, [:user_id, :gist_id])
|> validate_required([:user_id, :gist_id])

Repo Changes

Alter the saved_gists.ex and change

def create_saved_gist(attrs \\ %{}) do
    %SavedGist{}
    |> SavedGist.changeset(attrs)
    |> Repo.insert()
end

to

def create_saved_gist(user, attrs \\ %{}) do
    user
    ## Associates the user with the saved_gist schema
    |> Ecto.build_assoc(:saved_gists)
    |> SavedGist.changeset(attrs)
    |> Repo.insert()
end

Some Pre Migrate Comment Changes

Schema Changes

Alter the comment.ex and change

field :user_id, :binary_id
field :gist_id, :binary_id

and

|> cast(attrs, [:markup_text])
|> validate_required([:markup_text])

to

belongs_to :user, ElixirGist.Accounts.User
belongs_to :gist, ElixirGist.Gists.Gist

and

|> cast(attrs, [:user_id, :gist_id, :markup_text])
|> validate_required([:user_id, :gist_id, :markup_text])

Repo Changes

Alter the comments.ex and change

def create_comment(attrs \\ %{}) do
    %Comment{}
    |> Comment.changeset(attrs)
    |> Repo.insert()
end

to

def create_comment(user, attrs \\ %{}) do
    user
    ## Associates the user with the comment schema
    |> Ecto.build_assoc(:comments)
    |> Comment.changeset(attrs)
    |> Repo.insert()
end

Migrate the database

mix ecto.migrate

Setup Colors and fonts using

IMPORTANT

  • link assets
  • Copy the image assets to priv/static/images folder
  • copy the fonts assets to priv/static/fonts
  • link to the css file
  • link to taiwind file

Setup the layout

We did not include live view in the project, so we will be using the default layout. Which is a simple layout with a header, footer, and main content area.

This content lives in the lib/elixir_gist_web/controllers/page_html/home.html.eex file.

IMPORTANT

Lets delete the default html content for now, but keep the file.

In page_controller.ex change

    render(conn, :home, layout: false)

to

    render(conn, :home)

this will remove the default layout, and we will be able to see the default navigation bar.

Long Story short, in this simple app all code ill reside in the app.html.hex file, so we don’t need home.html.heex and we don’t need the nav bar in the app.html.hex file.

so

  • Delete app.html.heex contents
  • Delete home.html.heex file

We should have a blank page now.

Build Navigation bar

in app.html.heex add the following code

<header class="flex justify-between items-center px-6 py-3 bg-emDark">
  <div class="flex relative">
    <a href={~p"/"}>
      <img src="/images/gist-logo.svg" alt="Logo" class="h-8 w-auto" />
    </a>
    <a href={~p"/"} class="mr-6">
      <div class="text-white font-brand font-bold text-3xl">Gist</div>
    </a>
    <div>
      <input
        type="text"
        class="rounded-lg focus:outline-none focus:border-emLavender focus:ring-0 px-3 py-1 bg-emDark-dark placeholder-emDark-light text-white font-brand font-regular text-sm mr-5 border-white"
        placeholder="Search..."
      />
      <button class="mt-2 mr-2 text-white text-[1rem] font-brand font-bold hover:text-emDark-light">
        All gists
      </button>
    </div>
  </div>
  <div class="relative">
    <button class="img-down-arrow" type="button" id="user-menu-button">
      <img src="/images/user-image.svg" alt="Profile Image" class="round-image-padding w-8 h-8" />
    </button>
  </div>
</header>

and adding this css to the app.css file

.round-image-padding {
    border-radius: 50%;
    border: 1.5px solid #FFFFFF;
    padding: 4px;
}
 
.img-down-arrow::after {
    content: "";
    position: absolute;
    right: -10px;
    top: 45%;
    transform: translateY(-50%);
    border-width: 3px 3px 0 3px;
    border-color: white transparent transparent transparent;
    border-style: solid;
}
defmodule ElixirGistWeb.Layouts.App do
  alias Phoenix.LiveView.JS
 
  def toggle_dropdown_menu do
    JS.toggle(
      to: "#dropdown_menu",
      in:
        {"transition ease-out duration-100", "transform opacity-0 translate-y-[-10%]",
         "transform opacity-100 translate-y-0"},
      out:
        {"transition ease-in duration-75", "transform opacity-100 translate-y-0",
         "transform opacity-0 translate-y-[-10%]"}
    )
  end
end

Creat Gist Live View

In the live folder create a new file name create_gist_live.ex and add the following code.

defmodule ElixirGistWeb.CreateGistLive do
  use ElixirGistWeb, :live_view
 
  def mount(_params, _session, socket) do
    {:ok, socket}
  end
 
  def handle_params(params, _uri, socket) do
    {:noreply, socket}
  end
 
  def render(assigns) do
    ~H"""
    <.flash_group flash={@flash} />
    <div class="em-gradient flex items-center justify-center">
    <h1 class="font-brand font-bold text-3xl text-white">
        Instantly share Elixir code, notes, and snippets
    </h1>
    </div>
    """
  end
end
 

Add the live view to the router

In the router.ex file add the following code under the live_session with :require_authenticated_user

live "/create", CreateGistLive

Update the page controller

In the page_controller.ex file add the following code so that the controller looks liek this

defmodule ElixirGistWeb.PageController do
  use ElixirGistWeb, :controller
 
  def home(conn, _params) do
    redirect(conn, to: "/create")
  end
end

Add the gist form

In the create_gist_live.ex file add the following code to the render function

    <.form for={@form}>
      <div class="justify-center px-28 w-full space-y-4 mb-10">
        <.input field={@form[:description]} placeholder="Gist description.." autocomplete="off" />
        <div>
          <div class="flex p-2 items-center bg-emDark rounded-t-md border">
            <div class="w-[300px] mb-2">
              <.input
                field={@form[:name]}
                placeholder="Filename including extension..."
                autocomplete="off"
              />
            </div>
          </div>
          <.input
            type="textarea"
            field={@form[:markup_text]}
            placeholder="Insert code..."
            autocomplete="off"
            class="textarea w-full rounded-b-md"
          />
        </div>
 
        <div class="flex justify-end">
          <.button class="create_button">Create gist</.button>
        </div>
      </div>
    </.form>

CoreComponent fix

The core component hard codes the styles for the text area input fields, so lets make some changes

  • take the class value from the textarea
  • create an attr value named class which defaults to this value.
  • use the @class in place of the class string in textarea

This means that the classes will be set correctly on the component

make sure that mount sets the value to model

    def mount(_params, _session, socket) do
    socket =
      assign(
        socket,
        form: to_form(Gists.change_gist(%Gist{}))
      )
 
    {:ok, socket}
  end
 

Save the Gist

In the create_gist_live.ex file add the following code to the handle_event function

    def handle_event("create", %{"gist" => gist_params}, socket) do
    IO.inspect(gist_params)
 
    case Gists.create_gist(socket.assigns.current_user, gist_params) do
      {:ok, _gist} ->
        changeset = Gists.change_gist(%Gist{})
        {:noreply, assign(socket, form: to_form(changeset))}
 
      # match whatever is in changest, it will have errors
      {:error, %Ecto.Changeset{} = changeset} ->
        {:noreply, assign(socket, form: to_form(changeset))}
    end
  end
 

Validate the form

remember to add

  • phx-submit="create" to the form tag
  • phx-click="validate" to the form tag
  • phx-debounce="blur to the input tags
 def handle_event("validate", %{"gist" => gist_params}, socket) do
    changeset =
      %Gist{}
      |> Gists.change_gist(gist_params)
      |> Map.put(:action, :validate)
 
    {:noreply, assign(socket, form: to_form(changeset))}
  end