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:
- The user is greeted with a landing page that prompts them to enter their name.
- Upon entering their name and clicking "Submit", they are redirected to the document list page.
Document List Page:
- The user sees a list of available documents.
- Each document title is a clickable link that takes the user to the document editing page.
Document Editing Page:
- The user sees the document's content in a text area.
- The user's changes to the document are broadcasted in real-time to all other users currently viewing the same document.
- 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
Post a Comment