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
This commit is contained in:
Jonatan Kłosko 2021-01-17 22:03:03 +01:00 committed by GitHub
parent 0e593c3719
commit 80cd651b0f
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
16 changed files with 506 additions and 25 deletions

View file

@ -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;
}

View file

@ -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

View file

@ -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;

View file

@ -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.

View file

@ -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)}}

View file

@ -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

View file

@ -87,6 +87,9 @@ defmodule LiveBookWeb do
import LiveBookWeb.ErrorHelpers
alias LiveBookWeb.Router.Helpers, as: Routes
# Custom helpers
alias LiveBookWeb.Icons
end
end

View file

@ -0,0 +1,31 @@
defmodule LiveBookWeb.Cell do
use LiveBookWeb, :live_component
def render(assigns) do
~L"""
<div phx-click="focus_cell"
phx-value-cell_id="<%= @cell.id %>"
class="flex flex-col p-2 relative mr-10 border-2 border-gray-200 rounded border-opacity-0 <%= if @focused, do: "border-opacity-100"%>">
<div class="flex flex-col items-center space-y-2 absolute right-0 top-0 -mr-10">
<button class="text-gray-500 hover:text-current">
<%= Icons.svg(:play, class: "h-6") %>
</button>
<button phx-click="delete_cell" phx-value-cell_id="<%= @cell.id %>" class="text-gray-500 hover:text-current">
<%= Icons.svg(:trash, class: "h-6") %>
</button>
</div>
<div
id="cell-<%= @cell.id %>"
phx-hook="Editor"
phx-update="ignore"
data-id="<%= @cell.id %>"
data-type="<%= @cell.type %>"
>
<div class="h-20 flex opacity-20">
<%= @cell.type |> Atom.to_string() |> String.capitalize() %> cell placeholder
</div>
</div>
</div>
"""
end
end

View file

@ -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"""
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7" />
"""
|> heroicon_svg(attrs)
end
def svg(:play, attrs) do
~e"""
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M14.752 11.168l-3.197-2.132A1 1 0 0010 9.87v4.263a1 1 0 001.555.832l3.197-2.132a1 1 0 000-1.664z" />
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
"""
|> heroicon_svg(attrs)
end
def svg(:plus, attrs) do
~e"""
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 6v6m0 0v6m0-6h6m-6 0H6" />
"""
|> heroicon_svg(attrs)
end
def svg(:trash, attrs) do
~e"""
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
"""
|> 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

View file

@ -0,0 +1,34 @@
defmodule LiveBookWeb.InsertCellActions do
use LiveBookWeb, :live_component
def render(assigns) do
~L"""
<div class="opacity-0 hover:opacity-100 flex space-x-2 justify-center items-center">
<%= line() %>
<button
phx-click="insert_cell"
phx-value-type="markdown"
phx-value-section_id="<%= @section_id %>"
phx-value-index="<%= @index %>"
class="py-1 px-2 rounded text-sm hover:bg-gray-100 border border-gray-200 bg-gray-50">
+ Markdown
</button>
<button
phx-click="insert_cell"
phx-value-type="elixir"
phx-value-section_id="<%= @section_id %>"
phx-value-index="<%= @index %>"
class="py-1 px-2 rounded text-sm hover:bg-gray-100 border border-gray-200 bg-gray-50">
+ Elixir
</button>
<%= line() %>
</div>
"""
end
defp line() do
~e"""
<div class="border-t-2 border-dashed border-gray-200 flex-grow"></div>
"""
end
end

View file

@ -0,0 +1,43 @@
defmodule LiveBookWeb.Section do
use LiveBookWeb, :live_component
def render(assigns) do
~L"""
<div class="<%= if not @selected, do: "hidden" %>">
<div class="flex justify-between items-center">
<div class="flex space-x-2 items-center text-gray-600">
<%= Icons.svg(:chevron_right, class: "h-8") %>
<h2 id="section-<%= @section.id %>-name"
contenteditable
spellcheck="false"
phx-blur="set_section_name"
phx-value-section_id="<%= @section.id %>"
phx-hook="ContentEditable"
data-update-attribute="phx-value-name"
class="text-3xl"><%= @section.name %></h2>
</div>
<div class="flex space-x-2 items-center">
<button phx-click="delete_section" phx-value-section_id="<%= @section.id %>" class="text-gray-600 hover:text-current">
<%= Icons.svg(:trash, class: "h-6") %>
</button>
</div>
</div>
<div class="container py-4">
<div class="flex flex-col space-y-2">
<%= 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 %>
</div>
</div>
</div>
"""
end
end

View file

@ -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"""
<div class="container max-w-screen-md p-4 mx-auto">
Session <%= @session_id %>
<div class="flex flex-grow max-h-full">
<div class="w-1/5 bg-gray-100 border-r-2 border-gray-200">
<h1 id="notebook-name"
contenteditable
spellcheck="false"
phx-blur="set_notebook_name"
phx-hook="ContentEditable"
data-update-attribute="phx-value-name"
class="p-8 text-2xl"><%= @data.notebook.name %></h1>
<div class="flex flex-col space-y-2 pl-4">
<%= for section <- @data.notebook.sections do %>
<div phx-click="select_section"
phx-value-section_id="<%= section.id %>"
class="py-2 px-4 rounded-l-md cursor-pointer text-gray-500 hover:text-current">
<span>
<%= section.name %>
</span>
</div>
<% end %>
<div phx-click="add_section" class="py-2 px-4 rounded-l-md cursor-pointer text-gray-300 hover:text-gray-400">
<div class="flex items-center space-x-2">
<%= Icons.svg(:plus, class: "h-6") %>
<span>New section</span>
</div>
</div>
</div>
</div>
<div class="flex-grow px-6 py-8 flex overflow-y-auto">
<div class="max-w-screen-lg w-full mx-auto">
<%= 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 %>
</div>
</div>
</div>
"""
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

View file

@ -22,14 +22,12 @@ defmodule LiveBookWeb.SessionsLive do
</div>
<%= for session_id <- Enum.sort(@session_ids) do %>
<div class="p-3 flex">
<div class="flex-grow text-lg hover:opacity-70">
<div class="flex-grow text-lg text-gray-500 hover:text-current">
<%= live_redirect session_id, to: Routes.live_path(@socket, LiveBookWeb.SessionLive, session_id) %>
</div>
<div>
<button phx-click="delete_session" phx-value-id="<%= session_id %>" aria-label="delete">
<svg class="h-6 w-6 hover:opacity-70" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
</svg>
<button phx-click="delete_session" phx-value-id="<%= session_id %>" aria-label="delete" class="text-gray-500 hover:text-current">
<%= Icons.svg(:trash, class: "h-6") %>
</button>
</div>
</div>

View file

@ -1,15 +1,4 @@
<nav class="flex items-center justify-between px-8 py-3 bg-purple-600">
<div class="w-full">
<%= live_redirect "LiveBook", to: "/", class: "font-bold inline-block mr-4 py-2 text-white" %>
</div>
<ul class="flex flex-row">
<li class="flex items-center">
<%= live_redirect "Sessions", to: "/sessions", class: "px-3 text-xs uppercase font-bold text-white hover:opacity-75" %>
</li>
</ul>
</nav>
<main role="main" class="container mx-auto flex flex-col align-center">
<main role="main" class="flex-grow flex flex-col align-center h-screen">
<div>
<p class="alert alert-info" role="alert"
phx-click="lv:clear-flash"

View file

@ -447,6 +447,37 @@ defmodule LiveBook.Session.DataTest do
end
end
describe "apply_operation/2 given :set_notebook_name" do
test "updates notebook name with the given string" do
data = Data.new()
operation = {:set_notebook_name, "Cat's guide to life"}
assert {:ok, %{notebook: %{name: "Cat's guide to life"}}, []} =
Data.apply_operation(data, operation)
end
end
describe "apply_operation/2 given :set_section_name" do
test "returns an error given invalid cell id" do
data = Data.new()
operation = {:set_section_name, "nonexistent", "Chapter 1"}
assert :error = Data.apply_operation(data, operation)
end
test "updates section name with the given string" do
data =
data_after_operations!([
{:insert_section, 0, "s1"}
])
operation = {:set_section_name, "s1", "Cat's guide to life"}
assert {:ok, %{notebook: %{sections: [%{name: "Cat's guide to life"}]}}, []} =
Data.apply_operation(data, operation)
end
end
defp data_after_operations!(operations) do
Enum.reduce(operations, Data.new(), fn operation, data ->
case Data.apply_operation(data, operation) do

View file

@ -84,6 +84,26 @@ defmodule LiveBook.SessionTest do
end
end
describe "set_notebook_name/2" do
test "sends a notebook name update operation to subscribers", %{session_id: session_id} do
Phoenix.PubSub.subscribe(LiveBook.PubSub, "sessions:#{session_id}")
Session.set_notebook_name(session_id, "Cat's guide to life")
assert_receive {:operation, {:set_notebook_name, "Cat's guide to life"}}
end
end
describe "set_section_name/3" do
test "sends a section name update operation to subscribers", %{session_id: session_id} do
Phoenix.PubSub.subscribe(LiveBook.PubSub, "sessions:#{session_id}")
{section_id, _cell_id} = insert_section_and_cell(session_id)
Session.set_section_name(session_id, section_id, "Chapter 1")
assert_receive {:operation, {:set_section_name, section_id, "Chapter 1"}}
end
end
defp insert_section_and_cell(session_id) do
Session.insert_section(session_id, 0)
assert_receive {:operation, {:insert_section, 0, section_id}}