mirror of
https://github.com/livebook-dev/livebook.git
synced 2025-09-04 12:04:20 +08:00
Add support for layout outputs (#1326)
This commit is contained in:
parent
4dc1deab24
commit
194e31e2d9
7 changed files with 277 additions and 11 deletions
|
@ -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}
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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"
|
||||
|
|
|
@ -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()
|
||||
|
|
Loading…
Add table
Reference in a new issue