Skip to main content

Building a Real-Time Collaborative Editing Application with Phoenix LiveView and Presence

In this blog post, we will walk through the process of building a real-time collaborative document editing application using Phoenix LiveView. We will cover everything from setting up the project, creating the user interface, implementing real-time updates, and handling user presence. By the end of this tutorial, you'll have a fully functional collaborative editor that allows multiple users to edit a document simultaneously, with real-time updates and presence tracking.

User Flow 

Before diving into the code, let's outline the user flow and wireframes for our application. This will help us understand the overall structure and functionality we aim to achieve.

Landing Page:
  1. The user is greeted with a landing page that prompts them to enter their name.
  2. Upon entering their name and clicking "Submit", they are redirected to the document list page.
Document List Page:
  1. The user sees a list of available documents.
  2. Each document title is a clickable link that takes the user to the document editing page.
Document Editing Page:
  1. The user sees the document's content in a text area.
  2. The user's changes to the document are broadcasted in real-time to all other users currently viewing the same document.
  3. A sidebar shows the list of users currently editing the document.
Building the Landing Page with TDD

In this section, we'll create the landing page for our collaborative editing application using a Test-Driven Development (TDD) approach. 

We'll start by writing tests for the landing page, then implement the functionality to make the tests pass.

Step 1: Writing Tests for the Landing Page
We'll begin by writing tests to verify the landing page's functionality, including rendering the page, handling form submissions, and showing error messages for invalid input.

Test: Rendering the Landing Page


defmodule CollaborativeEditorWeb.LandingPageTest do
use CollaborativeEditorWeb.ConnCase, async: true
import Phoenix.LiveViewTest

@doc """
Test to ensure the landing page renders correctly.
"""
test "renders landing page", %{conn: conn} do
{:ok, view, _html} = live(conn, "/")
assert has_element?(view, "input[name=name]")
assert has_element?(view, "button[type=submit]", "Submit")
end

@doc """
Test to ensure the name is saved and user is redirected.
"""
test "redirects to document list page on valid name submit", %{conn: conn} do
{:ok, view, _html} = live(conn, "/")

view
|> form("#user-form", name: "Karthikeyan")
|> render_submit()

assert_redirect(view, ~p"/documents?name=Karthikeyan")
end

@doc """
Test to ensure error is shown on empty name submit.
"""
test "shows error on empty name submit", %{conn: conn} do
{:ok, view, _html} = live(conn, "/")

view
|> form("#user-form", name: "")
|> render_submit()

assert render(view) =~ "Name cannot be empty"
assert has_element?(view, ".alert-error", "Name cannot be empty")
end
end


Step 2: Implementing the Landing Page

Next, we'll implement the landing page to pass the tests we wrote. We'll create a LiveView module for the landing page and a corresponding HTML template.

Landing Page LiveView Module


defmodule CollaborativeEditorWeb.LandingPage do
use CollaborativeEditorWeb, :live_view

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

@impl true
def handle_event("save_name", %{"name" => name}, socket) do
if name != "" do
socket =
socket
|> assign(:name, name)
|> push_redirect(to: ~p"/documents?name=#{name}")

{:noreply, socket}
else
{:noreply, put_flash(socket, :error, "Name cannot be empty")}
end
end
end


Landing Page Template


<div class="min-h-screen flex items-center justify-center bg-gray-100">
<div class="w-full max-w-md bg-white p-8 rounded-lg shadow-md">
<h1 class="text-2xl font-bold mb-6 text-center">Welcome to Collaborative Editor</h1>

<%= if Phoenix.Flash.get(@flash, :error) do %>
<div class="alert-error text-red-500 mb-4"><%= Phoenix.Flash.get(@flash, :error) %></div>
<% end %>

<form phx-submit="save_name" id="user-form" class="space-y-6">
<div>
<label for="name" class="block text-gray-700">Enter your name</label>
<input type="text" name="name" id="name" class="w-full mt-1 p-2 border rounded" />
</div>

<div>
<button type="submit" class="w-full bg-blue-500 text-white p-2 rounded hover:bg-blue-600">Submit</button>
</div>
</form>
</div>
</div>



By following the TDD approach, we have written tests to define the expected behavior of our landing page and then implemented the functionality to make these tests pass. This ensures our landing page works as intended and handles both valid and invalid input gracefully.

Building the Document List Page with TDD

In this section, we'll create the document list page for our collaborative editing application using a Test-Driven Development (TDD) approach. We'll start by writing tests for the document list page, then implement the functionality to make the tests pass.

Step 1: Writing Tests for the Document List Page

We'll begin by writing tests to verify the document list page's functionality, including rendering the list of documents and handling navigation to the document editing page.

Test: Document List Page


defmodule CollaborativeEditorWeb.DocumentListLiveTest do
use CollaborativeEditorWeb.ConnCase, async: true
import Phoenix.LiveViewTest

alias CollaborativeEditor.Document
alias CollaborativeEditor.Repo

setup do
# Seed the database with test documents
documents = [
%Document{title: "Document 1", content: "Initial content for Document 1"},
%Document{title: "Document 2", content: "Initial content for Document 2"},
%Document{title: "Document 3", content: "Initial content for Document 3"}
]

for document <- documents do
Repo.insert!(document)
end

# Verify that documents are inserted
inserted_documents = Repo.all(Document)
assert length(inserted_documents) == 3

:ok
end

test "displays live documents", %{conn: conn} do
{:ok, view, _html} = live(conn, "/documents?name=KK")
assert has_element?(view, "li", "Document 1")
end

test "redirects to document edit page on click", %{conn: conn} do
{:ok, view, _html} = live(conn, "/documents?name=KK")

document = Repo.get_by(Document, title: "Document 1")

view
|> element("a", "Document 1")
|> render_click()

assert_redirected(view, ~p"/document/#{document.id}/KK")
end
end


Step 2: Implementing the Document List Page

Next, we'll implement the document list page to pass the tests we wrote. We'll create a LiveView module for the document list page and a corresponding HTML template.

Document List LiveView Module


defmodule CollaborativeEditorWeb.Live.DocumentListLive do
use CollaborativeEditorWeb, :live_view

alias CollaborativeEditor.Documents

@moduledoc """
Handles the display of the document list page where users can see and select documents to edit.
"""

@impl true
def mount(_params, _session, socket) do
documents = Documents.list_documents()
{:ok, assign(socket, documents: documents)}
end

@impl true
def handle_params(%{"name" => name}, _url, socket) do
# Store the user's name in the socket assigns
{:noreply, assign(socket, :name, name)}
end
end


Document List Template


<div class="min-h-screen flex flex-col items-center bg-gray-100">
<div class="w-full max-w-4xl p-8">
<h1 class="text-2xl font-bold mb-6 text-center">Documents</h1>

<ul class="list-disc list-inside w-full">
<%= for document <- @documents do %>
<li class="mb-2">
<a href={~p"/document/#{document.id}/#{@name}"} class="text-blue-500 hover:underline">
<%= document.title %>
</a>
</li>
<% end %>
</ul>
</div>
</div>



This ensures our document list page works as intended and handles navigation to the document editing page properly.

In the next section, we will move on to the document editing page.

Building the Document Edit Page with TDD


In this section, we'll create the document edit page for our collaborative editing application using a Test-Driven Development (TDD) approach.

Step 1: Writing Tests for the Document Edit Page

We'll begin by writing tests to verify the document edit page's functionality, including rendering the document content, updating the content in real-time, and showing the list of currently editing users.

Test: Document Edit Page


defmodule CollaborativeEditorWeb.Live.DocumentLiveTest do
use CollaborativeEditorWeb.ConnCase, async: true
import Phoenix.LiveViewTest

alias CollaborativeEditor.Document
alias CollaborativeEditor.Repo

setup do
document = %Document{title: "Document 1", content: "Initial content for Document 1"}
{:ok, document} = Repo.insert(document)

{:ok, document: document}
end

test "displays document content", %{conn: conn, document: document} do
{:ok, view, _html} = live(conn, "/document/#{document.id}/kk")

assert has_element?(view, "textarea", document.content)
end

test "updates document content in real-time", %{conn: conn, document: document} do
{:ok, view, _html} = live(conn, "/document/#{document.id}/kk")

# Simulate user input using render_hook
render_hook(view, "update_content", %{"content" => "Updated content"})

# Verify that the content is updated
assert render(view) =~ "Updated content"
end

test "shows currently editing users with name", %{conn: conn, document: document} do
# Simulate user entering name and joining the document page
{:ok, view, _html} = live(conn, "/document/#{document.id}/User1")

# Verify that the user is listed as currently editing
assert has_element?(view, "li", "User1")
end

test "tracks multiple users editing the document", %{conn: conn, document: document} do
# Simulate multiple users joining the document page
{:ok, view1, _html1} = live(conn, "/document/#{document.id}/User1")
{:ok, view2, _html2} = live(conn, "/document/#{document.id}/User2")

# Verify that both users are listed as currently editing
assert has_element?(view1, "li", "User1")
assert has_element?(view1, "li", "User2")
assert has_element?(view2, "li", "User1")
assert has_element?(view2, "li", "User2")
end
end



Step 2: Implementing the Document Edit Page

Next, we'll implement the document edit page to pass the tests we wrote. We'll create a LiveView module for the document edit page and a corresponding HTML template. We'll also include the JavaScript hook file to handle real-time content updates.

Document Edit LiveView Module


defmodule CollaborativeEditorWeb.Live.DocumentLive do
use CollaborativeEditorWeb, :live_view

alias CollaborativeEditor.Documents
alias CollaborativeEditorWeb.Presence

@moduledoc """
Handles the real-time collaborative document editing.
"""

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

@impl true
def handle_params(%{"id" => id, "name" => name}, _url, socket) do
if connected?(socket), do: track_user_presence(socket, id, name)

document = Documents.get_document!(id)
{:noreply, assign(socket, document: document, name: name, users: %{})}
end

@impl true
def handle_event("update_content", %{"content" => content}, socket) do
document = socket.assigns.document

# Update the document content in the database
case Documents.update_document(document, %{content: content}) do
{:ok, updated_document} ->
# Update the document content in the socket
socket = assign(socket, document: updated_document)

# Broadcast the update to all clients subscribed to this document
CollaborativeEditorWeb.Endpoint.broadcast("document:#{document.id}", "content_update", %{
content: content
})

{:noreply, socket}

{:error, _changeset} ->
{:noreply, socket}
end
end

@impl true
def handle_info(%{event: "content_update", payload: %{content: content}}, socket) do
{:noreply, assign(socket, document: %{socket.assigns.document | content: content})}
end

@impl true
def handle_info(%{event: "presence_diff", payload: _diff}, socket) do
users = Presence.list("document:#{socket.assigns.document.id}")
{:noreply, assign(socket, users: users)}
end

defp track_user_presence(socket, document_id, name) do
CollaborativeEditorWeb.Endpoint.subscribe("document:#{document_id}")

Presence.track(self(), "document:#{document_id}", socket.id, %{
user: name
})
end
end



Document Edit Template


<div class="min-h-screen flex flex-row">
<div class="flex-1 p-4">
<h1 class="text-2xl font-bold mb-4"><%= @document.title %></h1>
<textarea
id="document-content"
class="w-full h-64 p-2 border rounded focus:outline-none focus:shadow-outline"
phx-hook="DocumentEditor"
phx-debounce="500"
>
<%= @document.content %>
</textarea>
</div>
<div class="w-1/4 p-4 border-l border-gray-200">
<h2 class="text-xl font-bold mb-2">Currently Editing:</h2>
<ul class="list-disc list-inside">
<%= for {_key, %{metas: metas}} <- @users do %>
<%= for meta <- metas do %>
<li><%= meta.user %></li>
<% end %>
<% end %>
</ul>
</div>
</div>


Document Editor Hook

assets/js/hooks/document_editor.js


let DocumentEditor = {
mounted() {
this.el.addEventListener("input", e => {
this.pushEvent("update_content", { content: e.target.value });
});
this.handleEvent("content_update", ({ content }) => {
this.el.value = content;
});
}
};
export default DocumentEditor;



Ensure Hook is Imported in app.js

assets/js/app.js

// Include phoenix_html to handle method=PUT/DELETE in forms and buttons.
import "phoenix_html"
// Establish Phoenix Socket and LiveView configuration.
import {Socket} from "phoenix"
import {LiveSocket} from "phoenix_live_view"
import topbar from "../vendor/topbar"

// Import hooks
import DocumentEditor from "./hooks/document_editor";


let csrfToken = document.querySelector("meta[name='csrf-token']").getAttribute("content")
let hooks = {
DocumentEditor: DocumentEditor
};

let liveSocket = new LiveSocket("/live", Socket, {
longPollFallbackMs: 2500,
hooks: hooks,
params: {_csrf_token: csrfToken}
})

topbar.config({barColors: {0: "#29d"}, shadowColor: "rgba(0, 0, 0, .3)"})
window.addEventListener("phx:page-loading-start", _info => topbar.show(300))
window.addEventListener("phx:page-loading-stop", _info => topbar.hide())

liveSocket.connect()

window.liveSocket = liveSocket



This ensures our document edit page works as intended, providing real-time updates and showing the list of currently editing users.


In this blog post, we walked through the process of building a real-time collaborative document editing application using Phoenix LiveView. By leveraging the powerful features of LiveView and the Phoenix framework, we were able to create a seamless and responsive collaborative editing experience.You can further improve the collaborative experience and build a more comprehensive application.

Thank you for following along with this tutorial. We hope you found it helpful and that it inspires you to build your own real-time collaborative applications with Phoenix LiveView. For the complete code, visit the https://github.com/karthikeyan234/collaborative_editor_liveview. Happy coding!

















Comments

Popular posts from this blog

Handling Massive Concurrency with Elixir and OTP: Advanced Practical Example

For advanced Elixir developers, handling massive concurrency involves not just understanding the basics of GenServers, Tasks, and Supervisors, but also effectively utilizing more complex patterns and strategies to optimize performance and ensure fault tolerance. In this blog post, we'll dive deeper into advanced techniques and provide a practical example involving a distributed, fault-tolerant system. Practical Example: Distributed Web Crawler We'll build a distributed web crawler that can handle massive concurrency by distributing tasks across multiple nodes, dynamically supervising crawling processes, and implementing rate limiting to control the crawling rate. In this example, we will build a distributed web crawler that simulates handling massive concurrency and backpressure. To achieve this, we will: Generate 100 unique API URLs that will be processed by our system. Create an API within the application that simulates slow responses using :timer.sleep to introduce artificia...

How Phoenix Live View Works and what are its advantage

If we take a look at the Phoenix live view Docs this statement actually comes directly from it Phoenix live view is a library that enables rich real-time user experiences all from server rendered HTML and you might be thinking that this is quite an ambitious claim is that even possible what does actually mean to you what this actually means is you don't have to immediately reach for some sort of front-end library or framework to have rich user experiences what it actually means is that you can truly write some rich interactive user interfaces using the tools that you're already familiar with in the elixir ecosystem without needing to write JavaScript you might need to write JavaScript but definitely a lot less so but don't get me wrong when I make this statement you still might want client side JavaScript it still does have its use cases and you shouldn't discard it completely but one of the benefits is that we can lessen the technical burden of developers by not making...