Skip to the content.

(Partly generated by LLM)

Tutorial 2: A simple note taking app

In this tutorial, we’ll walk through the process of building a simple note-taking application using Phoenix LiveView. We’ll cover setting up the basic components, handling user interactions like showing and hiding modals, and performing CRUD (Create, Read, Update, Delete) operations on our notes.

Setting Up the Basic Components

First, we’ll implement a simple note-taking app. To get started, we need to define the routes that will handle our note-related actions. We’ll add two new live routes to our router.ex file:

live "/notes", NoteLive, :index
live "/notes/new", NoteLive, :new

We’ll use a single render function and leverage flags to conditionally render the “new note” page.

2.1 Showing and Hiding the Modal

To manage the state of our application, we’ll start by setting up the initial state in the mount function. We’ll initialize an empty list of notes and a flag to control the visibility of our modal.

def mount(_params, _session, socket) do
  {:ok,
    socket
    |> assign(:notes, [])
    |> assign(:modal_open, false)
  }
end

Since we’re using the same LiveView for both the index and new note pages, we need to handle the incoming parameters to differentiate between the two. The handle_params function allows us to do this.

def handle_params(_params, _uri, socket) do
  {:noreply, socket}
end

Our render function will be responsible for displaying both the list of notes and the modal for creating a new note. The modal will be conditionally rendered based on the @live_action assign, which is determined by the route.

def render(assigns) do
  ~H"""
  <div>
  <h3>Live action: {@live_action == :index}</h3>
  <h1>Notes</h1>
  <p><.link patch={~p"/notes/new"}>

    <button class="bg-blue-500 text-white font-bold py-2 px-4 rounded">New Note</button></.link></p>
  <table>
    <thead>
      <tr>
        <th>Title</th>
        <th>Content</th>
        <th>Actions</th>
      </tr>
    </thead>
    <tbody>
      <%= for note <- @notes do %>
        <tr>
          <td><%= note.title %></td>
          <td><%= note.content %></td>
          <td>
            <.link patch={~p"/notes/#{note.id}/edit"}>Edit</.link>
            <button phx-click="delete" phx-value-id={note.id}>Delete</button>
          </td>
        </tr>
      <% end %>
    </tbody>
  </table>


  <div id="modalOverlay" class="fixed inset-0 bg-gray-500 bg-opacity-75 transition-opacity" :if={@live_action == :new} >
    <div class="fixed inset-0 z-10 overflow-y-auto">
      <div class="flex items-end justify-center min-h-screen pt-4 px-4 pb-20 text-center sm:block sm:p-0" >
        <div class="absolute inset-0 bg-gray-500 opacity-75"></div>
        <span class="hidden sm:inline-block sm:align-middle sm:h-screen"></span>
        <div class="inline-block align-bottom bg-white rounded-lg text-left overflow-hidden shadow-xl transform transition-all sm:my-8 sm:align-middle sm:max-w-lg sm:w-full" phx-click-away={close_modal()}>
          <div class="bg-white px-4 pt-5 pb-4 sm:p-6 sm:pb-4">
            <h2>Modal Content</h2>
            <p>This is a modal example.</p>
          </div>
        </div>
      </div>
    </div>
  </div>
  </div>
  """
end

The first part of this page displays a table of notes. When the “New Note” button is clicked, LiveView will patch the page to the /notes/new route. As defined in our router, this will use the same NoteLive module but with the :new parameter. This parameter is passed as @live_action in the assigns, which we use to conditionally render the modal.

To close the modal when the user clicks outside of it, we add the phx-click-away={close_modal()} event handler. The close_modal function then patches the URL back to /notes, effectively closing the modal.

def close_modal() do
  %JS{}
  |> JS.patch(~p"/notes")
end

2.2 Saving and Canceling Data

The cancel functionality is already handled by our close_modal function. Now, let’s create a form within the modal to add a new note. We’ll add a single field for the note’s title.

<div id="modalOverlay" class="fixed inset-0 bg-gray-500 bg-opacity-75 transition-opacity"
:if={@live_action == :new} >
  <div class="fixed inset-0 z-10 overflow-y-auto">
    <div class="flex items-end justify-center min-h-screen pt-4 px-4 pb-20 text-center sm:block sm:p-0" >
      <div class="absolute inset-0 bg-gray-500 opacity-75"></div>
      <span class="hidden sm:inline-block sm:align-middle sm:h-screen"></span>
      <div class="inline-block align-bottom bg-white rounded-lg text-left overflow-hidden shadow-xl transform transition-all sm:my-8 sm:align-middle sm:max-w-lg sm:w-full" phx-click-away={close_modal()}>
        <div class="bg-white px-4 pt-5 pb-4 sm:p-6 sm:pb-4">
          <h1 class="text-xl font-bold">
            Create new note
          </h1>
          <form phx-submit="save-note" class="space-y-4">
            <div>
              <label for="title" class="block text-sm font-medium text-gray-700">Note</label>
              <input type="text" name="title" id="title" required class="mt-1 block w-full border border-gray-300 rounded-md shadow-sm p-2 focus:ring-blue-500 focus:border-blue-500" />
            </div>
            <button type="submit" class="bg-blue-500 text-white font-bold py-2 px-4 rounded hover:bg-blue-600">Save</button>
          </form>
        </div>
      </div>
    </div>
  </div>
</div>

We’ve also created an Ecto model for our notes with a single title field. When the form is submitted, the save-note event is triggered, and we can save the note using Ecto.

def handle_event("save-note", %{"title" => title}, socket) do
  case NoteApp.create_note(%{title: title}) do
    {:ok, _note} ->
      {:noreply,
        socket
        |> assign(:notes, NoteApp.list_notes())
        |> push_patch(to: ~p"/notes")
      }
    {:error, changeset} ->
      {:noreply, socket |> assign(:modal_open, true)}
  end
end

2.3 Showing Note Details

To show the details of a note, we’ll first add a new route to our router.ex file.

live "/notes/:id/show", NoteLive, :show

Next, we’ll modify the template to include a link to the note’s title, which will trigger the :show action.

<td class="px-6 py-4 text-center text-sm text-gray-900">
    <.link patch={~p"/notes/#{note.id}/show"} class="text-blue-600 hover:text-blue-800">
        <%= note.title %>
    </.link>
</td>

We’ll also add a conditional modal to display the note’s details.

<div id="noteDetailsModal" class="fixed inset-0 z-50 bg-gray-500 bg-opacity-75 transition-opacity"
:if={@live_action in [:show]}>
  <div class="flex items-center justify-center min-h-screen">
    <div class="bg-white rounded-lg shadow-lg p-6 max-w-md w-full" phx-click-away={close_modal()}>
      <h2 class="text-2xl font-bold mb-4">Note Details</h2>
      <p class="text-lg font-semibold">
        Title: <span id="noteTitle">{@note.title}</span>
      </p>
      <p class="text-sm text-gray-600">
        Created At: <span id="noteCreatedAt">{@note.inserted_at}</span>
      </p>
      <p class="text-sm text-gray-600">
        Updated At: <span id="noteUpdatedAt">{@note.updated_at}</span>
      </p>
    </div>
  </div>
</div>

2.4 Editing Note Details

To edit a note, we’ll start by modifying the template to handle both the :new and :edit actions in the same modal.

<div id="modalOverlay" class="fixed inset-0 bg-gray-500 bg-opacity-75 transition-opacity"
-    :if={@live_action == :new} >
+    :if={@live_action in [:new, :edit]} >
  <div class="fixed inset-0 z-10 overflow-y-auto">
    <div class="flex items-end justify-center min-h-screen pt-4 px-4 pb-20 text-center sm:block sm:p-0" >
      <div class="absolute inset-0 bg-gray-500 opacity-75"></div>
      <span class="hidden sm:inline-block sm:align-middle sm:h-screen"></span>
      <div class="inline-block align-bottom bg-white rounded-lg text-left overflow-hidden shadow-xl transform transition-all sm:my-8 sm:align-middle sm:max-w-lg sm:w-full" phx-click-away={close_modal()}>
        <div class="bg-white px-4 pt-5 pb-4 sm:p-6 sm:pb-4">
          <%!-- <h2>Modal Content</h2> --%>
          <h1 class="text-xl font-bold">
-                Create new note
+                {if @live_action == :new, do: "Create new note", else: "Edit note"}
          </h1>
          <%!-- <p>This is a modal example.</p> --%>
          <form phx-submit="save-note" class="space-y-4">
            <div>
+                  <input type="hidden"
+                    name="note_id"
+                    value={@note.id}
+                    :if={@note}
+                  />
              <label for="title" class="block text-sm font-medium text-gray-700">Note</label>
-                  <input type="text" name="title" id="title" required class="mt-1 block w-full border border-gray-300 rounded-md shadow-sm p-2 focus:ring-blue-500 focus:border-blue-500"/>
+                  <input type="text" name="title" id="title" required class="mt-1 block w-full border border-gray-300 rounded-md shadow-sm p-2 focus:ring-blue-500 focus:border-blue-500" value={@note && @note.title || ""}/>
            </div>
            <button type="submit" class="bg-blue-500 text-white font-bold py-2 px-4 rounded hover:bg-blue-600">Save</button>
          </form>
        </div>
      </div>
    </div>
  </div>
</div>

Next, we’ll add a new route to our router.ex file to handle the edit action.

    live "/notes/new", NoteLive, :new
+   live "/notes/:id/edit", NoteLive, :edit
    live "/notes/:id/show", NoteLive, :show

We’ll also add a new handle_event function to update the note when the note_id is present in the event parameters.

+  def handle_event("save-note", %{"note_id" => id, "title" => title}, socket) do
+    NoteApp.update_note(
+      NoteApp.get_note!(id),
+      %{title: title}
+    )
+
+    {:noreply,
+      socket
+      |> assign(:notes, NoteApp.list_notes())
+      |> push_patch(to: ~p"/notes")
+    }
+
+  end

To ensure that the note is cleared when we navigate back to the index route, we’ll modify the handle_params function.

  def handle_params(params, uri, socket) do
    case params do
      %{"id" => id} ->
        note = NoteApp.get_note!(id)
        IO.inspect(note, label: "Fetched note in handle_params")
        {:noreply, assign(socket, :note, note)}
+      _ ->
+        {:noreply, assign(socket, :note, nil)}
    end
  end

Finally, here’s the Ecto function to update a note.

def update_note(%Note{} = note, attrs) do
  note
  |> Note.changeset(attrs)
  |> Repo.update()
end

And that’s it! We’ve built a simple yet functional note-taking application with Phoenix LiveView, covering the essential concepts of handling user interactions and data manipulation.