go back Vanya's Website

Phoenix crash course

modified 20/04/2024 22:57

<2023-11-12 Sun>: let’s start learning some phoneix because why not. My goal is going to be building a 999.md clone in it. But I don’t know elixir at all, so I’m going to be picking up everything along the way. Let’s go.

Installation

On my Ubuntu I used asfd and will writing code VSCode. Pretty simple to install, just do

KERL_BUILD_DOCS=yes asdf install erlang 26.1.2 # this installs documentation for erlang too
asdf install elixir 1.15.7-otp-26 # make sure otp-<version> matches version of erlang
mix archive.install hex phx_new # installs phoenix

mix is like a manage.py script from Django – it creates the base project and you can use it to build and start the app, generate migrations & so on but also much more.

For VSCode I installed the ElixirLS and Phoenix Framework extensions. Just run mix phx.new hello and your hello app should be created.

Then, to create the database (postgres by default) run mix setup.

Elixir crash course

Go over to https://learnxinyminutes.com/docs/elixir/ and try typing everything in iex which is a elixir repl. Play around with various inputs, like passing negative numbers to the elem function or going over the bounds and see what happens. For everything else, you can also use https://elixirschool.com/en/, but we’ll be picking up everything as needed.

Phoenix project structure

I’ll only cover what’s necessary. For more, refer to the phoenix docs on this.

mix.exs

This is like a requirements + pyproject.toml file for Python but also a package.json for npm – define requirements and general config for the app.

defmodule Hello.MixProject do
use Mix.Project

This are our first language constructs: defmodule defines a module, use imports a library. We can read more about what Mix.Project does by using the hover documentation, but esentially it exports a bunch of functions that allow configuring our project.

I wish I could go to the definition of it, and actually read the source code but VSCode doesn’t allow this.

We see functions defined by def (public) and defp (private).

How does it work?

Skimming through the source code, the main functionality is done by the push function which when called when a module is compiled uses the module inside mix.exs as the config atom. push merges the map (like a python .update() dict method – overriding using the last value) using [app: app] (we define it in our project function) ++ (list concatination) a default configuration + our actual config (atom.project). This is how the project function actually gets called, and the stuff returned from it is used to override everything previously in the dict.

I’ll assume that the other functions work/are being called the same way (call the specific function defined in a module)

Phoenix dependencies

Let’s take a look at what we get out of the box:

/config

This is where config files reside.

config.exs

Main config file, that uses the Config module and imports (calls import_config) the specific config based on the environment we’re using. Interestingly, parantheses are optional, so we can do

config :hello,
  ecto_repos: [Hello.Repo],
  generators: [timestamp_type: :utc_datetime]

which is gonig to call config on the hello atom and provide some general config, then

config :hello, HelloWeb.Endpoint,
  url: [host: "localhost"],
  adapter: Phoenix.Endpoint.Cowboy2Adapter,
  render_errors: [
    formats: [html: HelloWeb.ErrorHTML, json: HelloWeb.ErrorJSON],
    layout: false
  ],
  pubsub_server: Hello.PubSub,

which is going to call config again on the HelloWeb.Endpoint module and so on. This indenation looks awesome! And it’s like passing kwargs. You’re esentially calling config with [url: [host: "localhost"], adapter: ...]

runtime.exs

Here’s config that gets execuetd at runtime (i.e. reading from env): api port and host, db url, secret key

dev|prod|test.exs

Manual config for different environments. I believe the convention is that for the dev and test envs we configure using dev.exs while for prod, we use environment variables and rely on config.exs.

/lib

Where the actual meat is.

/hello hello.ex

This is where the business-logic-related stuff resides.

/hello_web & hello_web.ex

Where endpoints, routers, telemetry and internationalization is defined and configured.

Endpoints

API routing functionality happens inside the hello_web/router.ex file. Here we see something like

scope "/", HelloWeb do
  pipe_through :browser

  get "/", PageController, :home
end

For now ignore everything except get "/", PageController, :home. What this does is map the method (get) to the path (/) using the :home function of our PageController (defined in controllers/). What home does is call render with the :home atom, which renders the home.html.heex page.

Adding a new endpoint

To add a new endpoint, we need 2 things:

  1. Adding a new entry in the “/” scope that’s going to point to the function of a new controller and
  2. A new controller, with a function that will perform some stuff.

HEEx

Defining a View in MVC means creating a html template. For this we use the convention <controller_name> + _html. We can use Phoenix’s own templating language using the ~H sigil that’s esentially Jinja but also a lot of other stuff like built-in xss protection. We can use functions for simple html, or actual templates for something more complex.

How it works

Esentially, we have 4 layers, all managed by various plugs.

Plugs

Plugs are essentially some combination of middleware but also specification for design, and all components are built from plugs.

Plugs can be set at different levels: endpoint (will get applied to every request), router (will get applied to all requests in a scope), controllers (allows executing custom stuff for specific actions only)

LiveView ToDo app

Let’s dive deeper and get some hands-on experience with Phoenix LiveView. I’ll be relying on https://github.com/dwyl/phoenix-liveview-todo-list-tutorial for this, but will try to make sense of everything I don’t understand on my own. (I’ll skip over tests and css/html stuff, but that’s simple enough to pick on your own I believe)

The controller

First real step (after creating & running app) is adding a PageLive controller.

defmodule LiveViewTodoWeb.PageLive do
  use LiveViewTodoWeb, :live_view

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

Let’s disect this line-by-line

The router

As we saw, the router is where we specify the url and view. The next step requries changing get "/", PageController, :index to live "/", PageLive in the LiveViewTodoWeb router, to essentially define a live view, instead of a regular one.

The Item schema

Now comes the interesting part: the schema. Running mix phx.gen.schema Item items text:string person_id:integer status:integer will generate both, a file for the model and the migration for it. Let us again go line-by-line

defmodule LiveViewTodo.Item do
  use Ecto.Schema
  import Ecto.Changeset
  alias LiveViewTodo.Repo
  alias __MODULE__

  schema "items" do
    field :person_id, :integer
    field :status, :integer
    field :text, :string

    timestamps()
  end

  @doc false
  def changeset(item, attrs) do
    item
    |> cast(attrs, [:text, :person_id, :status])
    |> validate_required([:text])
  end
end

Live handlers

We’ve got our models, our router and our controller. But they’re not communicating in any way. The way we make them is by

  1. triggering an event
  2. handling the event

Point number 1 is addressed with the template. We add a form to our template with the phx-submit="create" attribute, which will trigger the “create” event once submitted.

Point number 2 requires updating our live controller.

Here’s what it’s supposed to look like:

defmodule LiveViewTodoWeb.PageLive do
  use LiveViewTodoWeb, :live_view
  alias LiveViewTodo.Item

  @topic "live"

  @impl true
  def mount(_params, _session, socket) do
    if connected?(socket), do: LiveViewTodoWeb.Endpoint.subscribe(@topic)
    {:ok, assign(socket, items: Item.list_items())} # add items to assigns
  end

  @impl true
  def handle_event("create", %{"text" => text}, socket) do
    Item.create_item(%{text: text})
    socket = assign(socket, items: Item.list_items(), active: %Item{})
    LiveViewTodoWeb.Endpoint.broadcast_from(self(), @topic, "update", socket.assigns)
    {:noreply, socket}
  end

  @impl true
  def handle_info(%{event: "update", payload: %{items: items}}, socket) do
    {:noreply, assign(socket, items: items)}
  end
end

Let’s now display our items.

<ul class="todo-list">
    <%= for item <- @items do %>
      <li data-id={item.id} class={completed?(item)}>
        <div class="view">
            <%= if checked?(item) do %>
            <input class="toggle" type="checkbox" phx-value-id={item.id} phx-click="toggle" checked />
            <% else %>
            <input class="toggle" type="checkbox" phx-value-id={item.id} phx-click="toggle" />
            <% end %>
            <label><%= item.text %></label>
            <button class="destroy" phx-click="delete" phx-value-id={item.id}></button>
        </div>
      </li>
      <% end %>
  </ul>

This looks pretty similar to Django/Jinja templates. We’re iterating over all items, checking if the item is completed (with the checked? function we didn’t define yet); if so, we’re setting the checked attribute. We also have an destroy button that will call a destroy event (which we didn’t define yet).

Define the functions

def checked?(item) do
  not is_nil(item.status) and item.status > 0
end

def completed?(item) do
  if checked?(item), do: "completed", else: ""
end

…and we’re now ready to add and read items!

Live Components

This is working, but it’s kinda split across a bunch of modules and files. And the template renders everything. This is ok for a small number of objects (we only have items) but once we add multiple objects, it’s going to become a mess.

To prevent this, we can group together everything regarding one object in the same place, using a Live Component, which is going to have a render function. And in our template, we’ll only call it.

That’s it

You’re now ready to build whatever! Just use google and the docs whenever you need to know how to do something.