mirror of
https://github.com/livebook-dev/livebook.git
synced 2025-09-04 20:14:57 +08:00
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:
parent
0e593c3719
commit
80cd651b0f
16 changed files with 506 additions and 25 deletions
|
@ -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;
|
||||
}
|
||||
|
|
|
@ -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
|
||||
|
|
39
assets/js/content_editable.js
Normal file
39
assets/js/content_editable.js
Normal 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;
|
|
@ -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.
|
||||
|
||||
|
|
|
@ -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)}}
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -87,6 +87,9 @@ defmodule LiveBookWeb do
|
|||
|
||||
import LiveBookWeb.ErrorHelpers
|
||||
alias LiveBookWeb.Router.Helpers, as: Routes
|
||||
|
||||
# Custom helpers
|
||||
alias LiveBookWeb.Icons
|
||||
end
|
||||
end
|
||||
|
||||
|
|
31
lib/live_book_web/live/cell.ex
Normal file
31
lib/live_book_web/live/cell.ex
Normal 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
|
51
lib/live_book_web/live/icons.ex
Normal file
51
lib/live_book_web/live/icons.ex
Normal 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
|
34
lib/live_book_web/live/insert_cell_actions.ex
Normal file
34
lib/live_book_web/live/insert_cell_actions.ex
Normal 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
|
43
lib/live_book_web/live/section.ex
Normal file
43
lib/live_book_web/live/section.ex
Normal 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
|
|
@ -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
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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"
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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}}
|
||||
|
|
Loading…
Add table
Reference in a new issue