Implement reactive input (#389)

* Implement reactive input

* Store reactive attribute only when truthy

* Polishing
This commit is contained in:
Jonatan Kłosko 2021-06-24 11:58:13 +02:00 committed by GitHub
parent 1689dae71c
commit 4ca6f9eb5e
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
14 changed files with 477 additions and 116 deletions

View file

@ -52,12 +52,14 @@ defmodule Livebook.LiveMarkdown.Export do
value = if cell.type == :password, do: "", else: cell.value
json =
Jason.encode!(%{
%{
livebook_object: :cell_input,
type: cell.type,
name: cell.name,
value: value
})
}
|> put_truthy(reactive: cell.reactive)
|> Jason.encode!()
"<!-- livebook:#{json} -->"
|> prepend_metadata(cell.metadata)
@ -120,4 +122,14 @@ defmodule Livebook.LiveMarkdown.Export do
_ -> code
end
end
defp put_truthy(map, entries) do
Enum.reduce(entries, map, fn {key, value}, map ->
if value do
Map.put(map, key, value)
else
map
end
end)
end
end

View file

@ -201,7 +201,9 @@ defmodule Livebook.LiveMarkdown.Import do
| metadata: metadata,
type: data["type"] |> String.to_existing_atom(),
name: data["name"],
value: data["value"]
value: data["value"],
# Optional flags
reactive: Map.get(data, "reactive", false)
}
build_notebook(elems, [cell | cells], sections)

View file

@ -6,7 +6,7 @@ defmodule Livebook.Notebook.Cell.Input do
# It consists of an input that the user may fill
# and then read during code evaluation.
defstruct [:id, :metadata, :type, :name, :value]
defstruct [:id, :metadata, :type, :name, :value, :reactive]
alias Livebook.Utils
alias Livebook.Notebook.Cell
@ -16,7 +16,8 @@ defmodule Livebook.Notebook.Cell.Input do
metadata: Cell.metadata(),
type: type(),
name: String.t(),
value: String.t()
value: String.t(),
reactive: boolean()
}
@type type :: :text | :url | :number | :password
@ -31,7 +32,8 @@ defmodule Livebook.Notebook.Cell.Input do
metadata: %{},
type: :text,
name: "input",
value: ""
value: "",
reactive: false
}
end
@ -62,4 +64,20 @@ defmodule Livebook.Notebook.Cell.Input do
_ -> {:error, "not a valid number"}
end
end
@doc """
Checks if the input changed in terms of content.
"""
@spec invalidated?(t(), t()) :: boolean()
def invalidated?(cell, prev_cell) do
cell.value != prev_cell.value or cell.name != prev_cell.name
end
@doc """
Checks if the input change should trigger reactive update.
"""
@spec reactive_update?(t(), t()) :: boolean()
def reactive_update?(cell, prev_cell) do
cell.reactive and cell.value != prev_cell.value and validate(cell) == :ok
end
end

View file

@ -495,8 +495,10 @@ defmodule Livebook.Session do
end
def handle_info({:evaluation_input, cell_id, reply_to, prompt}, state) do
input_cell = Notebook.input_cell_for_prompt(state.data.notebook, cell_id, prompt)
reply =
with {:ok, cell} <- Notebook.input_cell_for_prompt(state.data.notebook, cell_id, prompt),
with {:ok, cell} <- input_cell,
:ok <- Cell.Input.validate(cell) do
{:ok, cell.value <> "\n"}
else
@ -505,6 +507,15 @@ defmodule Livebook.Session do
send(reply_to, {:evaluation_input_reply, reply})
state =
case input_cell do
{:ok, input_cell} ->
handle_operation(state, {:bind_input, self(), cell_id, input_cell.id})
:error ->
state
end
{:noreply, state}
end

View file

@ -56,7 +56,9 @@ defmodule Livebook.Session.Data do
deltas: list(Delta.t()),
revision_by_client_pid: %{pid() => cell_revision()},
evaluation_digest: String.t() | nil,
evaluation_time_ms: integer() | nil
evaluation_time_ms: integer() | nil,
number_of_evaluations: non_neg_integer(),
bound_to_input_ids: MapSet.t(Cell.id())
}
@type cell_revision :: non_neg_integer()
@ -87,6 +89,7 @@ defmodule Livebook.Session.Data do
| {:evaluation_started, pid(), Cell.id(), binary()}
| {:add_cell_evaluation_output, pid(), Cell.id(), term()}
| {:add_cell_evaluation_response, pid(), Cell.id(), term()}
| {:bind_input, pid(), elixir_cell_id :: Cell.id(), input_cell_id :: Cell.id()}
| {:reflect_evaluation_failure, pid()}
| {:cancel_cell_evaluation, pid(), Cell.id()}
| {:set_notebook_name, pid(), String.t()}
@ -307,6 +310,20 @@ defmodule Livebook.Session.Data do
end
end
def apply_operation(data, {:bind_input, _client_pid, id, input_id}) do
with {:ok, %Cell.Elixir{} = cell, _section} <-
Notebook.fetch_cell_and_section(data.notebook, id),
{:ok, %Cell.Input{} = input_cell, _section} <-
Notebook.fetch_cell_and_section(data.notebook, input_id) do
data
|> with_actions()
|> bind_input(cell, input_cell)
|> wrap_ok()
else
_ -> :error
end
end
def apply_operation(data, {:reflect_evaluation_failure, _client_pid}) do
data
|> with_actions()
@ -421,21 +438,15 @@ defmodule Livebook.Session.Data do
def apply_operation(data, {:set_cell_attributes, _client_pid, cell_id, attrs}) do
with {:ok, cell, _} <- Notebook.fetch_cell_and_section(data.notebook, cell_id),
true <- Enum.all?(attrs, fn {key, _} -> Map.has_key?(cell, key) end) do
invalidates_dependent =
case cell do
%Cell.Input{} -> Map.has_key?(attrs, :value) or Map.has_key?(attrs, :name)
_ -> false
end
data
|> with_actions()
|> set_cell_attributes(cell, attrs)
|> then(fn data_actions ->
if invalidates_dependent do
mark_dependent_cells_as_stale(data_actions, cell)
else
data_actions
end
|> then(fn {data, _} = data_actions ->
{:ok, updated_cell, _} = Notebook.fetch_cell_and_section(data.notebook, cell_id)
data_actions
|> maybe_invalidate_bound_cells(updated_cell, cell)
|> maybe_queue_bound_cells(updated_cell, cell)
end)
|> set_dirty()
|> wrap_ok()
@ -548,11 +559,14 @@ defmodule Livebook.Session.Data do
defp queue_cell_evaluation(data_actions, cell, section) do
data_actions
|> update_section_info!(section.id, fn section ->
%{section | evaluation_queue: section.evaluation_queue ++ [cell.id]}
%{section | evaluation_queue: append_new(section.evaluation_queue, cell.id)}
end)
|> update_cell_info!(cell.id, fn info ->
update_in(info.evaluation_status, fn
:ready -> :queued
other -> other
end)
end)
|> set_cell_info!(cell.id,
evaluation_status: :queued
)
end
defp unqueue_cell_evaluation(data_actions, cell, section) do
@ -605,20 +619,20 @@ defmodule Livebook.Session.Data do
defp finish_cell_evaluation(data_actions, cell, section, metadata) do
data_actions
|> set_cell_info!(cell.id,
evaluation_status: :ready,
evaluation_time_ms: metadata.evaluation_time_ms
)
|> update_cell_info!(cell.id, fn info ->
%{
info
| evaluation_status: :ready,
evaluation_time_ms: metadata.evaluation_time_ms,
number_of_evaluations: info.number_of_evaluations + 1
}
end)
|> set_section_info!(section.id, evaluating_cell_id: nil)
end
defp mark_dependent_cells_as_stale({data, _} = data_actions, cell) do
child_cells =
data.notebook
|> Notebook.child_cells_with_section(cell.id)
|> Enum.filter(fn {cell, _} -> is_struct(cell, Cell.Elixir) end)
mark_cells_as_stale(data_actions, child_cells)
dependent = dependent_cells_with_section(data, cell.id)
mark_cells_as_stale(data_actions, dependent)
end
defp mark_cells_as_stale({data, _} = data_actions, cells_with_section) do
@ -672,7 +686,8 @@ defmodule Livebook.Session.Data do
# During evaluation notebook changes may invalidate the cell,
# so we mark it as up-to-date straight away and possibly mark
# it as stale during evaluation
validity_status: :evaluated
validity_status: :evaluated,
bound_to_input_ids: MapSet.new()
}
end)
|> set_section_info!(section.id, evaluating_cell_id: id, evaluation_queue: ids)
@ -686,6 +701,13 @@ defmodule Livebook.Session.Data do
end
end
defp bind_input(data_actions, cell, input_cell) do
data_actions
|> update_cell_info!(cell.id, fn info ->
%{info | bound_to_input_ids: MapSet.put(info.bound_to_input_ids, input_cell.id)}
end)
end
defp clear_evaluation({data, _} = data_actions) do
data_actions
|> reduce(data.notebook.sections, &clear_section_evaluation/2)
@ -730,8 +752,8 @@ defmodule Livebook.Session.Data do
end
defp unqueue_dependent_cells_evaluation({data, _} = data_actions, cell) do
dependent_cells = Notebook.child_cells_with_section(data.notebook, cell.id)
unqueue_cells_evaluation(data_actions, dependent_cells)
dependent = dependent_cells_with_section(data, cell.id)
unqueue_cells_evaluation(data_actions, dependent)
end
defp unqueue_cells_evaluation({data, _} = data_actions, cells_with_section) do
@ -838,6 +860,40 @@ defmodule Livebook.Session.Data do
|> set!(notebook: Notebook.update_cell(data.notebook, cell.id, &Map.merge(&1, attrs)))
end
defp maybe_invalidate_bound_cells({data, _} = data_actions, %Cell.Input{} = cell, prev_cell) do
if Cell.Input.invalidated?(cell, prev_cell) do
bound_cells = bound_cells_with_section(data, cell.id)
data_actions
|> reduce(bound_cells, fn data_actions, {bound_cell, section} ->
dependent = dependent_cells_with_section(data, bound_cell.id)
mark_cells_as_stale(data_actions, [{bound_cell, section} | dependent])
end)
else
data_actions
end
end
defp maybe_invalidate_bound_cells(data_actions, _cell, _prev_cell), do: data_actions
defp maybe_queue_bound_cells({data, _} = data_actions, %Cell.Input{} = cell, prev_cell) do
if Cell.Input.reactive_update?(cell, prev_cell) do
bound_cells = bound_cells_with_section(data, cell.id)
data_actions
|> reduce(bound_cells, fn data_actions, {bound_cell, section} ->
data_actions
|> queue_prerequisite_cells_evaluation(bound_cell)
|> queue_cell_evaluation(bound_cell, section)
end)
|> maybe_evaluate_queued()
else
data_actions
end
end
defp maybe_queue_bound_cells(data_actions, _cell, _prev_cell), do: data_actions
defp set_runtime(data_actions, prev_data, runtime) do
{data, _} = data_actions = set!(data_actions, runtime: runtime)
@ -872,6 +928,14 @@ defmodule Livebook.Session.Data do
{data, actions ++ [action]}
end
defp append_new(list, item) do
if item in list do
list
else
list ++ [item]
end
end
defp new_section_info() do
%{
evaluating_cell_id: nil,
@ -889,7 +953,9 @@ defmodule Livebook.Session.Data do
validity_status: :fresh,
evaluation_status: :ready,
evaluation_digest: nil,
evaluation_time_ms: nil
evaluation_time_ms: nil,
number_of_evaluations: 0,
bound_to_input_ids: MapSet.new()
}
end
@ -951,4 +1017,23 @@ defmodule Livebook.Session.Data do
info = data.section_infos[section_id]
info && info.evaluating_cell_id
end
@doc """
Find child cells bound to the given input cell.
"""
@spec bound_cells_with_section(t(), Cell.id()) :: list(Cell.t())
def bound_cells_with_section(data, cell_id) do
data
|> dependent_cells_with_section(cell_id)
|> Enum.filter(fn {child_cell, _} ->
info = data.cell_infos[child_cell.id]
MapSet.member?(info.bound_to_input_ids, cell_id)
end)
end
defp dependent_cells_with_section(data, cell_id) do
data.notebook
|> Notebook.child_cells_with_section(cell_id)
|> Enum.filter(fn {cell, _} -> is_struct(cell, Cell.Elixir) end)
end
end

View file

@ -118,7 +118,6 @@ defmodule LivebookWeb.Helpers do
@doc """
Renders a list of select input options with the given one selected.
"""
def render_select(name, options, selected) do
assigns = %{name: name, options: options, selected: selected}
@ -132,4 +131,21 @@ defmodule LivebookWeb.Helpers do
</select>
"""
end
@doc """
Renders a checkbox input styled as a switch.
"""
def render_switch(name, checked, label) do
assigns = %{name: name, checked: checked, label: label}
~L"""
<div class="flex space-x-3 items-center justify-between">
<span class="text-gray-700"><%= @label %></span>
<label class="switch-button">
<%= tag :input, class: "switch-button__checkbox", type: "checkbox", name: @name, checked: @checked %>
<div class="switch-button__bg"></div>
</label>
</div>
"""
end
end

View file

@ -489,6 +489,18 @@ defmodule LivebookWeb.SessionLive do
{:noreply, socket}
end
def handle_event("queue_bound_cells_evaluation", %{"cell_id" => cell_id}, socket) do
data = socket.private.data
with {:ok, cell, _section} <- Notebook.fetch_cell_and_section(data.notebook, cell_id) do
for {bound_cell, _} <- Session.Data.bound_cells_with_section(data, cell.id) do
Session.queue_cell_evaluation(socket.assigns.session_id, bound_cell.id)
end
end
{:noreply, socket}
end
def handle_event("cancel_cell_evaluation", %{"cell_id" => cell_id}, socket) do
Session.cancel_cell_evaluation(socket.assigns.session_id, cell_id)
@ -851,7 +863,8 @@ defmodule LivebookWeb.SessionLive do
outputs: cell.outputs,
validity_status: info.validity_status,
evaluation_status: info.evaluation_status,
evaluation_time_ms: info.evaluation_time_ms
evaluation_time_ms: info.evaluation_time_ms,
number_of_evaluations: info.number_of_evaluations
}
end

View file

@ -186,7 +186,7 @@ defmodule LivebookWeb.SessionLive.CellComponent do
<div class="w-1 rounded-lg relative -left-3" data-element="cell-focus-indicator">
</div>
<div>
<form phx-change="set_cell_value" onsubmit="return false">
<form phx-change="set_cell_value" phx-submit="queue_bound_cells_evaluation">
<input type="hidden" name="cell_id" value="<%= @cell_view.id %>" />
<div class="input-label">
<%= @cell_view.name %>
@ -196,6 +196,7 @@ defmodule LivebookWeb.SessionLive.CellComponent do
class="input <%= if(@cell_view.error, do: "input--error") %>"
name="value"
value="<%= @cell_view.value %>"
phx-debounce="300"
spellcheck="false"
autocomplete="off"
tabindex="-1" />
@ -224,7 +225,12 @@ defmodule LivebookWeb.SessionLive.CellComponent do
<%= if @cell_view.type == :elixir do %>
<div class="absolute bottom-2 right-2">
<%= render_cell_status(@cell_view.validity_status, @cell_view.evaluation_status, @cell_view.evaluation_time_ms) %>
<%= render_cell_status(
@cell_view.validity_status,
@cell_view.evaluation_status,
@cell_view.evaluation_time_ms,
"cell-#{@cell_view.id}-evaluation#{@cell_view.number_of_evaluations}"
) %>
</div>
<% end %>
</div>
@ -359,13 +365,14 @@ defmodule LivebookWeb.SessionLive.CellComponent do
"""
end
defp render_cell_status(cell_view, evaluation_status, evaluation_time_ms)
defp render_cell_status(cell_view, evaluation_status, evaluation_time_ms, evaluation_id)
defp render_cell_status(_, :evaluating, _) do
defp render_cell_status(_, :evaluating, _, evaluation_id) do
timer =
content_tag(:span, nil,
phx_hook: "Timer",
id: "evaluating-cell-timer",
# Make sure each evaluation gets its own timer
id: "#{evaluation_id}-timer",
phx_update: "ignore",
class: "font-mono"
)
@ -376,29 +383,29 @@ defmodule LivebookWeb.SessionLive.CellComponent do
)
end
defp render_cell_status(_, :queued, _) do
defp render_cell_status(_, :queued, _, _) do
render_status_indicator("Queued", "bg-gray-500", animated_circle_class: "bg-gray-400")
end
defp render_cell_status(:evaluated, _, evaluation_time_ms) do
defp render_cell_status(:evaluated, _, evaluation_time_ms, _) do
render_status_indicator("Evaluated", "bg-green-400",
change_indicator: true,
tooltip: evaluated_label(evaluation_time_ms)
)
end
defp render_cell_status(:stale, _, evaluation_time_ms) do
defp render_cell_status(:stale, _, evaluation_time_ms, _) do
render_status_indicator("Stale", "bg-yellow-200",
change_indicator: true,
tooltip: evaluated_label(evaluation_time_ms)
)
end
defp render_cell_status(:aborted, _, _) do
defp render_cell_status(:aborted, _, _, _) do
render_status_indicator("Aborted", "bg-red-400")
end
defp render_cell_status(_, _, _), do: nil
defp render_cell_status(_, _, _, _), do: nil
defp render_status_indicator(element, circle_class, opts \\ []) do
assigns = %{

View file

@ -21,14 +21,8 @@ defmodule LivebookWeb.SessionLive.ElixirCellSettingsComponent do
Cell settings
</h3>
<form phx-submit="save" phx-target="<%= @myself %>">
<div class="w-full flex-col space-y-3">
<div class="flex space-x-3 items-center justify-between">
<span class="text-gray-700">Disable code formatting (when saving to file)</span>
<label class="switch-button">
<%= tag :input, class: "switch-button__checkbox", type: "checkbox", name: "disable_formatting", checked: @disable_formatting %>
<div class="switch-button__bg"></div>
</label>
</div>
<div class="w-full flex-col space-y-6">
<%= render_switch("disable_formatting", @disable_formatting, "Disable code formatting (when saving to file)") %>
</div>
<div class="mt-8 flex justify-end space-x-2">
<%= live_patch "Cancel", to: @return_to, class: "button button-outlined-gray" %>

View file

@ -12,6 +12,7 @@ defmodule LivebookWeb.SessionLive.InputCellSettingsComponent do
|> assign(assigns)
|> assign_new(:name, fn -> cell.name end)
|> assign_new(:type, fn -> cell.type end)
|> assign_new(:reactive, fn -> cell.reactive end)
{:ok, socket}
end
@ -24,12 +25,18 @@ defmodule LivebookWeb.SessionLive.InputCellSettingsComponent do
Cell settings
</h3>
<form phx-submit="save" phx-change="validate" phx-target="<%= @myself %>">
<div class="flex space-x-8 items-center">
<%= render_select("type", [number: "Number", password: "Password", text: "Text", url: "URL"], @type) %>
</div>
<div class="mt-4">
<div class="input-label">Name</div>
<input type="text" class="input" name="name" value="<%= @name %>" spellcheck="false" autocomplete="off" autofocus />
<div class="flex flex-col space-y-6">
<div>
<div class="input-label">Type</div>
<%= render_select("type", [number: "Number", password: "Password", text: "Text", url: "URL"], @type) %>
</div>
<div>
<div class="input-label">Name</div>
<input type="text" class="input" name="name" value="<%= @name %>" spellcheck="false" autocomplete="off" autofocus />
</div>
<div>
<%= render_switch("reactive", @reactive, "Reactive (reevaluates dependent cells on change)") %>
</div>
</div>
<div class="mt-8 flex justify-end space-x-2">
<%= live_patch "Cancel", to: @return_to, class: "button button-outlined-gray" %>
@ -44,15 +51,22 @@ defmodule LivebookWeb.SessionLive.InputCellSettingsComponent do
end
@impl true
def handle_event("validate", %{"name" => name, "type" => type}, socket) do
type = String.to_existing_atom(type)
{:noreply, assign(socket, name: name, type: type)}
def handle_event("validate", params, socket) do
attrs = params_to_attrs(params)
{:noreply, assign(socket, attrs)}
end
def handle_event("save", %{"name" => name, "type" => type}, socket) do
type = String.to_existing_atom(type)
attrs = %{name: name, type: type}
def handle_event("save", params, socket) do
attrs = params_to_attrs(params)
Session.set_cell_attributes(socket.assigns.session_id, socket.assigns.cell.id, attrs)
{:noreply, push_patch(socket, to: socket.assigns.return_to)}
end
defp params_to_attrs(params) do
name = params["name"]
type = params["type"] |> String.to_existing_atom()
reactive = Map.has_key?(params, "reactive")
%{name: name, type: type, reactive: reactive}
end
end

View file

@ -51,7 +51,8 @@ defmodule Livebook.LiveMarkdown.ExportTest do
Notebook.Cell.new(:input)
| type: :text,
name: "length",
value: "100"
value: "100",
reactive: true
},
%{
Notebook.Cell.new(:elixir)
@ -92,7 +93,7 @@ defmodule Livebook.LiveMarkdown.ExportTest do
## Section 2
<!-- livebook:{"livebook_object":"cell_input","name":"length","type":"text","value":"100"} -->
<!-- livebook:{"livebook_object":"cell_input","name":"length","reactive":true,"type":"text","value":"100"} -->
```elixir
IO.gets("length: ")

View file

@ -33,7 +33,7 @@ defmodule Livebook.LiveMarkdown.ImportTest do
## Section 2
<!-- livebook:{"livebook_object":"cell_input","name":"length","type":"text","value":"100"} -->
<!-- livebook:{"livebook_object":"cell_input","name":"length","reactive":true,"type":"text","value":"100"} -->
```elixir
IO.gets("length: ")
@ -84,7 +84,8 @@ defmodule Livebook.LiveMarkdown.ImportTest do
metadata: %{},
type: :text,
name: "length",
value: "100"
value: "100",
reactive: true
},
%Cell.Elixir{
metadata: %{},

View file

@ -46,4 +46,50 @@ defmodule Livebook.Notebook.Cell.InputText do
assert Input.validate(input) == {:error, "not a valid number"}
end
end
describe "invalidated?/2" do
test "returns false if only the type changes" do
input = %{Input.new() | type: :text}
updated_input = %{input | type: :url}
refute Input.invalidated?(updated_input, input)
end
test "returns true if the name changes" do
input = %{Input.new() | name: "Name"}
updated_input = %{input | name: "Full name"}
assert Input.invalidated?(updated_input, input)
end
test "returns true if the value changes" do
input = %{Input.new() | value: "Jake Peralta"}
updated_input = %{input | value: "Amy Santiago"}
assert Input.invalidated?(updated_input, input)
end
end
describe "reactive_change?/2" do
test "returns false if the input is not reactive" do
input = %{Input.new() | reactive: false, value: "Jake Peralta"}
updated_input = %{input | value: "Amy Santiago"}
refute Input.reactive_update?(updated_input, input)
end
test "returns true if the input is reactive and value changes" do
input = %{Input.new() | reactive: true, value: "Jake Peralta"}
updated_input = %{input | value: "Amy Santiago"}
assert Input.reactive_update?(updated_input, input)
end
test "returns false if the new value is invalid" do
input = %{Input.new() | reactive: true, type: :number, value: "10"}
updated_input = %{input | value: "invalid"}
refute Input.reactive_update?(updated_input, input)
end
end
end

View file

@ -8,6 +8,9 @@ defmodule Livebook.Session.DataTest do
alias Livebook.Runtime.NoopRuntime
@eval_resp {:ok, [1, 2, 3]}
@eval_meta %{evaluation_time_ms: 10}
describe "new/1" do
test "called with no arguments defaults to a blank notebook" do
empty_map = %{}
@ -191,11 +194,9 @@ defmodule Livebook.Session.DataTest do
# Evaluate both cells
{:set_runtime, self(), NoopRuntime.new()},
{:queue_cell_evaluation, self(), "c1"},
{:add_cell_evaluation_response, self(), "c1", {:ok, [1, 2, 3]},
%{evaluation_time_ms: 10}},
{:add_cell_evaluation_response, self(), "c1", @eval_resp, @eval_meta},
{:queue_cell_evaluation, self(), "c2"},
{:add_cell_evaluation_response, self(), "c2", {:ok, [1, 2, 3]},
%{evaluation_time_ms: 20}}
{:add_cell_evaluation_response, self(), "c2", @eval_resp, @eval_meta}
])
operation = {:delete_cell, self(), "c1"}
@ -816,15 +817,12 @@ defmodule Livebook.Session.DataTest do
# Evaluate first 2 cells
{:set_runtime, self(), NoopRuntime.new()},
{:queue_cell_evaluation, self(), "c1"},
{:add_cell_evaluation_response, self(), "c1", {:ok, [1, 2, 3]},
%{evaluation_time_ms: 10}},
{:add_cell_evaluation_response, self(), "c1", @eval_resp, @eval_meta},
{:queue_cell_evaluation, self(), "c2"},
{:add_cell_evaluation_response, self(), "c2", {:ok, [1, 2, 3]},
%{evaluation_time_ms: 20}},
{:add_cell_evaluation_response, self(), "c2", @eval_resp, @eval_meta},
# Evaluate the first cell, so the second becomes stale
{:queue_cell_evaluation, self(), "c1"},
{:add_cell_evaluation_response, self(), "c1", {:ok, [1, 2, 3]},
%{evaluation_time_ms: 30}}
{:add_cell_evaluation_response, self(), "c1", @eval_resp, @eval_meta}
])
# The above leads to:
@ -959,8 +957,7 @@ defmodule Livebook.Session.DataTest do
{:insert_cell, self(), "s1", 0, :elixir, "c1"},
{:set_runtime, self(), NoopRuntime.new()},
{:queue_cell_evaluation, self(), "c1"},
{:add_cell_evaluation_response, self(), "c1", {:ok, [1, 2, 3]},
%{evaluation_time_ms: 10}}
{:add_cell_evaluation_response, self(), "c1", @eval_resp, @eval_meta}
])
operation = {:add_cell_evaluation_output, self(), "c1", "Hello!"}
@ -1012,8 +1009,7 @@ defmodule Livebook.Session.DataTest do
{:queue_cell_evaluation, self(), "c1"}
])
operation =
{:add_cell_evaluation_response, self(), "c1", {:ok, [1, 2, 3]}, %{evaluation_time_ms: 10}}
operation = {:add_cell_evaluation_response, self(), "c1", @eval_resp, @eval_meta}
assert {:ok,
%{
@ -1031,16 +1027,14 @@ defmodule Livebook.Session.DataTest do
{:set_runtime, self(), NoopRuntime.new()},
# Evaluate the first cell
{:queue_cell_evaluation, self(), "c1"},
{:add_cell_evaluation_response, self(), "c1", {:ok, [1, 2, 3]},
%{evaluation_time_ms: 10}},
{:add_cell_evaluation_response, self(), "c1", @eval_resp, @eval_meta},
# Start evaluating the second cell
{:queue_cell_evaluation, self(), "c2"},
# Remove the first cell, marking the second as stale
{:delete_cell, self(), "c1"}
])
operation =
{:add_cell_evaluation_response, self(), "c2", {:ok, [1, 2, 3]}, %{evaluation_time_ms: 10}}
operation = {:add_cell_evaluation_response, self(), "c2", @eval_resp, @eval_meta}
assert {:ok,
%{
@ -1059,8 +1053,7 @@ defmodule Livebook.Session.DataTest do
{:queue_cell_evaluation, self(), "c2"}
])
operation =
{:add_cell_evaluation_response, self(), "c1", {:ok, [1, 2, 3]}, %{evaluation_time_ms: 10}}
operation = {:add_cell_evaluation_response, self(), "c1", @eval_resp, @eval_meta}
assert {:ok,
%{
@ -1083,8 +1076,7 @@ defmodule Livebook.Session.DataTest do
{:queue_cell_evaluation, self(), "c2"}
])
operation =
{:add_cell_evaluation_response, self(), "c1", {:ok, [1, 2, 3]}, %{evaluation_time_ms: 10}}
operation = {:add_cell_evaluation_response, self(), "c1", @eval_resp, @eval_meta}
assert {:ok,
%{
@ -1132,20 +1124,16 @@ defmodule Livebook.Session.DataTest do
# Evaluate all cells
{:set_runtime, self(), NoopRuntime.new()},
{:queue_cell_evaluation, self(), "c1"},
{:add_cell_evaluation_response, self(), "c1", {:ok, [1, 2, 3]},
%{evaluation_time_ms: 10}},
{:add_cell_evaluation_response, self(), "c1", @eval_resp, @eval_meta},
{:queue_cell_evaluation, self(), "c2"},
{:add_cell_evaluation_response, self(), "c2", {:ok, [1, 2, 3]},
%{evaluation_time_ms: 20}},
{:add_cell_evaluation_response, self(), "c2", @eval_resp, @eval_meta},
{:queue_cell_evaluation, self(), "c3"},
{:add_cell_evaluation_response, self(), "c3", {:ok, [1, 2, 3]},
%{evaluation_time_ms: 30}},
{:add_cell_evaluation_response, self(), "c3", @eval_resp, @eval_meta},
# Queue the first cell again
{:queue_cell_evaluation, self(), "c1"}
])
operation =
{:add_cell_evaluation_response, self(), "c1", {:ok, [1, 2, 3]}, %{evaluation_time_ms: 10}}
operation = {:add_cell_evaluation_response, self(), "c1", @eval_resp, @eval_meta}
assert {:ok,
%{
@ -1165,8 +1153,7 @@ defmodule Livebook.Session.DataTest do
{:queue_cell_evaluation, self(), "c1"}
])
operation =
{:add_cell_evaluation_response, self(), "c1", {:ok, [1, 2, 3]}, %{evaluation_time_ms: 10}}
operation = {:add_cell_evaluation_response, self(), "c1", @eval_resp, @eval_meta}
Process.sleep(10)
@ -1179,6 +1166,51 @@ defmodule Livebook.Session.DataTest do
end
end
describe "apply_operation/2 given :bind_input" do
test "returns an error given invalid input cell id" do
data =
data_after_operations!([
{:insert_section, self(), 0, "s1"},
{:insert_cell, self(), "s1", 0, :elixir, "c1"}
])
operation = {:bind_input, self(), "c1", "nonexistent"}
assert :error = Data.apply_operation(data, operation)
end
test "returns an error given non-input cell" do
data =
data_after_operations!([
{:insert_section, self(), 0, "s1"},
{:insert_cell, self(), "s1", 0, :elixir, "c1"},
{:insert_cell, self(), "s1", 1, :elixir, "c2"}
])
operation = {:bind_input, self(), "c2", "c1"}
assert :error = Data.apply_operation(data, operation)
end
test "updates elixir cell info with binding to the input cell" do
data =
data_after_operations!([
{:insert_section, self(), 0, "s1"},
{:insert_cell, self(), "s1", 0, :input, "c1"},
{:insert_cell, self(), "s1", 1, :elixir, "c2"}
])
operation = {:bind_input, self(), "c2", "c1"}
bound_to_input_ids = MapSet.new(["c1"])
assert {:ok,
%{
cell_infos: %{
"c2" => %{bound_to_input_ids: ^bound_to_input_ids}
}
}, _actions} = Data.apply_operation(data, operation)
end
end
describe "apply_operation/2 given :reflect_evaluation_failure" do
test "clears evaluation queue and marks evaluated and evaluating cells as aborted" do
data =
@ -1191,8 +1223,7 @@ defmodule Livebook.Session.DataTest do
{:queue_cell_evaluation, self(), "c1"},
{:queue_cell_evaluation, self(), "c2"},
{:queue_cell_evaluation, self(), "c3"},
{:add_cell_evaluation_response, self(), "c1", {:ok, [1, 2, 3]},
%{evaluation_time_ms: 10}}
{:add_cell_evaluation_response, self(), "c1", @eval_resp, @eval_meta}
])
operation = {:reflect_evaluation_failure, self()}
@ -1225,8 +1256,7 @@ defmodule Livebook.Session.DataTest do
{:insert_cell, self(), "s1", 0, :elixir, "c1"},
{:set_runtime, self(), NoopRuntime.new()},
{:queue_cell_evaluation, self(), "c1"},
{:add_cell_evaluation_response, self(), "c1", {:ok, [1, 2, 3]},
%{evaluation_time_ms: 10}}
{:add_cell_evaluation_response, self(), "c1", @eval_resp, @eval_meta}
])
operation = {:cancel_cell_evaluation, self(), "c1"}
@ -1243,8 +1273,7 @@ defmodule Livebook.Session.DataTest do
{:insert_cell, self(), "s2", 0, :elixir, "c3"},
{:set_runtime, self(), NoopRuntime.new()},
{:queue_cell_evaluation, self(), "c1"},
{:add_cell_evaluation_response, self(), "c1", {:ok, [1, 2, 3]},
%{evaluation_time_ms: 10}},
{:add_cell_evaluation_response, self(), "c1", @eval_resp, @eval_meta},
{:queue_cell_evaluation, self(), "c2"},
{:queue_cell_evaluation, self(), "c3"}
])
@ -1764,16 +1793,24 @@ defmodule Livebook.Session.DataTest do
}, _} = Data.apply_operation(data, operation)
end
test "given input value change, marks evaluated child cells as stale" do
test "given input value change, marks evaluated bound cells and their dependants as stale" do
data =
data_after_operations!([
{:insert_section, self(), 0, "s1"},
{:insert_cell, self(), "s1", 0, :input, "c1"},
{:set_cell_attributes, self(), "c1", %{reactive: false}},
# Insert three evaluated cells and bind the second one to the input
{:insert_cell, self(), "s1", 1, :elixir, "c2"},
{:insert_cell, self(), "s1", 2, :elixir, "c3"},
{:insert_cell, self(), "s1", 3, :elixir, "c4"},
{:set_runtime, self(), NoopRuntime.new()},
{:queue_cell_evaluation, self(), "c2"},
{:add_cell_evaluation_response, self(), "c2", {:ok, [1, 2, 3]},
%{evaluation_time_ms: 10}}
{:queue_cell_evaluation, self(), "c3"},
{:queue_cell_evaluation, self(), "c4"},
{:add_cell_evaluation_response, self(), "c2", @eval_resp, @eval_meta},
{:add_cell_evaluation_response, self(), "c3", @eval_resp, @eval_meta},
{:add_cell_evaluation_response, self(), "c4", @eval_resp, @eval_meta},
{:bind_input, self(), "c3", "c1"}
])
attrs = %{value: "stuff"}
@ -1781,7 +1818,74 @@ defmodule Livebook.Session.DataTest do
assert {:ok,
%{
cell_infos: %{"c2" => %{validity_status: :stale}}
cell_infos: %{
"c2" => %{validity_status: :evaluated},
"c3" => %{validity_status: :stale},
"c4" => %{validity_status: :stale}
}
}, _} = Data.apply_operation(data, operation)
end
test "given reactive input value change, triggers bound cells evaluation" do
data =
data_after_operations!([
{:insert_section, self(), 0, "s1"},
{:insert_cell, self(), "s1", 0, :input, "c1"},
{:set_cell_attributes, self(), "c1", %{reactive: true}},
# Insert three evaluated cells and bind the second and third one to the input
{:insert_cell, self(), "s1", 1, :elixir, "c2"},
{:insert_cell, self(), "s1", 2, :elixir, "c3"},
{:insert_cell, self(), "s1", 3, :elixir, "c4"},
{:set_runtime, self(), NoopRuntime.new()},
{:queue_cell_evaluation, self(), "c2"},
{:queue_cell_evaluation, self(), "c3"},
{:queue_cell_evaluation, self(), "c4"},
{:add_cell_evaluation_response, self(), "c2", @eval_resp, @eval_meta},
{:add_cell_evaluation_response, self(), "c3", @eval_resp, @eval_meta},
{:add_cell_evaluation_response, self(), "c4", @eval_resp, @eval_meta},
{:bind_input, self(), "c3", "c1"},
{:bind_input, self(), "c4", "c1"}
])
attrs = %{value: "stuff"}
operation = {:set_cell_attributes, self(), "c1", attrs}
assert {:ok,
%{
cell_infos: %{
"c2" => %{evaluation_status: :ready},
"c3" => %{evaluation_status: :evaluating},
"c4" => %{evaluation_status: :queued}
}
}, _} = Data.apply_operation(data, operation)
end
test "given reactive input value change, queues bound cell evaluation even if evaluating" do
data =
data_after_operations!([
{:insert_section, self(), 0, "s1"},
{:insert_cell, self(), "s1", 0, :input, "c1"},
{:set_cell_attributes, self(), "c1", %{reactive: true}},
{:insert_cell, self(), "s1", 1, :elixir, "c2"},
{:set_runtime, self(), NoopRuntime.new()},
{:queue_cell_evaluation, self(), "c2"},
{:bind_input, self(), "c2", "c1"}
])
attrs = %{value: "stuff"}
operation = {:set_cell_attributes, self(), "c1", attrs}
assert {:ok,
%{
cell_infos: %{
"c2" => %{evaluation_status: :evaluating}
},
section_infos: %{
"s1" => %{
evaluating_cell_id: "c2",
evaluation_queue: ["c2"]
}
}
}, _} = Data.apply_operation(data, operation)
end
end
@ -1890,4 +1994,41 @@ defmodule Livebook.Session.DataTest do
end
end)
end
describe "bound_cells_with_section/2" do
test "returns an empty list when an invalid cell id is given" do
data = Data.new()
assert [] = Data.bound_cells_with_section(data, "nonexistent")
end
test "returns elixir cells bound to the given input cell" do
data =
data_after_operations!([
{:insert_section, self(), 0, "s1"},
{:insert_cell, self(), "s1", 0, :input, "c1"},
{:insert_cell, self(), "s1", 1, :elixir, "c2"},
{:insert_cell, self(), "s1", 2, :elixir, "c3"},
{:insert_cell, self(), "s1", 4, :elixir, "c4"},
{:bind_input, self(), "c2", "c1"},
{:bind_input, self(), "c4", "c1"}
])
assert [{%{id: "c2"}, _}, {%{id: "c4"}, _}] = Data.bound_cells_with_section(data, "c1")
end
test "returns only child cells" do
data =
data_after_operations!([
{:insert_section, self(), 0, "s1"},
{:insert_cell, self(), "s1", 0, :elixir, "c4"},
{:insert_cell, self(), "s1", 1, :input, "c1"},
{:insert_cell, self(), "s1", 2, :elixir, "c2"},
{:insert_cell, self(), "s1", 3, :elixir, "c3"},
{:bind_input, self(), "c2", "c1"},
{:bind_input, self(), "c4", "c1"}
])
assert [{%{id: "c2"}, _}] = Data.bound_cells_with_section(data, "c1")
end
end
end