Integrate evaluation into UI (#17)

* Render evaluation outputs and result

* Fix auto-scrolling to not be interrupted by editor focus

* Add cell output tests

* Run formatter

* Show cell status

* Apply review suggestions

* Change EEx strings to Live EEx
This commit is contained in:
Jonatan Kłosko 2021-02-02 19:58:06 +01:00 committed by GitHub
parent 936d0af5fb
commit 77b60c8110
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
12 changed files with 378 additions and 87 deletions

View file

@ -121,3 +121,17 @@ iframe[hidden] {
.bg-editor {
background-color: #282c34;
}
.tiny-scrollbar::-webkit-scrollbar {
width: 0.4rem;
height: 0.4rem;
}
.tiny-scrollbar::-webkit-scrollbar-thumb {
border-radius: 0.25rem;
@apply bg-gray-400;
}
.tiny-scrollbar::-webkit-scrollbar-track {
@apply bg-gray-100;
}

View file

@ -67,6 +67,10 @@ const Cell = {
if (isActive(prevProps) && !isActive(this.props)) {
this.liveEditor.blur();
}
if (!prevProps.isFocused && this.props.isFocused) {
this.el.scrollIntoView({ behavior: "smooth", block: "center" });
}
},
};

View file

@ -14,7 +14,7 @@ const Session = {
this.props = getProps(this);
// Keybindings
document.addEventListener("keydown", (event) => {
this.handleDocumentKeydown = (event) => {
if (event.shiftKey && event.key === "Enter" && !event.repeat) {
if (this.props.focusedCellId !== null) {
// If the editor is focused we don't want it to receive the input
@ -27,33 +27,35 @@ const Session = {
} else if (event.altKey && event.key === "k") {
event.preventDefault();
this.pushEvent("move_cell_focus", { offset: -1 });
} else if (event.ctrlKey && event.key === "Enter") {
event.stopPropagation();
this.pushEvent("queue_cell_evaluation", {});
}
});
};
document.addEventListener("keydown", this.handleDocumentKeydown, true);
// Focus/unfocus a cell when the user clicks somewhere
document.addEventListener("click", (event) => {
this.handleDocumentClick = (event) => {
// Find the parent with cell id info, if there is one
const cell = event.target.closest("[data-cell-id]");
const cellId = cell ? cell.dataset.cellId : null;
if (cellId !== this.props.focusedCellId) {
this.pushEvent("focus_cell", { cell_id: cellId });
}
});
};
document.addEventListener("click", this.handleDocumentClick);
},
updated() {
const prevProps = this.props;
this.props = getProps(this);
// When a new cell gets focus, center it nicely on the page
if (
this.props.focusedCellId &&
this.props.focusedCellId !== prevProps.focusedCellId
) {
const cell = this.el.querySelector(`#cell-${this.props.focusedCellId}`);
cell.scrollIntoView({ behavior: "smooth", block: "center" });
}
},
destroyed() {
document.removeEventListener("keydown", this.handleDocumentKeydown);
document.removeEventListener("click", this.handleDocumentClick);
}
};
function getProps(hook) {

View file

@ -18,7 +18,6 @@ defmodule LiveBook.Notebook.Cell do
id: id(),
type: type(),
source: String.t(),
# TODO: expand on this
outputs: list(),
metadata: %{atom() => term()}
}

View file

@ -326,22 +326,35 @@ defmodule LiveBook.Session.Data do
|> set_cell_info!(cell.id, evaluation_status: :ready)
end
defp add_cell_evaluation_stdout({data, _} = data_actions, _cell, _string) do
defp add_cell_evaluation_stdout({data, _} = data_actions, cell, string) do
data_actions
|> set!(
# TODO: add stdout to cell outputs
notebook: data.notebook
notebook:
Notebook.update_cell(data.notebook, cell.id, fn cell ->
%{cell | outputs: add_output(cell.outputs, string)}
end)
)
end
defp add_cell_evaluation_response({data, _} = data_actions, _cell, _response) do
defp add_cell_evaluation_response({data, _} = data_actions, cell, response) do
data_actions
|> set!(
# TODO: add result to outputs
notebook: data.notebook
notebook:
Notebook.update_cell(data.notebook, cell.id, fn cell ->
%{cell | outputs: add_output(cell.outputs, response)}
end)
)
end
defp add_output([], output), do: [output]
defp add_output([head | tail], output) when is_binary(head) and is_binary(output) do
# Merge consecutive string outputs
[head <> output | tail]
end
defp add_output(outputs, output), do: [output | outputs]
defp finish_cell_evaluation(data_actions, cell, section) do
data_actions
|> set_cell_info!(cell.id,

View file

@ -17,14 +17,16 @@ defmodule LiveBookWeb.Cell do
def render_cell_content(%{cell: %{type: :markdown}} = assigns) do
~L"""
<div class="flex flex-col items-center space-y-2 absolute right-0 top-0 -mr-10">
<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>
<%= if @focused do %>
<div class="flex flex-col items-center space-y-2 absolute right-0 top-0 -mr-10">
<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>
<% end %>
<div class="<%= if @expanded, do: "mb-4", else: "hidden" %>">
<%= render_editor(@cell) %>
<%= render_editor(@cell, @cell_info) %>
</div>
<div class="markdown" data-markdown-container id="markdown-container-<%= @cell.id %>" phx-update="ignore">
@ -35,26 +37,44 @@ defmodule LiveBookWeb.Cell do
def render_cell_content(%{cell: %{type: :elixir}} = assigns) do
~L"""
<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>
<%= if @focused do %>
<div class="flex flex-col items-center space-y-2 absolute right-0 top-0 -mr-10">
<button phx-click="queue_cell_evaluation" phx-value-cell_id="<%= @cell.id %>" 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>
<% end %>
<%= render_editor(@cell) %>
<%= render_editor(@cell, @cell_info, show_status: true) %>
<%= if @cell.outputs != [] do %>
<div class="mt-2">
<%= render_outputs(@cell.outputs) %>
</div>
<% end %>
"""
end
defp render_editor(cell) do
~E"""
<div class="py-3 rounded-md overflow-hidden bg-editor"
data-editor-container
id="editor-container-<%= cell.id %>"
phx-update="ignore">
<%= render_editor_content_placeholder(cell.source) %>
defp render_editor(cell, cell_info, opts \\ []) do
show_status = Keyword.get(opts, :show_status, false)
assigns = %{cell: cell, cell_info: cell_info, show_status: show_status}
~L"""
<div class="py-3 rounded-md overflow-hidden bg-editor relative">
<div id="editor-container-<%= @cell.id %>"
data-editor-container
phx-update="ignore">
<%= render_editor_content_placeholder(@cell.source) %>
</div>
<%= if @show_status do %>
<div class="absolute bottom-2 right-2 z-50">
<%= render_cell_status(@cell_info) %>
</div>
<% end %>
</div>
"""
end
@ -64,13 +84,17 @@ defmodule LiveBookWeb.Cell do
# or and editors are mounted, so show neat placeholders immediately.
defp render_markdown_content_placeholder("" = _content) do
~E"""
assigns = %{}
~L"""
<div class="h-4"></div>
"""
end
defp render_markdown_content_placeholder(_content) do
~E"""
assigns = %{}
~L"""
<div class="max-w-2xl w-full animate-pulse">
<div class="flex-1 space-y-4">
<div class="h-4 bg-gray-200 rounded w-3/4"></div>
@ -82,13 +106,17 @@ defmodule LiveBookWeb.Cell do
end
defp render_editor_content_placeholder("" = _content) do
~E"""
assigns = %{}
~L"""
<div class="h-4"></div>
"""
end
defp render_editor_content_placeholder(_content) do
~E"""
assigns = %{}
~L"""
<div class="px-8 max-w-2xl w-full animate-pulse">
<div class="flex-1 space-y-4 py-1">
<div class="h-4 bg-gray-500 rounded w-3/4"></div>
@ -98,4 +126,98 @@ defmodule LiveBookWeb.Cell do
</div>
"""
end
defp render_outputs(outputs) do
assigns = %{outputs: outputs}
~L"""
<div class="flex flex-col rounded-md border border-gray-200 divide-y divide-gray-200 text-sm">
<%= for output <- Enum.reverse(@outputs) do %>
<div class="p-4">
<div class="max-h-80 overflow-auto tiny-scrollbar">
<%= render_output(output) %>
</div>
</div>
<% end %>
</div>
"""
end
defp render_output(output) when is_binary(output) do
assigns = %{output: output}
~L"""
<div class="whitespace-pre text-gray-500"><%= @output %></div>
"""
end
defp render_output({:ok, value}) do
inspected = inspect(value, pretty: true, width: 140)
assigns = %{inspected: inspected}
~L"""
<div class="whitespace-pre text-gray-500"><%= @inspected %></div>
"""
end
defp render_output({:error, kind, error, stacktrace}) do
formatted = Exception.format(kind, error, stacktrace)
assigns = %{formatted: formatted}
~L"""
<div class="whitespace-pre text-red-600"><%= @formatted %></div>
"""
end
defp render_cell_status(%{evaluation_status: :evaluating}) do
assigns = %{}
~L"""
<div class="flex items-center space-x-2">
<div class="text-xs text-gray-400">Evaluating</div>
<span class="flex relative h-3 w-3">
<span class="animate-ping absolute inline-flex h-3 w-3 rounded-full bg-blue-300 opacity-75"></span>
<span class="relative inline-flex rounded-full h-3 w-3 bg-blue-400"></span>
</span>
</div>
"""
end
defp render_cell_status(%{evaluation_status: :queued}) do
assigns = %{}
~L"""
<div class="flex items-center space-x-2">
<div class="text-xs text-gray-400">Queued</div>
<span class="flex relative h-3 w-3">
<span class="animate-ping absolute inline-flex h-3 w-3 rounded-full bg-gray-400 opacity-75"></span>
<span class="relative inline-flex rounded-full h-3 w-3 bg-gray-500"></span>
</span>
</div>
"""
end
defp render_cell_status(%{validity_status: :evaluated}) do
assigns = %{}
~L"""
<div class="flex items-center space-x-2">
<div class="text-xs text-gray-400">Evaluated</div>
<div class="h-3 w-3 rounded-full bg-green-400"></div>
</div>
"""
end
defp render_cell_status(%{validity_status: :stale}) do
assigns = %{}
~L"""
<div class="flex items-center space-x-2">
<div class="text-xs text-gray-400">Stale</div>
<div class="h-3 w-3 rounded-full bg-yellow-200"></div>
</div>
"""
end
defp render_cell_status(_), do: nil
end

View file

@ -1,44 +1,55 @@
defmodule LiveBookWeb.Icons do
import Phoenix.HTML
import Phoenix.HTML.Tag
import Phoenix.LiveView.Helpers
@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" />
assigns = %{attrs: heroicon_svg_attrs(attrs)}
~L"""
<%= tag(:svg, @attrs) %>
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7" />
</svg>
"""
|> 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" />
assigns = %{attrs: heroicon_svg_attrs(attrs)}
~L"""
<%= tag(:svg, @attrs) %>
<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" />
</svg>
"""
|> 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" />
assigns = %{attrs: heroicon_svg_attrs(attrs)}
~L"""
<%= tag(:svg, @attrs) %>
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 6v6m0 0v6m0-6h6m-6 0H6" />
</svg>
"""
|> 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" />
assigns = %{attrs: heroicon_svg_attrs(attrs)}
~L"""
<%= tag(:svg, @attrs) %>
<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" />
</svg>
"""
|> heroicon_svg(attrs)
end
# https://heroicons.com
defp heroicon_svg(svg_content, attrs) do
defp heroicon_svg_attrs(attrs) do
heroicon_svg_attrs = [
xmlns: "http://www.w3.org/2000/svg",
fill: "none",
@ -46,6 +57,6 @@ defmodule LiveBookWeb.Icons do
stroke: "currentColor"
]
content_tag(:svg, svg_content, Keyword.merge(attrs, heroicon_svg_attrs))
Keyword.merge(attrs, heroicon_svg_attrs)
end
end

View file

@ -27,7 +27,9 @@ defmodule LiveBookWeb.InsertCellActions do
end
defp line() do
~e"""
assigns = %{}
~L"""
<div class="border-t-2 border-dashed border-gray-200 flex-grow"></div>
"""
end

View file

@ -25,15 +25,18 @@ defmodule LiveBookWeb.Section do
<div class="container py-4">
<div class="flex flex-col space-y-2 pb-80">
<%= live_component @socket, LiveBookWeb.InsertCellActions,
id: "#{@section.id}:0",
section_id: @section.id,
index: 0 %>
<%= for {cell, index} <- Enum.with_index(@section.cells) do %>
<%= live_component @socket, LiveBookWeb.Cell,
id: cell.id,
cell: cell,
cell_info: @cell_infos[cell.id],
focused: @selected and cell.id == @focused_cell_id,
expanded: @selected and cell.id == @focused_cell_id and @focused_cell_expanded %>
<%= live_component @socket, LiveBookWeb.InsertCellActions,
id: "#{@section.id}:#{index + 1}",
section_id: @section.id,
index: index + 1 %>
<% end %>

View file

@ -75,6 +75,7 @@ defmodule LiveBookWeb.SessionLive do
<div class="max-w-screen-lg w-full mx-auto">
<%= for section <- @data.notebook.sections do %>
<%= live_component @socket, LiveBookWeb.Section,
id: section.id,
section: section,
selected: section.id == @selected_section_id,
cell_infos: @data.cell_infos,
@ -187,6 +188,20 @@ defmodule LiveBookWeb.SessionLive do
end
end
def handle_event("queue_cell_evaluation", %{"cell_id" => cell_id}, socket) do
Session.queue_cell_evaluation(socket.assigns.session_id, cell_id)
{:noreply, socket}
end
def handle_event("queue_cell_evaluation", %{}, socket) do
if socket.assigns.focused_cell_id do
Session.queue_cell_evaluation(socket.assigns.session_id, socket.assigns.focused_cell_id)
end
{:noreply, socket}
end
@impl true
def handle_info({:operation, operation}, socket) do
case Session.Data.apply_operation(socket.assigns.data, operation) do

View file

@ -252,14 +252,73 @@ defmodule LiveBook.Session.DataTest do
end
describe "apply_operation/2 given :add_cell_evaluation_stdout" do
test "update the cell output" do
# TODO assert against output being updated once we do so
test "updates the cell outputs" do
data =
data_after_operations!([
{:insert_section, 0, "s1"},
{:insert_cell, "s1", 0, :elixir, "c1"},
{:queue_cell_evaluation, "c1"}
])
operation = {:add_cell_evaluation_stdout, "c1", "Hello!"}
assert {:ok,
%{
notebook: %{
sections: [
%{
cells: [%{outputs: ["Hello!"]}]
}
]
}
}, []} = Data.apply_operation(data, operation)
end
test "merges consecutive stdout results" do
data =
data_after_operations!([
{:insert_section, 0, "s1"},
{:insert_cell, "s1", 0, :elixir, "c1"},
{:queue_cell_evaluation, "c1"},
{:add_cell_evaluation_stdout, "c1", "Hello"}
])
operation = {:add_cell_evaluation_stdout, "c1", " amigo!"}
assert {:ok,
%{
notebook: %{
sections: [
%{
cells: [%{outputs: ["Hello amigo!"]}]
}
]
}
}, []} = Data.apply_operation(data, operation)
end
end
describe "apply_operation/2 given :add_cell_evaluation_response" do
test "update the cell output" do
# TODO assert against output being updated once we do so
test "updates the cell outputs" do
data =
data_after_operations!([
{:insert_section, 0, "s1"},
{:insert_cell, "s1", 0, :elixir, "c1"},
{:queue_cell_evaluation, "c1"}
])
operation = {:add_cell_evaluation_response, "c1", {:ok, [1, 2, 3]}}
assert {:ok,
%{
notebook: %{
sections: [
%{
cells: [%{outputs: [{:ok, [1, 2, 3]}]}]
}
]
}
}, []} = Data.apply_operation(data, operation)
end
test "marks the cell as evaluated" do

View file

@ -3,7 +3,7 @@ defmodule LiveBookWeb.SessionLiveTest do
import Phoenix.LiveViewTest
alias LiveBook.{SessionSupervisor, Session}
alias LiveBook.{SessionSupervisor, Session, Delta}
setup do
{:ok, session_id} = SessionSupervisor.create_session()
@ -29,48 +29,42 @@ defmodule LiveBookWeb.SessionLiveTest do
test "renders a newly inserted section", %{conn: conn, session_id: session_id} do
{:ok, view, _} = live(conn, "/sessions/#{session_id}")
Session.insert_section(session_id, 0)
%{notebook: %{sections: [section]}} = Session.get_data(session_id)
section_id = insert_section(session_id)
assert render(view) =~ section.id
assert render(view) =~ section_id
end
test "renders an updated section name", %{conn: conn, session_id: session_id} do
Session.insert_section(session_id, 0)
%{notebook: %{sections: [section]}} = Session.get_data(session_id)
section_id = insert_section(session_id)
{:ok, view, _} = live(conn, "/sessions/#{session_id}")
Session.set_section_name(session_id, section.id, "My section")
Session.set_section_name(session_id, section_id, "My section")
wait_for_session_update(session_id)
assert render(view) =~ "My section"
end
test "renders a newly inserted cell", %{conn: conn, session_id: session_id} do
Session.insert_section(session_id, 0)
%{notebook: %{sections: [section]}} = Session.get_data(session_id)
section_id = insert_section(session_id)
{:ok, view, _} = live(conn, "/sessions/#{session_id}")
Session.insert_cell(session_id, section.id, 0, :markdown)
%{notebook: %{sections: [%{cells: [cell]}]}} = Session.get_data(session_id)
cell_id = insert_cell(session_id, section_id, :markdown)
assert render(view) =~ cell.id
assert render(view) =~ cell_id
end
test "un-renders a deleted cell", %{conn: conn, session_id: session_id} do
Session.insert_section(session_id, 0)
%{notebook: %{sections: [section]}} = Session.get_data(session_id)
Session.insert_cell(session_id, section.id, 0, :markdown)
%{notebook: %{sections: [%{cells: [cell]}]}} = Session.get_data(session_id)
section_id = insert_section(session_id)
cell_id = insert_cell(session_id, section_id, :markdown)
{:ok, view, _} = live(conn, "/sessions/#{session_id}")
Session.delete_cell(session_id, cell.id)
Session.delete_cell(session_id, cell_id)
wait_for_session_update(session_id)
refute render(view) =~ cell.id
refute render(view) =~ cell_id
end
end
@ -97,6 +91,40 @@ defmodule LiveBookWeb.SessionLiveTest do
assert %{notebook: %{sections: [%{cells: [%{type: :markdown}]}]}} =
Session.get_data(session_id)
end
test "queueing cell evaluation updates the shared state", %{conn: conn, session_id: session_id} do
section_id = insert_section(session_id)
cell_id = insert_cell(session_id, section_id, :elixir, "Process.sleep(5)")
{:ok, view, _} = live(conn, "/sessions/#{session_id}")
view
|> element("#session")
|> render_hook("queue_cell_evaluation", %{"cell_id" => cell_id})
assert %{cell_infos: %{^cell_id => %{evaluation_status: :evaluating}}} =
Session.get_data(session_id)
end
test "queueing cell evaluation defaults to the focused cell if no cell id is given",
%{conn: conn, session_id: session_id} do
section_id = insert_section(session_id)
cell_id = insert_cell(session_id, section_id, :elixir, "Process.sleep(5)")
{:ok, view, _} = live(conn, "/sessions/#{session_id}")
view
|> element("#session")
|> render_hook("focus_cell", %{"cell_id" => cell_id})
view
|> element("#session")
|> render_hook("queue_cell_evaluation", %{})
assert %{cell_infos: %{^cell_id => %{evaluation_status: :evaluating}}} =
Session.get_data(session_id)
end
end
defp wait_for_session_update(session_id) do
@ -105,4 +133,23 @@ defmodule LiveBookWeb.SessionLiveTest do
Session.get_data(session_id)
:ok
end
# Utils for sending session requests, waiting for the change to be applied
# and retrieving new ids if applicable.
defp insert_section(session_id) do
Session.insert_section(session_id, 0)
%{notebook: %{sections: [section]}} = Session.get_data(session_id)
section.id
end
defp insert_cell(session_id, section_id, type, content \\ "") do
Session.insert_cell(session_id, section_id, 0, type)
%{notebook: %{sections: [%{cells: [cell]}]}} = Session.get_data(session_id)
delta = Delta.new(insert: content)
Session.apply_cell_delta(session_id, self(), cell.id, delta, 1)
cell.id
end
end