From 80cd651b0f550e64fb95267cd579de55ea504782 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jonatan=20K=C5=82osko?= Date: Sun, 17 Jan 2021 22:03:03 +0100 Subject: [PATCH] Setup initial session UI (#9) * Sync session data within LV client * Add basic session UI * Add operations for setting notebook and section name * Update notebook and section name from the UI * Some cleanup * Return current data upon client registartion to avoid race conditions * Small fixes --- assets/css/app.css | 11 ++ assets/js/app.js | 6 + assets/js/content_editable.js | 39 +++++ lib/live_book/notebook.ex | 13 ++ lib/live_book/session.ex | 53 ++++++- lib/live_book/session/data.ex | 28 ++++ lib/live_book_web.ex | 3 + lib/live_book_web/live/cell.ex | 31 ++++ lib/live_book_web/live/icons.ex | 51 ++++++ lib/live_book_web/live/insert_cell_actions.ex | 34 ++++ lib/live_book_web/live/section.ex | 43 +++++ lib/live_book_web/live/session_live.ex | 147 +++++++++++++++++- lib/live_book_web/live/sessions_live.ex | 8 +- .../templates/layout/live.html.leex | 13 +- test/live_book/session/data_test.exs | 31 ++++ test/live_book/session_test.exs | 20 +++ 16 files changed, 506 insertions(+), 25 deletions(-) create mode 100644 assets/js/content_editable.js create mode 100644 lib/live_book_web/live/cell.ex create mode 100644 lib/live_book_web/live/icons.ex create mode 100644 lib/live_book_web/live/insert_cell_actions.ex create mode 100644 lib/live_book_web/live/section.ex diff --git a/assets/css/app.css b/assets/css/app.css index 20a301b09..50e984e73 100644 --- a/assets/css/app.css +++ b/assets/css/app.css @@ -4,3 +4,14 @@ @import "../node_modules/nprogress/nprogress.css"; @import "./live_view.css"; + +/* Remove the default outline on focused elements */ +:focus, +button:focus { + outline: none; +} + +/* Hide Phoenix live reload frame */ +iframe[hidden] { + display: none; +} diff --git a/assets/js/app.js b/assets/js/app.js index c44b1b544..dcbcb8e4a 100644 --- a/assets/js/app.js +++ b/assets/js/app.js @@ -4,6 +4,11 @@ import "phoenix_html"; import { Socket } from "phoenix"; import NProgress from "nprogress"; import { LiveSocket } from "phoenix_live_view"; +import ContentEditable from "./content_editable"; + +const Hooks = { + ContentEditable, +}; const csrfToken = document .querySelector("meta[name='csrf-token']") @@ -11,6 +16,7 @@ const csrfToken = document const liveSocket = new LiveSocket("/live", Socket, { params: { _csrf_token: csrfToken }, + hooks: Hooks, }); // Show progress bar on live navigation and form submits diff --git a/assets/js/content_editable.js b/assets/js/content_editable.js new file mode 100644 index 000000000..ad91c39b2 --- /dev/null +++ b/assets/js/content_editable.js @@ -0,0 +1,39 @@ +/** + * A hook used on [contenteditable] elements to update the specified + * attribute with the element text. + * + * Configuration: + * + * * `data-update-attribute` - the name of the attribute to update when content changes + */ +const ContentEditable = { + mounted() { + this.attribute = this.el.dataset.updateAttribute; + + this.__updateAttribute(); + + // Set the specified attribute on every content change + this.el.addEventListener("input", (event) => { + this.__updateAttribute(); + }); + + // Make sure only plain text is pasted + this.el.addEventListener("paste", (event) => { + event.preventDefault(); + const text = event.clipboardData.getData("text/plain"); + document.execCommand("insertText", false, text); + }); + }, + + updated() { + // The element has been re-rendered so we have to add the attribute back + this.__updateAttribute(); + }, + + __updateAttribute() { + const value = this.el.innerText.trim(); + this.el.setAttribute(this.attribute, value); + }, +}; + +export default ContentEditable; diff --git a/lib/live_book/notebook.ex b/lib/live_book/notebook.ex index 011f5fbf1..406bed998 100644 --- a/lib/live_book/notebook.ex +++ b/lib/live_book/notebook.ex @@ -132,6 +132,19 @@ defmodule LiveBook.Notebook do %{notebook | sections: sections} end + @doc """ + Updates section with the given function. + """ + @spec update_section(t(), Section.id(), (Section.t() -> Section.t())) :: t() + def update_section(notebook, section_id, fun) do + sections = + Enum.map(notebook.sections, fn section -> + if section.id == section_id, do: fun.(section), else: section + end) + + %{notebook | sections: sections} + end + @doc """ Returns a list of Elixir cells that the given cell depends on. diff --git a/lib/live_book/session.ex b/lib/live_book/session.ex index 1365ef33e..1e651776b 100644 --- a/lib/live_book/session.ex +++ b/lib/live_book/session.ex @@ -46,13 +46,25 @@ defmodule LiveBook.Session do end @doc """ - Registers a session client, so that it receives updates from the server. + Registers a session client, so that the session is aware of it. The client process is automatically unregistered when it terminates. + + Returns the current session data, which the client can than + keep in sync with the server by subscribing to the `sessions:id` topic + and reciving operations to apply. """ - @spec register_client(id(), pid()) :: :ok + @spec register_client(id(), pid()) :: Data.t() def register_client(session_id, pid) do - GenServer.cast(name(session_id), {:register_client, pid}) + GenServer.call(name(session_id), {:register_client, pid}) + end + + @doc """ + Returns the current session data. + """ + @spec get_data(id()) :: Data.t() + def get_data(session_id) do + GenServer.call(name(session_id), :get_data) end @doc """ @@ -104,6 +116,22 @@ defmodule LiveBook.Session do GenServer.cast(name(session_id), {:cancel_cell_evaluation, cell_id}) end + @doc """ + Asynchronously sends notebook name update request to the server. + """ + @spec set_notebook_name(id(), String.t()) :: :ok + def set_notebook_name(session_id, name) do + GenServer.cast(name(session_id), {:set_notebook_name, name}) + end + + @doc """ + Asynchronously sends section name update request to the server. + """ + @spec set_section_name(id(), Section.id(), String.t()) :: :ok + def set_section_name(session_id, section_id, name) do + GenServer.cast(name(session_id), {:set_section_name, section_id, name}) + end + @doc """ Synchronously stops the server. """ @@ -126,11 +154,16 @@ defmodule LiveBook.Session do end @impl true - def handle_cast({:register_client, pid}, state) do + def handle_call({:register_client, pid}, _from, state) do Process.monitor(pid) - {:noreply, %{state | client_pids: [pid | state.client_pids]}} + {:reply, state.data, %{state | client_pids: [pid | state.client_pids]}} end + def handle_call(:get_data, _from, state) do + {:reply, state.data, state} + end + + @impl true def handle_cast({:insert_section, index}, state) do # Include new id in the operation, so it's reproducible operation = {:insert_section, index, Utils.random_id()} @@ -163,6 +196,16 @@ defmodule LiveBook.Session do handle_operation(state, operation) end + def handle_cast({:set_notebook_name, name}, state) do + operation = {:set_notebook_name, name} + handle_operation(state, operation) + end + + def handle_cast({:set_section_name, section_id, name}, state) do + operation = {:set_section_name, section_id, name} + handle_operation(state, operation) + end + @impl true def handle_info({:DOWN, _, :process, pid, _}, state) do {:noreply, %{state | client_pids: List.delete(state.client_pids, pid)}} diff --git a/lib/live_book/session/data.ex b/lib/live_book/session/data.ex index 23d04d95d..3c5b752d9 100644 --- a/lib/live_book/session/data.ex +++ b/lib/live_book/session/data.ex @@ -63,6 +63,8 @@ defmodule LiveBook.Session.Data do | {:add_cell_evaluation_stdout, Cell.id(), String.t()} | {:add_cell_evaluation_response, Cell.id(), Evaluator.evaluation_response()} | {:cancel_cell_evaluation, Cell.id()} + | {:set_notebook_name, String.t()} + | {:set_section_name, Section.id(), String.t()} @type action :: {:start_evaluation, Cell.t(), Section.t()} @@ -230,6 +232,22 @@ defmodule LiveBook.Session.Data do end end + def apply_operation(data, {:set_notebook_name, name}) do + data + |> with_actions() + |> set_notebook_name(name) + |> wrap_ok() + end + + def apply_operation(data, {:set_section_name, section_id, name}) do + with {:ok, section} <- Notebook.fetch_section(data.notebook, section_id) do + data + |> with_actions() + |> set_section_name(section, name) + |> wrap_ok() + end + end + # === defp with_actions(data, actions \\ []), do: {data, actions} @@ -384,6 +402,16 @@ defmodule LiveBook.Session.Data do |> reduce(queued_dependent_cells, &unqueue_cell_evaluation(&1, &2, section)) end + defp set_notebook_name({data, _} = data_actions, name) do + data_actions + |> set!(notebook: %{data.notebook | name: name}) + end + + defp set_section_name({data, _} = data_actions, section, name) do + data_actions + |> set!(notebook: Notebook.update_section(data.notebook, section.id, &%{&1 | name: name})) + end + defp add_action({data, actions}, action) do {data, actions ++ [action]} end diff --git a/lib/live_book_web.ex b/lib/live_book_web.ex index 23abffb4a..ed4f8603f 100644 --- a/lib/live_book_web.ex +++ b/lib/live_book_web.ex @@ -87,6 +87,9 @@ defmodule LiveBookWeb do import LiveBookWeb.ErrorHelpers alias LiveBookWeb.Router.Helpers, as: Routes + + # Custom helpers + alias LiveBookWeb.Icons end end diff --git a/lib/live_book_web/live/cell.ex b/lib/live_book_web/live/cell.ex new file mode 100644 index 000000000..6ec43926a --- /dev/null +++ b/lib/live_book_web/live/cell.ex @@ -0,0 +1,31 @@ +defmodule LiveBookWeb.Cell do + use LiveBookWeb, :live_component + + def render(assigns) do + ~L""" +
"> +
+ + +
+
+
+ <%= @cell.type |> Atom.to_string() |> String.capitalize() %> cell placeholder +
+
+
+ """ + end +end diff --git a/lib/live_book_web/live/icons.ex b/lib/live_book_web/live/icons.ex new file mode 100644 index 000000000..320b79869 --- /dev/null +++ b/lib/live_book_web/live/icons.ex @@ -0,0 +1,51 @@ +defmodule LiveBookWeb.Icons do + import Phoenix.HTML + import Phoenix.HTML.Tag + + @doc """ + Returns icon svg tag. + """ + @spec svg(atom(), keyword()) :: Phoenix.HTML.safe() + def svg(name, attrs \\ []) + + def svg(:chevron_right, attrs) do + ~e""" + + """ + |> heroicon_svg(attrs) + end + + def svg(:play, attrs) do + ~e""" + + + """ + |> heroicon_svg(attrs) + end + + def svg(:plus, attrs) do + ~e""" + + """ + |> heroicon_svg(attrs) + end + + def svg(:trash, attrs) do + ~e""" + + """ + |> heroicon_svg(attrs) + end + + # https://heroicons.com + defp heroicon_svg(svg_content, attrs) do + heroicon_svg_attrs = [ + xmlns: "http://www.w3.org/2000/svg", + fill: "none", + viewBox: "0 0 24 24", + stroke: "currentColor" + ] + + content_tag(:svg, svg_content, Keyword.merge(attrs, heroicon_svg_attrs)) + end +end diff --git a/lib/live_book_web/live/insert_cell_actions.ex b/lib/live_book_web/live/insert_cell_actions.ex new file mode 100644 index 000000000..71b91d392 --- /dev/null +++ b/lib/live_book_web/live/insert_cell_actions.ex @@ -0,0 +1,34 @@ +defmodule LiveBookWeb.InsertCellActions do + use LiveBookWeb, :live_component + + def render(assigns) do + ~L""" +
+ <%= line() %> + + + <%= line() %> +
+ """ + end + + defp line() do + ~e""" +
+ """ + end +end diff --git a/lib/live_book_web/live/section.ex b/lib/live_book_web/live/section.ex new file mode 100644 index 000000000..56a3f3255 --- /dev/null +++ b/lib/live_book_web/live/section.ex @@ -0,0 +1,43 @@ +defmodule LiveBookWeb.Section do + use LiveBookWeb, :live_component + + def render(assigns) do + ~L""" +
"> +
+
+ <%= Icons.svg(:chevron_right, class: "h-8") %> +

<%= @section.name %>

+
+
+ +
+
+
+
+ <%= live_component @socket, LiveBookWeb.InsertCellActions, + section_id: @section.id, + index: 0 %> + <%= for {cell, index} <- Enum.with_index(@section.cells) do %> + <%= live_component @socket, LiveBookWeb.Cell, + cell: cell, + focused: cell.id == @focused_cell_id %> + <%= live_component @socket, LiveBookWeb.InsertCellActions, + section_id: @section.id, + index: index + 1 %> + <% end %> +
+
+
+ """ + end +end diff --git a/lib/live_book_web/live/session_live.ex b/lib/live_book_web/live/session_live.ex index 2f84c68e9..d699c0149 100644 --- a/lib/live_book_web/live/session_live.ex +++ b/lib/live_book_web/live/session_live.ex @@ -1,17 +1,158 @@ defmodule LiveBookWeb.SessionLive do use LiveBookWeb, :live_view + alias LiveBook.{SessionSupervisor, Session} + @impl true def mount(%{"id" => session_id}, _session, socket) do - {:ok, assign(socket, session_id: session_id)} + if SessionSupervisor.session_exists?(session_id) do + data = + if connected?(socket) do + data = Session.register_client(session_id, self()) + Phoenix.PubSub.subscribe(LiveBook.PubSub, "sessions:#{session_id}") + + data + else + Session.get_data(session_id) + end + + {:ok, assign(socket, initial_assigns(session_id, data))} + else + {:ok, redirect(socket, to: Routes.live_path(socket, LiveBookWeb.SessionsLive))} + end + end + + defp initial_assigns(session_id, data) do + first_section_id = + case data.notebook.sections do + [section | _] -> section.id + [] -> nil + end + + %{ + session_id: session_id, + data: data, + selected_section_id: first_section_id, + focused_cell_id: nil + } end @impl true def render(assigns) do ~L""" -
- Session <%= @session_id %> +
+
+

<%= @data.notebook.name %>

+
+ <%= for section <- @data.notebook.sections do %> +
+ + <%= section.name %> + +
+ <% end %> +
+
+ <%= Icons.svg(:plus, class: "h-6") %> + New section +
+
+
+
+
+
+ <%= for section <- @data.notebook.sections do %> + <%= live_component @socket, LiveBookWeb.Section, + section: section, + selected: section.id == @selected_section_id, + focused_cell_id: @focused_cell_id %> + <% end %> +
+
""" end + + @impl true + def handle_event("add_section", _params, socket) do + end_index = length(socket.assigns.data.notebook.sections) + Session.insert_section(socket.assigns.session_id, end_index) + + {:noreply, socket} + end + + def handle_event("delete_section", %{"section_id" => section_id}, socket) do + Session.delete_section(socket.assigns.session_id, section_id) + + {:noreply, socket} + end + + def handle_event("select_section", %{"section_id" => section_id}, socket) do + {:noreply, assign(socket, selected_section_id: section_id)} + end + + def handle_event( + "insert_cell", + %{"section_id" => section_id, "index" => index, "type" => type}, + socket + ) do + index = String.to_integer(index) |> max(0) + type = String.to_atom(type) + Session.insert_cell(socket.assigns.session_id, section_id, index, type) + + {:noreply, socket} + end + + def handle_event("delete_cell", %{"cell_id" => cell_id}, socket) do + Session.delete_cell(socket.assigns.session_id, cell_id) + + {:noreply, socket} + end + + def handle_event("focus_cell", %{"cell_id" => cell_id}, socket) do + {:noreply, assign(socket, focused_cell_id: cell_id)} + end + + def handle_event("set_notebook_name", %{"name" => name}, socket) do + name = normalize_name(name) + Session.set_notebook_name(socket.assigns.session_id, name) + + {:noreply, socket} + end + + def handle_event("set_section_name", %{"section_id" => section_id, "name" => name}, socket) do + name = normalize_name(name) + Session.set_section_name(socket.assigns.session_id, section_id, name) + + {:noreply, socket} + end + + defp normalize_name(name) do + name + |> String.trim() + |> String.replace(~r/\s+/, " ") + |> case do + "" -> "Untitled" + name -> name + end + end + + @impl true + def handle_info({:operation, operation}, socket) do + case Session.Data.apply_operation(socket.assigns.data, operation) do + {:ok, data, _actions} -> + {:noreply, assign(socket, data: data)} + + :error -> + {:noreply, socket} + end + end end diff --git a/lib/live_book_web/live/sessions_live.ex b/lib/live_book_web/live/sessions_live.ex index d535203e0..77e15eb55 100644 --- a/lib/live_book_web/live/sessions_live.ex +++ b/lib/live_book_web/live/sessions_live.ex @@ -22,14 +22,12 @@ defmodule LiveBookWeb.SessionsLive do
<%= for session_id <- Enum.sort(@session_ids) do %>
-
+
<%= live_redirect session_id, to: Routes.live_path(@socket, LiveBookWeb.SessionLive, session_id) %>
-
diff --git a/lib/live_book_web/templates/layout/live.html.leex b/lib/live_book_web/templates/layout/live.html.leex index 31d4dc7dc..63b573a03 100644 --- a/lib/live_book_web/templates/layout/live.html.leex +++ b/lib/live_book_web/templates/layout/live.html.leex @@ -1,15 +1,4 @@ - - -
+