Add support for layout outputs (#1326)

This commit is contained in:
Jonatan Kłosko 2022-08-04 00:24:15 +02:00 committed by GitHub
parent 4dc1deab24
commit 194e31e2d9
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
7 changed files with 277 additions and 11 deletions

View file

@ -201,6 +201,25 @@ defmodule Livebook.LiveMarkdown.Export do
end
end
defp render_output({:tabs, outputs, _info}, ctx) do
Enum.find_value(outputs, :ignored, fn {_idx, output} ->
case render_output(output, ctx) do
:ignored -> nil
rendered -> rendered
end
end)
end
defp render_output({:grid, outputs, _info}, ctx) do
outputs
|> Enum.map(fn {_idx, output} -> render_output(output, ctx) end)
|> Enum.reject(&(&1 == :ignored))
|> case do
[] -> :ignored
rendered -> Enum.intersperse(rendered, "\n\n")
end
end
defp render_output(_output, _ctx), do: :ignored
defp encode_js_data(data) when is_binary(data), do: {:ok, data}

View file

@ -590,9 +590,14 @@ defmodule Livebook.Notebook do
defp find_assets_info_in_outputs(outputs, hash) do
Enum.find_value(outputs, fn
{_idx, {:js, %{js_view: %{assets: %{hash: ^hash} = assets_info}}}} -> assets_info
{_idx, {:frame, outputs, _}} -> find_assets_info_in_outputs(outputs, hash)
_ -> nil
{_idx, {:js, %{js_view: %{assets: %{hash: ^hash} = assets_info}}}} ->
assets_info
{_idx, {type, outputs, _}} when type in [:frame, :tabs, :grid] ->
find_assets_info_in_outputs(outputs, hash)
_ ->
nil
end)
end
@ -650,11 +655,11 @@ defmodule Livebook.Notebook do
defp apply_frame_update(_outputs, new_outputs, :replace), do: new_outputs
defp apply_frame_update(outputs, new_outputs, :append), do: new_outputs ++ outputs
defp add_output(outputs, {_idx, :ignored}), do: outputs
defp add_output([], {idx, {:stdout, text}}),
do: [{idx, {:stdout, normalize_stdout(text)}}]
defp add_output(outputs, {_idx, :ignored}), do: outputs
defp add_output([], output), do: [output]
# Session clients prune stdout content and handle subsequent
@ -696,9 +701,9 @@ defmodule Livebook.Notebook do
Enum.map_reduce(outputs, counter, &index_output/2)
end
defp index_output({:frame, outputs, info}, counter) do
defp index_output({type, outputs, info}, counter) when type in [:frame, :tabs, :grid] do
{outputs, counter} = index_outputs(outputs, counter)
{{counter, {:frame, outputs, info}}, counter + 1}
{{counter, {type, outputs, info}}, counter + 1}
end
defp index_output(output, counter) do
@ -721,7 +726,8 @@ defmodule Livebook.Notebook do
[output]
end
defp do_find_frame_outputs({_idx, {:frame, outputs, _info}}, ref) do
defp do_find_frame_outputs({_idx, {type, outputs, _info}}, ref)
when type in [:frame, :tabs, :grid] do
Enum.flat_map(outputs, &do_find_frame_outputs(&1, ref))
end
@ -758,6 +764,18 @@ defmodule Livebook.Notebook do
do_prune_outputs(outputs, [{idx, {:frame, prune_outputs(frame_outputs), info}} | acc])
end
# Keep layout output and its relevant contents
defp do_prune_outputs([{idx, {type, tabs_outputs, _info}} | outputs], acc)
when type in [:tabs, :grid] do
case prune_outputs(tabs_outputs) do
[] ->
do_prune_outputs(outputs, acc)
pruned_tabs_outputs ->
do_prune_outputs(outputs, [{idx, {type, pruned_tabs_outputs, :__pruned__}} | acc])
end
end
# Keep outputs that get re-rendered
defp do_prune_outputs([{idx, output} | outputs], acc)
when elem(output, 0) in [:input, :control, :error] do

View file

@ -62,7 +62,7 @@ defmodule Livebook.Notebook.Cell do
Keyword.values(fields)
end
def find_inputs_in_output({_idx, {:frame, outputs, _}}) do
def find_inputs_in_output({_idx, {type, outputs, _}}) when type in [:frame, :tabs, :grid] do
Enum.flat_map(outputs, &find_inputs_in_output/1)
end

View file

@ -52,6 +52,10 @@ defprotocol Livebook.Runtime do
| {:js, info :: map()}
# Outputs placeholder
| {:frame, outputs :: list(output()), info :: map()}
# Outputs in tabs
| {:tabs, outputs :: list(output()), info :: map()}
# Outputs in grid
| {:grid, outputs :: list(output()), info :: map()}
# An input field
| {:input, attrs :: map()}
# A control element

View file

@ -3,6 +3,7 @@ defmodule LivebookWeb.Output do
import LivebookWeb.Helpers
alias Phoenix.LiveView.JS
alias LivebookWeb.Output
@doc """
@ -35,6 +36,8 @@ defmodule LivebookWeb.Output do
defp border?(_output), do: false
defp wrapper?({:frame, _outputs, _info}), do: true
defp wrapper?({:tabs, _tabs, _info}), do: true
defp wrapper?({:grid, _tabs, _info}), do: true
defp wrapper?(_output), do: false
defp render_output({:stdout, text}, %{id: id}) do
@ -88,6 +91,126 @@ defmodule LivebookWeb.Output do
)
end
defp render_output({:tabs, outputs, info}, %{
id: id,
input_values: input_values,
session_id: session_id,
socket: socket
}) do
{labels, active_idx} =
if info == :__pruned__ do
{[], nil}
else
labels =
Enum.zip_with(info.labels, outputs, fn label, {output_idx, _} -> {output_idx, label} end)
active_idx = get_in(outputs, [Access.at(0), Access.elem(0)])
{labels, active_idx}
end
assigns = %{
id: id,
active_idx: active_idx,
labels: labels,
outputs: outputs,
socket: socket,
session_id: session_id,
input_values: input_values
}
# After pruning we don't render labels and we render only those
# outputs that are kept during pruning
~H"""
<div id={@id}>
<div class="tabs mb-2" id={"#{@id}-tabs"} phx-update="append">
<%= for {output_idx, label} <- @labels do %>
<button
id={"#{@id}-tabs-#{output_idx}"}
class={"tab #{if(output_idx == @active_idx, do: "active")}"}
phx-click={
JS.remove_class("active", to: "##{@id}-tabs .tab.active")
|> JS.add_class("active")
|> JS.add_class("hidden", to: "##{@id}-tab-contents > *:not(.hidden)")
|> JS.remove_class("hidden", to: "##{@id}-tab-content-#{output_idx}")
}
>
<%= label %>
</button>
<% end %>
</div>
<div id={"#{@id}-tab-contents"} phx-update="append">
<%= for {output_idx, output} <- @outputs do %>
<% # We use data-keep-attribute, because we know active_idx only on the first render %>
<div
id={"#{@id}-tab-content-#{output_idx}"}
data-tab-content={output_idx}
class={"#{if(output_idx != @active_idx, do: "hidden")}"}
data-keep-attribute="class"
>
<.outputs
outputs={[{output_idx, output}]}
dom_id_map={%{}}
socket={@socket}
session_id={@session_id}
input_values={@input_values}
/>
</div>
<% end %>
</div>
</div>
"""
end
defp render_output({:grid, outputs, info}, %{
id: id,
input_values: input_values,
session_id: session_id,
socket: socket
}) do
style =
if info == :__pruned__ do
nil
else
columns = info[:columns] || 1
"grid-template-columns: repeat(#{columns}, minmax(0, 1fr));"
end
assigns = %{
id: id,
style: style,
outputs: outputs,
socket: socket,
session_id: session_id,
input_values: input_values
}
~H"""
<div id={@id} class="overflow-auto tiny-scrollbar">
<div
id={"#{@id}-grid"}
class="grid grid-cols-2 gap-x-4 w-full"
style={@style}
data-keep-attribute="style"
phx-update="append"
>
<%= for {output_idx, output} <- @outputs do %>
<div id={"#{@id}-grid-item-#{output_idx}"}>
<.outputs
outputs={[{output_idx, output}]}
dom_id_map={%{}}
socket={@socket}
session_id={@session_id}
input_values={@input_values}
/>
</div>
<% end %>
</div>
</div>
"""
end
defp render_output({:input, attrs}, %{id: id, input_values: input_values}) do
live_component(Output.InputComponent, id: id, attrs: attrs, input_values: input_values)
end

View file

@ -70,11 +70,11 @@ defmodule LivebookWeb.SidebarHelpers do
</button>
<% end %>
<button
class="mt-8 flex items-center group"
class="mt-8 flex items-center group border-l-4 border-transparent"
aria_label="user profile"
phx-click={show_current_user_modal()}
>
<div class="w-[60px] border-l-4 border-transparent flex justify-center group">
<div class="w-[56px] flex justify-center">
<.user_avatar
user={@current_user}
class="w-8 h-8 group-hover:ring-white group-hover:ring-2"

View file

@ -898,6 +898,108 @@ defmodule Livebook.LiveMarkdown.ExportTest do
end
end
test "includes only the first tabs output that can be exported" do
notebook = %{
Notebook.new()
| name: "My Notebook",
sections: [
%{
Notebook.Section.new()
| name: "Section 1",
cells: [
%{
Notebook.Cell.new(:code)
| source: ":ok",
outputs: [
{0,
{:tabs,
[
{1, {:markdown, "a"}},
{2, {:text, "b"}},
{3, {:text, "c"}}
], %{labels: ["A", "B", "C"]}}}
]
}
]
}
]
}
expected_document = """
# My Notebook
## Section 1
```elixir
:ok
```
<!-- livebook:{"output":true} -->
```
b
```
"""
document = Export.notebook_to_livemd(notebook, include_outputs: true)
assert expected_document == document
end
test "includes all grid outputs that can be exported" do
notebook = %{
Notebook.new()
| name: "My Notebook",
sections: [
%{
Notebook.Section.new()
| name: "Section 1",
cells: [
%{
Notebook.Cell.new(:code)
| source: ":ok",
outputs: [
{0,
{:grid,
[
{1, {:text, "a"}},
{2, {:markdown, "b"}},
{3, {:text, "c"}}
], %{columns: 2}}}
]
}
]
}
]
}
expected_document = """
# My Notebook
## Section 1
```elixir
:ok
```
<!-- livebook:{"output":true} -->
```
a
```
<!-- livebook:{"output":true} -->
```
c
```
"""
document = Export.notebook_to_livemd(notebook, include_outputs: true)
assert expected_document == document
end
test "includes outputs when notebook has :persist_outputs set" do
notebook = %{
Notebook.new()