Migrate inputs to Kino (#714)

* Migrate inputs to Kino

* Update lib/livebook/session/data.ex

Co-authored-by: José Valim <jose.valim@dashbit.co>

* Try parsing numbers as integers

* Garbage collect input values

* Adjust tests

* Remove unused variable

* Fix frame rendering

* Wrap inputs in border depending on its type

* Add textarea

* Reorder

* Update tests

Co-authored-by: José Valim <jose.valim@dashbit.co>
This commit is contained in:
Jonatan Kłosko 2021-11-25 18:43:42 +01:00 committed by GitHub
parent b6ddf1883c
commit c2636b8220
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
34 changed files with 964 additions and 1570 deletions

View file

@ -134,20 +134,6 @@ const Cell = {
});
}
if (this.props.type === "input") {
const input = getInput(this);
input.addEventListener("blur", (event) => {
// Wait for other handlers to complete and if still in insert
// force focus
setTimeout(() => {
if (this.state.isFocused && this.state.insertMode) {
input.focus();
}
}, 0);
});
}
this._unsubscribeFromNavigationEvents = globalPubSub.subscribe(
"navigation",
(event) => {
@ -185,14 +171,6 @@ function getProps(hook) {
};
}
function getInput(hook) {
if (hook.props.type === "input") {
return hook.el.querySelector(`[data-element="input"]`);
} else {
return null;
}
}
/**
* Handles client-side navigation event.
*/
@ -231,8 +209,6 @@ function handleElementFocused(hook, focusableId, scroll) {
}
function handleInsertModeChanged(hook, insertMode) {
const input = getInput(hook);
if (hook.state.isFocused && !hook.state.insertMode && insertMode) {
hook.state.insertMode = insertMode;
@ -255,24 +231,12 @@ function handleInsertModeChanged(hook, insertMode) {
broadcastSelection(hook);
}
if (input) {
input.focus();
// selectionStart is only supported on text based input
if (input.selectionStart !== null) {
input.selectionStart = input.selectionEnd = input.value.length;
}
}
} else if (hook.state.insertMode && !insertMode) {
hook.state.insertMode = insertMode;
if (hook.state.liveEditor) {
hook.state.liveEditor.blur();
}
if (input) {
input.blur();
}
}
}

View file

@ -326,9 +326,15 @@ function handleDocumentKeyDown(hook, event) {
saveNotebook(hook);
}
} else {
// Ignore inputs and notebook/section title fields
// Ignore keystrokes on input fields
if (isEditableElement(event.target)) {
keyBuffer.reset();
// Use Escape for universal blur
if (key === "Escape") {
event.target.blur();
}
return;
}
@ -429,7 +435,7 @@ function handleDocumentMouseDown(hook, event) {
function editableElementClicked(event, element) {
if (element) {
const editableElement = element.querySelector(
`[data-element="editor-container"], [data-element="input"], [data-element="heading"]`
`[data-element="editor-container"], [data-element="heading"]`
);
return editableElement.contains(event.target);
}

View file

@ -243,7 +243,7 @@ defmodule Livebook.Evaluator do
state = put_in(state.contexts[ref], result_context)
Evaluator.IOProxy.flush(state.io_proxy)
Evaluator.IOProxy.clear_input_buffers(state.io_proxy)
Evaluator.IOProxy.clear_input_cache(state.io_proxy)
output = state.formatter.format_response(response)
metadata = %{evaluation_time_ms: evaluation_time_ms}

View file

@ -30,7 +30,8 @@ defmodule Livebook.Evaluator.IOProxy do
end
@doc """
Sets IO proxy destination and the reference to be attached to all messages.
Sets IO proxy destination and the reference to be attached
to all messages.
For all supported requests a message is sent to `target`,
so this device serves as a proxy. The given evaluation
@ -38,8 +39,12 @@ defmodule Livebook.Evaluator.IOProxy do
The possible messages are:
* `{:evaluation_output, ref, string}` - for output requests,
where `ref` is the given evaluation reference and `string` is the output.
* `{:evaluation_output, ref, output}`
* `{:evaluation_input, ref, reply_to, input_id}`
As described by the `Livebook.Runtime` protocol. The `ref`
is always the given evaluation reference.
"""
@spec configure(pid(), pid(), Evaluator.ref()) :: :ok
def configure(pid, target, ref) do
@ -47,7 +52,8 @@ defmodule Livebook.Evaluator.IOProxy do
end
@doc """
Synchronously sends all buffer contents to the configured target process.
Synchronously sends all buffer contents to the configured
target process.
"""
@spec flush(pid()) :: :ok
def flush(pid) do
@ -58,9 +64,9 @@ defmodule Livebook.Evaluator.IOProxy do
Asynchronously clears all buffered inputs, so next time they
are requested again.
"""
@spec clear_input_buffers(pid()) :: :ok
def clear_input_buffers(pid) do
GenServer.cast(pid, :clear_input_buffers)
@spec clear_input_cache(pid()) :: :ok
def clear_input_cache(pid) do
GenServer.cast(pid, :clear_input_cache)
end
@doc """
@ -81,18 +87,19 @@ defmodule Livebook.Evaluator.IOProxy do
target: nil,
ref: nil,
buffer: [],
input_buffers: %{},
widget_pids: MapSet.new()
input_cache: %{},
widget_pids: MapSet.new(),
token_count: 0
}}
end
@impl true
def handle_cast({:configure, target, ref}, state) do
{:noreply, %{state | target: target, ref: ref}}
{:noreply, %{state | target: target, ref: ref, token_count: 0}}
end
def handle_cast(:clear_input_buffers, state) do
{:noreply, %{state | input_buffers: %{}}}
def handle_cast(:clear_input_cache, state) do
{:noreply, %{state | input_cache: %{}}}
end
@impl true
@ -131,28 +138,28 @@ defmodule Livebook.Evaluator.IOProxy do
put_chars(encoding, apply(mod, fun, args), req, state)
end
defp io_request({:get_chars, prompt, count}, state) when count >= 0 do
get_chars(:latin1, prompt, count, state)
defp io_request({:get_chars, _prompt, count}, state) when count >= 0 do
{{:error, :enotsup}, state}
end
defp io_request({:get_chars, encoding, prompt, count}, state) when count >= 0 do
get_chars(encoding, prompt, count, state)
defp io_request({:get_chars, _encoding, _prompt, count}, state) when count >= 0 do
{{:error, :enotsup}, state}
end
defp io_request({:get_line, prompt}, state) do
get_line(:latin1, prompt, state)
defp io_request({:get_line, _prompt}, state) do
{{:error, :enotsup}, state}
end
defp io_request({:get_line, encoding, prompt}, state) do
get_line(encoding, prompt, state)
defp io_request({:get_line, _encoding, _prompt}, state) do
{{:error, :enotsup}, state}
end
defp io_request({:get_until, prompt, mod, fun, args}, state) do
get_until(:latin1, prompt, mod, fun, args, state)
defp io_request({:get_until, _prompt, _mod, _fun, _args}, state) do
{{:error, :enotsup}, state}
end
defp io_request({:get_until, encoding, prompt, mod, fun, args}, state) do
get_until(encoding, prompt, mod, fun, args, state)
defp io_request({:get_until, _encoding, _prompt, _mod, _fun, _args}, state) do
{{:error, :enotsup}, state}
end
defp io_request({:get_password, _encoding}, state) do
@ -183,9 +190,11 @@ defmodule Livebook.Evaluator.IOProxy do
io_requests(reqs, {:ok, state})
end
# Livebook custom request type, handled in a special manner
# Livebook custom request types, handled in a special manner
# by IOProxy and safely failing for any other IO device
# (resulting in the {:error, :request} response).
# Those requests are generally made by Kino
defp io_request({:livebook_put_output, output}, state) do
state = flush_buffer(state)
send(state.target, {:evaluation_output, state.ref, output})
@ -199,6 +208,22 @@ defmodule Livebook.Evaluator.IOProxy do
{:ok, state}
end
defp io_request({:livebook_get_input_value, input_id}, state) do
input_cache =
Map.put_new_lazy(state.input_cache, input_id, fn ->
request_input_value(input_id, state)
end)
{input_cache[input_id], %{state | input_cache: input_cache}}
end
# Token is a unique, reevaluation-safe opaque identifier
defp io_request(:livebook_generate_token, state) do
token = {state.ref, state.token_count}
state = update_in(state.token_count, &(&1 + 1))
{token, state}
end
defp io_request(_, state) do
{{:error, :request}, state}
end
@ -227,148 +252,25 @@ defmodule Livebook.Evaluator.IOProxy do
ArgumentError -> {{:error, req}, state}
end
defp get_line(encoding, prompt, state) do
get_consume(encoding, prompt, state, fn input ->
line_from_input(input)
end)
end
defp get_chars(encoding, prompt, count, state) do
get_consume(encoding, prompt, state, fn input ->
chars_from_input(input, encoding, count)
end)
end
defp get_until(encoding, prompt, mod, fun, args, state) do
get_consume(encoding, prompt, state, fn input ->
get_until_from_input(input, encoding, mod, fun, args)
end)
end
defp get_consume(encoding, prompt, state, consume_fun) do
prompt = :unicode.characters_to_binary(prompt, encoding, state.encoding)
case get_input(prompt, state) do
input when is_binary(input) ->
{chars, rest} = consume_fun.(input)
state = put_in(state.input_buffers[prompt], rest)
{chars, state}
error ->
{error, state}
end
end
defp get_input(prompt, state) do
Map.get_lazy(state.input_buffers, prompt, fn ->
request_input(prompt, state)
end)
end
defp request_input(prompt, state) do
send(state.target, {:evaluation_input, state.ref, self(), prompt})
defp request_input_value(input_id, state) do
send(state.target, {:evaluation_input, state.ref, self(), input_id})
ref = Process.monitor(state.target)
receive do
{:evaluation_input_reply, {:ok, string}} ->
{:evaluation_input_reply, {:ok, value}} ->
Process.demonitor(ref, [:flush])
string
{:ok, value}
{:evaluation_input_reply, :error} ->
Process.demonitor(ref, [:flush])
{:error, "no matching Livebook input found"}
{:error, :not_found}
{:DOWN, ^ref, :process, _object, _reason} ->
{:error, :terminated}
end
end
defp line_from_input(""), do: {:eof, ""}
defp line_from_input(input) do
case :binary.match(input, ["\r\n", "\n"]) do
:nomatch ->
{input, ""}
{pos, len} ->
:erlang.split_binary(input, pos + len)
end
end
defp chars_from_input("", _encoding, _count), do: {:eof, ""}
defp chars_from_input(input, :unicode, count) do
{:ok, count} = utf8_split_at(input, count)
:erlang.split_binary(input, count)
end
defp chars_from_input(input, :latin1, count) do
if byte_size(input) > count do
:erlang.split_binary(input, count)
else
{input, ""}
end
end
defp utf8_split_at(input, count), do: utf8_split_at(input, count, 0)
defp utf8_split_at(_, 0, acc), do: {:ok, acc}
defp utf8_split_at(<<h::utf8, t::binary>>, count, acc),
do: utf8_split_at(t, count - 1, acc + byte_size(<<h::utf8>>))
defp utf8_split_at(<<_, _::binary>>, _count, _acc),
do: {:error, :invalid_unicode}
defp utf8_split_at(<<>>, _count, acc),
do: {:ok, acc}
defp get_until_from_input(input, encoding, mod, fun, args) do
{chars, rest} = get_until_from_input(input, encoding, mod, fun, args, [])
{get_until_result(chars, encoding), rest}
end
defp get_until_from_input("", encoding, mod, fun, args, continuation) do
case apply(mod, fun, [continuation, :eof | args]) do
{:done, result, :eof} ->
{result, ""}
{:done, result, rest} ->
{result, list_to_binary(rest, encoding)}
{:more, next_continuation} ->
get_until_from_input("", encoding, mod, fun, args, next_continuation)
end
end
defp get_until_from_input(input, encoding, mod, fun, args, continuation) do
{line, rest} = line_from_input(input)
case apply(mod, fun, [continuation, binary_to_list(line, encoding) | args]) do
{:done, result, :eof} ->
{result, rest}
{:done, result, extra} ->
{result, list_to_binary(extra, encoding) <> rest}
{:more, next_continuation} ->
get_until_from_input(rest, encoding, mod, fun, args, next_continuation)
end
end
defp binary_to_list(data, :unicode) when is_binary(data), do: String.to_charlist(data)
defp binary_to_list(data, :latin1) when is_binary(data), do: :erlang.binary_to_list(data)
defp list_to_binary(data, _) when is_binary(data), do: data
defp list_to_binary(data, :unicode) when is_list(data), do: List.to_string(data)
defp list_to_binary(data, :latin1) when is_list(data), do: :erlang.list_to_binary(data)
# From https://erlang.org/doc/apps/stdlib/io_protocol.html - result can be any
# Erlang term, but if it is a list(), the I/O server can convert it to a binary().
defp get_until_result(data, encoding) when is_list(data), do: list_to_binary(data, encoding)
defp get_until_result(data, _), do: data
defp io_reply(from, reply_as, reply) do
send(from, {:io_reply, reply_as, reply})
end

View file

@ -109,28 +109,6 @@ defmodule Livebook.LiveMarkdown.Export do
end
end
defp render_cell(%Cell.Input{} = cell, _ctx) do
value = if cell.type == :password, do: "", else: cell.value
json =
%{
livebook_object: :cell_input,
type: Cell.Input.type_to_string(cell.type),
name: cell.name,
value: value
}
|> put_unless_default(
Map.take(cell, [:props]),
Map.take(Cell.Input.new(), [:props])
)
|> Jason.encode!()
metadata = cell_metadata(cell)
"<!-- livebook:#{json} -->"
|> prepend_metadata(metadata)
end
defp cell_metadata(%Cell.Elixir{} = cell) do
put_unless_default(
%{},

View file

@ -221,14 +221,18 @@ defmodule Livebook.LiveMarkdown.Import do
end
defp build_notebook([{:cell, :input, data} | elems], cells, sections, messages) do
case parse_input_attrs(data) do
{:ok, attrs, input_messages} ->
cell = Notebook.Cell.new(:input) |> Map.merge(attrs)
build_notebook(elems, [cell | cells], sections, messages ++ input_messages)
warning =
"found an input cell, but those are no longer supported, please use Kino.Input instead"
{:error, message} ->
build_notebook(elems, cells, sections, [message | messages])
end
warning =
if data["reactive"] == true do
warning <>
". Also, to make the input reactive you can use an automatically reevaluating cell"
else
warning
end
build_notebook(elems, cells, sections, messages ++ [warning])
end
defp build_notebook([{:section_name, content} | elems], cells, sections, messages) do
@ -308,48 +312,6 @@ defmodule Livebook.LiveMarkdown.Import do
defp grab_leading_comments(elems), do: {[], elems}
defp parse_input_attrs(data) do
with {:ok, type} <- parse_input_type(data["type"]) do
warnings =
if data["reactive"] == true do
[
"found a reactive input, but those are no longer supported, you can use automatically reevaluating cell instead"
]
else
[]
end
{:ok,
%{
type: type,
name: data["name"],
value: data["value"],
# Fields with implicit value
props: data |> Map.get("props", %{}) |> parse_input_props(type)
}, warnings}
end
end
defp parse_input_type(string) do
case Notebook.Cell.Input.type_from_string(string) do
{:ok, type} ->
{:ok, type}
:error ->
{:error,
"unrecognised input type #{inspect(string)}, if it's a valid type it means your Livebook version doesn't support it"}
end
end
defp parse_input_props(data, type) do
default_props = Notebook.Cell.Input.default_props(type)
Map.new(default_props, fn {key, default_value} ->
value = Map.get(data, to_string(key), default_value)
{key, value}
end)
end
defp notebook_metadata_to_attrs(metadata) do
Enum.reduce(metadata, %{}, fn
{"persist_outputs", persist_outputs}, attrs ->

View file

@ -485,28 +485,6 @@ defmodule Livebook.Notebook do
Enum.filter(notebook.sections, &(&1.parent_id == section_id))
end
@doc """
Finds an input cell available to the given cell and matching
the given prompt.
"""
@spec input_cell_for_prompt(t(), Cell.id(), String.t()) :: {:ok, Cell.Input.t()} | :error
def input_cell_for_prompt(notebook, cell_id, prompt) do
notebook
|> parent_cells_with_section(cell_id)
|> Enum.map(fn {cell, _} -> cell end)
|> Enum.filter(fn cell ->
is_struct(cell, Cell.Input) and String.starts_with?(prompt, cell.name)
end)
|> case do
[] ->
:error
input_cells ->
cell = Enum.max_by(input_cells, &String.length(&1.name))
{:ok, cell}
end
end
@doc """
Returns a forked version of the given notebook.
"""

View file

@ -12,9 +12,9 @@ defmodule Livebook.Notebook.Cell do
@type id :: Utils.id()
@type t :: Cell.Elixir.t() | Cell.Markdown.t() | Cell.Input.t()
@type t :: Cell.Elixir.t() | Cell.Markdown.t()
@type type :: :markdown | :elixir | :input
@type type :: :markdown | :elixir
@doc """
Returns an empty cell of the given type.
@ -24,7 +24,6 @@ defmodule Livebook.Notebook.Cell do
def new(:markdown), do: Cell.Markdown.new()
def new(:elixir), do: Cell.Elixir.new()
def new(:input), do: Cell.Input.new()
@doc """
Returns an atom representing the type of the given cell.
@ -34,5 +33,4 @@ defmodule Livebook.Notebook.Cell do
def type(%Cell.Elixir{}), do: :elixir
def type(%Cell.Markdown{}), do: :markdown
def type(%Cell.Input{}), do: :input
end

View file

@ -40,6 +40,8 @@ defmodule Livebook.Notebook.Cell.Elixir do
| {:table_dynamic, widget_process :: pid()}
# Dynamic wrapper for static output
| {:frame_dynamic, widget_process :: pid()}
# An input field
| {:input, attrs :: map()}
# Internal output format for errors
| {:error, message :: binary(), type :: :other | :runtime_restart_required}
@ -56,4 +58,13 @@ defmodule Livebook.Notebook.Cell.Elixir do
reevaluate_automatically: false
}
end
@doc """
Extracts all inputs from the given output.
"""
@spec find_inputs_in_output(output()) :: list(input_attrs :: map())
def find_inputs_in_output(output)
def find_inputs_in_output({:input, attrs}), do: [attrs]
def find_inputs_in_output(_output), do: []
end

View file

@ -1,127 +0,0 @@
defmodule Livebook.Notebook.Cell.Input do
@moduledoc false
# A cell with an input field.
#
# It consists of an input that the user may fill
# and then read during code evaluation.
defstruct [:id, :type, :name, :value, :props]
alias Livebook.Utils
alias Livebook.Notebook.Cell
@type t :: %__MODULE__{
id: Cell.id(),
type: type(),
name: String.t(),
value: String.t(),
props: props()
}
# Make sure to keep this in sync with `type_from_string/1`
@type type ::
:text | :url | :number | :password | :textarea | :color | :range | :select | :checkbox
@typedoc """
Additional properties adjusting the given input type.
"""
@type props :: %{atom() => term()}
@doc """
Returns an empty cell.
"""
@spec new() :: t()
def new() do
%__MODULE__{
id: Utils.random_id(),
type: :text,
name: "input",
value: "",
props: %{}
}
end
@doc """
Checks if the input cell contains a valid value
for its type.
"""
@spec validate(t()) :: :ok | {:error, String.t()}
def validate(cell)
def validate(%{value: value, type: :url}) do
if Utils.valid_url?(value) do
:ok
else
{:error, "not a valid URL"}
end
end
def validate(%{value: value, type: :number}) do
case Float.parse(value) do
{_number, ""} -> :ok
_ -> {:error, "not a valid number"}
end
end
def validate(%{value: value, type: :color}) do
if Utils.valid_hex_color?(value) do
:ok
else
{:error, "not a valid hex color"}
end
end
def validate(%{value: value, type: :range, props: props}) do
case Float.parse(value) do
{number, ""} ->
cond do
number < props.min -> {:error, "number too small"}
number > props.max -> {:error, "number too big"}
true -> :ok
end
_ ->
{:error, "not a valid number"}
end
end
def validate(_cell), do: :ok
@doc """
Returns default properties for input of the given type.
"""
@spec default_props(type()) :: props()
def default_props(type)
def default_props(:range), do: %{min: 0, max: 100, step: 1}
def default_props(:select), do: %{options: [""]}
def default_props(_type), do: %{}
@doc """
Parses input type from string.
"""
@spec type_from_string(String.t()) :: {:ok, type()} | :error
def type_from_string(string) do
case string do
"text" -> {:ok, :text}
"url" -> {:ok, :url}
"number" -> {:ok, :number}
"password" -> {:ok, :password}
"textarea" -> {:ok, :textarea}
"color" -> {:ok, :color}
"range" -> {:ok, :range}
"select" -> {:ok, :select}
"checkbox" -> {:ok, :checkbox}
_other -> :error
end
end
@doc """
Converts input type to string.
"""
@spec type_to_string(type()) :: String.t()
def type_to_string(type) do
Atom.to_string(type)
end
end

View file

@ -197,17 +197,23 @@ if the date is valid or not? We can use `case` to pattern match on
the different tuples. This is also a good opportunity to use Livebook's
inputs to pass different values to our code:
<!-- livebook:{"livebook_object":"cell_input","name":"Date","type":"text","value":""} -->
```elixir
# Bring in Livebook inputs
Mix.install([
{:kino, "~> 0.3.1", github: "livebook-dev/kino"}
])
```
```elixir
# Read the date input, which returns something like "2020-02-30\n"
input = IO.gets("Date: ")
date_input = Kino.Input.text("Date")
```
# So we trim the newline from the input value
trimmed = String.trim(input)
```elixir
# Read the date input, which returns something like "2020-02-30"
input = Kino.Input.read(date_input)
# And then match on the return value
case Date.from_iso8601(trimmed) do
case Date.from_iso8601(input) do
{:ok, date} ->
"We got a valid date: #{inspect(date)}"
@ -715,22 +721,26 @@ IO.puts Node.get_cookie()
Now paste the result of the other node name and its cookie in the inputs below:
<!-- livebook:{"livebook_object":"cell_input","name":"Other node","type":"text","value":""} -->
```elixir
node_input = Kino.Input.text("Other node")
```
<!-- livebook:{"livebook_object":"cell_input","name":"Other cookie","type":"text","value":""} -->
```elixir
cookie_input = Kino.Input.text("Other cookie")
```
And now execute the code cell below, which will read the inputs, configure the
cookie, and connect to the other notebook:
```elixir
other_node =
IO.gets("Other node: ")
|> String.trim()
node_input
|> Kino.Input.read()
|> String.to_atom()
other_cookie =
IO.gets("Other cookie: ")
|> String.trim()
cookie_input
|> Kino.Input.read()
|> String.to_atom()
Node.set_cookie(other_node, other_cookie)

View file

@ -73,7 +73,7 @@ instance, otherwise the command below will fail.
```elixir
Mix.install([
{:kino, "~> 0.3.1"}
{:kino, "~> 0.3.1", github: "livebook-dev/kino"}
])
```
@ -169,19 +169,18 @@ from your notebook code, using `IO.gets/1`. Let's see an example
that expects a date in the format `YYYY-MM-DD` and returns if the
data is valid or not:
<!-- livebook:{"livebook_object":"cell_input","name":"Date","type":"text","value":""} -->
```elixir
date_input = Kino.Input.text("Date")
```
<!-- livebook:{"reevaluate_automatically":true} -->
```elixir
# Read the date input, which returns something like "2020-02-30\n"
input = IO.gets("Date: ")
# So we trim the newline from the input value
trimmed = String.trim(input)
# Read the date input, which returns something like "2020-02-30"
input = Kino.Input.read(date_input)
# And then match on the return value
case Date.from_iso8601(trimmed) do
case Date.from_iso8601(input) do
{:ok, date} ->
"We got a valid date: #{inspect(date)}"

View file

@ -14,7 +14,7 @@ so let's add `:vega_lite` and `:kino` for that.
```elixir
Mix.install([
{:vega_lite, "~> 0.1.2"},
{:kino, "~> 0.3.1"}
{:kino, "~> 0.3.1", github: "livebook-dev/kino"}
])
```
@ -50,22 +50,26 @@ IO.puts Node.get_cookie()
Now, paste these in the inputs below:
<!-- livebook:{"livebook_object":"cell_input","name":"Node","type":"text","value":""} -->
```elixir
node_input = Kino.Input.text("Node")
```
<!-- livebook:{"livebook_object":"cell_input","name":"Cookie","type":"text","value":""} -->
```elixir
cookie_input = Kino.Input.text("Cookie")
```
And now execute the code cell below, which will read the inputs,
configure the cookie, and connect to the other notebook:
```elixir
node =
IO.gets("Node: ")
|> String.trim()
node_input
|> Kino.Input.read()
|> String.to_atom()
cookie =
IO.gets("Cookie: ")
|> String.trim()
cookie_input
|> Kino.Input.read()
|> String.to_atom()
Node.set_cookie(node, cookie)

View file

@ -139,10 +139,11 @@ defprotocol Livebook.Runtime do
result of the evaluation. Recognised metadata entries
are: `evaluation_time_ms`
The evaluation may request user input by sending
`{:evaluation_input, ref, reply_to, prompt}` to the runtime owner,
which is supposed to reply with `{:evaluation_input_reply, reply}`
where `reply` is either `{:ok, input}` or `:error` if no matching
The output may include input fields. The evaluation may then
request the current value of a previously rendered input by
sending `{:evaluation_input, ref, reply_to, input_id}` to the
runtime owner, which is supposed to reply with `{:evaluation_input_reply, reply}`
where `reply` is either `{:ok, value}` or `:error` if no matching
input can be found.
In all of the above `ref` is the evaluation reference.

View file

@ -297,6 +297,14 @@ defmodule Livebook.Session do
GenServer.cast(pid, {:set_cell_attributes, self(), cell_id, attrs})
end
@doc """
Asynchronously sends a input value update to the server.
"""
@spec set_input_value(pid(), Session.input_id(), term()) :: :ok
def set_input_value(pid, input_id, value) do
GenServer.cast(pid, {:set_input_value, self(), input_id, value})
end
@doc """
Asynchronously connects to the given runtime.
@ -558,6 +566,11 @@ defmodule Livebook.Session do
{:noreply, handle_operation(state, operation)}
end
def handle_cast({:set_input_value, client_pid, input_id, value}, state) do
operation = {:set_input_value, client_pid, input_id, value}
{:noreply, handle_operation(state, operation)}
end
def handle_cast({:connect_runtime, client_pid, runtime}, state) do
if state.data.runtime do
Runtime.disconnect(state.data.runtime)
@ -639,28 +652,18 @@ defmodule Livebook.Session do
{:noreply, handle_operation(state, operation)}
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} <- input_cell,
:ok <- Cell.Input.validate(cell) do
{:ok, cell.value <> "\n"}
def handle_info({:evaluation_input, cell_id, reply_to, input_id}, state) do
{reply, state} =
with {:ok, cell, _section} <- Notebook.fetch_cell_and_section(state.data.notebook, cell_id),
{:ok, value} <- Map.fetch(state.data.input_values, input_id) do
state = handle_operation(state, {:bind_input, self(), cell.id, input_id})
{{:ok, value}, state}
else
_ -> :error
_ -> {:error, state}
end
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

@ -21,6 +21,7 @@ defmodule Livebook.Session.Data do
:dirty,
:section_infos,
:cell_infos,
:input_values,
:bin_entries,
:runtime,
:clients_map,
@ -39,6 +40,7 @@ defmodule Livebook.Session.Data do
dirty: boolean(),
section_infos: %{Section.id() => section_info()},
cell_infos: %{Cell.id() => cell_info()},
input_values: %{input_id() => term()},
bin_entries: list(cell_bin_entry()),
runtime: Runtime.t() | nil,
clients_map: %{pid() => User.id()},
@ -61,7 +63,7 @@ defmodule Livebook.Session.Data do
evaluation_snapshot: snapshot() | nil,
evaluation_time_ms: integer() | nil,
number_of_evaluations: non_neg_integer(),
bound_to_input_ids: MapSet.t(Cell.id()),
bound_to_input_ids: MapSet.t(input_id()),
bound_input_readings: input_reading()
}
@ -78,6 +80,8 @@ defmodule Livebook.Session.Data do
@type cell_validity_status :: :fresh | :evaluated | :stale | :aborted
@type cell_evaluation_status :: :ready | :queued | :evaluating
@type input_id :: String.t()
@type client :: {User.id(), pid()}
@type index :: non_neg_integer()
@ -99,7 +103,7 @@ defmodule Livebook.Session.Data do
#
@type snapshot :: {deps_snapshot :: term(), bound_inputs_snapshot :: term()}
@type input_reading :: {input_name :: String.t(), input_value :: String.t()}
@type input_reading :: {input_id(), input_value :: term()}
# Note that all operations carry the pid of whatever
# process originated the operation. Some operations
@ -125,7 +129,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(), metadata :: map()}
| {:bind_input, pid(), elixir_cell_id :: Cell.id(), input_cell_id :: Cell.id()}
| {:bind_input, pid(), elixir_cell_id :: Cell.id(), input_id()}
| {:reflect_main_evaluation_failure, pid()}
| {:reflect_evaluation_failure, pid(), Section.id()}
| {:cancel_cell_evaluation, pid(), Cell.id()}
@ -138,6 +142,7 @@ defmodule Livebook.Session.Data do
| {:apply_cell_delta, pid(), Cell.id(), Delta.t(), cell_revision()}
| {:report_cell_revision, pid(), Cell.id(), cell_revision()}
| {:set_cell_attributes, pid(), Cell.id(), map()}
| {:set_input_value, pid(), input_id(), value :: term()}
| {:set_runtime, pid(), Runtime.t() | nil}
| {:set_file, pid(), FileSystem.File.t() | nil}
| {:set_autosave_interval, pid(), non_neg_integer() | nil}
@ -162,6 +167,7 @@ defmodule Livebook.Session.Data do
dirty: false,
section_infos: initial_section_infos(notebook),
cell_infos: initial_cell_infos(notebook),
input_values: initial_input_values(notebook),
bin_entries: [],
runtime: nil,
clients_map: %{},
@ -187,6 +193,15 @@ defmodule Livebook.Session.Data do
do: {cell.id, new_cell_info(%{})}
end
defp initial_input_values(notebook) do
for section <- notebook.sections,
%Cell.Elixir{} = cell <- section.cells,
output <- cell.outputs,
attrs <- Cell.Elixir.find_inputs_in_output(output),
into: %{},
do: {attrs.id, attrs.default}
end
@doc """
Applies the change specified by `operation` to the given session `data`.
@ -412,6 +427,7 @@ defmodule Livebook.Session.Data do
|> with_actions()
|> add_cell_evaluation_response(cell, output)
|> finish_cell_evaluation(cell, section, metadata)
|> garbage_collect_input_values()
|> compute_snapshots_and_validity()
|> maybe_evaluate_queued()
|> compute_snapshots_and_validity()
@ -422,15 +438,14 @@ defmodule Livebook.Session.Data do
end
end
def apply_operation(data, {:bind_input, _client_pid, id, input_id}) do
def apply_operation(data, {:bind_input, _client_pid, cell_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),
false <- MapSet.member?(data.cell_infos[cell.id].bound_to_input_ids, input_cell.id) do
Notebook.fetch_cell_and_section(data.notebook, cell_id),
true <- Map.has_key?(data.input_values, input_id),
false <- MapSet.member?(data.cell_infos[cell.id].bound_to_input_ids, input_id) do
data
|> with_actions()
|> bind_input(cell, input_cell)
|> bind_input(cell, input_id)
|> wrap_ok()
else
_ -> :error
@ -566,6 +581,18 @@ defmodule Livebook.Session.Data do
end
end
def apply_operation(data, {:set_input_value, _client_pid, input_id, value}) do
with true <- Map.has_key?(data.input_values, input_id) do
data
|> with_actions()
|> set_input_value(input_id, value)
|> compute_snapshots_and_validity()
|> wrap_ok()
else
_ -> :error
end
end
def apply_operation(data, {:set_runtime, _client_pid, runtime}) do
data
|> with_actions()
@ -717,7 +744,7 @@ defmodule Livebook.Session.Data do
end
defp unqueue_cells_after_moved({data, _} = data_actions, prev_notebook) do
relevant_cell? = fn cell -> is_struct(cell, Cell.Elixir) or is_struct(cell, Cell.Input) end
relevant_cell? = fn cell -> is_struct(cell, Cell.Elixir) end
graph_before = Notebook.cell_dependency_graph(prev_notebook, cell_filter: relevant_cell?)
graph_after = Notebook.cell_dependency_graph(data.notebook, cell_filter: relevant_cell?)
@ -793,23 +820,28 @@ defmodule Livebook.Session.Data do
|> set_cell_info!(cell.id, evaluation_status: :ready)
end
defp add_cell_evaluation_output({data, _} = data_actions, cell, output) do
defp add_cell_evaluation_output(data_actions, cell, output) do
data_actions
|> set!(
notebook:
Notebook.update_cell(data.notebook, cell.id, fn cell ->
%{cell | outputs: add_output(cell.outputs, output)}
end)
)
|> add_cell_output(cell, output)
end
defp add_cell_evaluation_response({data, _} = data_actions, cell, output) do
defp add_cell_evaluation_response(data_actions, cell, output) do
data_actions
|> add_cell_output(cell, output)
end
defp add_cell_output({data, _} = data_actions, cell, output) do
data_actions
|> set!(
notebook:
Notebook.update_cell(data.notebook, cell.id, fn cell ->
%{cell | outputs: add_output(cell.outputs, output)}
end)
end),
input_values:
output
|> Cell.Elixir.find_inputs_in_output()
|> Map.new(fn attrs -> {attrs.id, attrs.default} end)
|> Map.merge(data.input_values)
)
end
@ -920,6 +952,12 @@ defmodule Livebook.Session.Data do
info.evaluating_cell_id != nil
end
defp any_section_evaluating?(data) do
Enum.any?(data.notebook.sections, fn section ->
section_evaluating?(data, section.id)
end)
end
defp section_awaits_evaluation?(data, section_id) do
info = data.section_infos[section_id]
info.evaluating_cell_id == nil and info.evaluation_queue != []
@ -953,13 +991,15 @@ defmodule Livebook.Session.Data do
end
end
defp bind_input(data_actions, cell, input_cell) do
defp bind_input({data, _} = data_actions, cell, input_id) 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),
bound_input_readings: [{input_cell.name, input_cell.value} | info.bound_input_readings]
| bound_to_input_ids: MapSet.put(info.bound_to_input_ids, input_id),
bound_input_readings: [
{input_id, data.input_values[input_id]} | info.bound_input_readings
]
}
end)
end
@ -1171,6 +1211,11 @@ defmodule Livebook.Session.Data do
|> set!(notebook: Notebook.update_cell(data.notebook, cell.id, &Map.merge(&1, attrs)))
end
defp set_input_value({data, _} = data_actions, input_id, value) do
data_actions
|> set!(input_values: Map.put(data.input_values, input_id, value))
end
defp set_runtime(data_actions, prev_data, runtime) do
{data, _} = data_actions = set!(data_actions, runtime: runtime)
@ -1219,6 +1264,16 @@ defmodule Livebook.Session.Data do
end
end
defp garbage_collect_input_values({data, _} = data_actions) do
if any_section_evaluating?(data) do
# Wait if evaluation is ongoing as it may render inputs
data_actions
else
used_input_ids = data.notebook |> initial_input_values() |> Map.keys()
set!(data_actions, input_values: Map.take(data.input_values, used_input_ids))
end
end
defp new_section_info() do
%{
evaluating_cell_id: nil,
@ -1306,15 +1361,15 @@ defmodule Livebook.Session.Data do
end
@doc """
Find child cells bound to the given input cell.
Find cells bound to the given input.
"""
@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)
@spec bound_cells_with_section(t(), input_id()) :: list({Cell.t(), Section.t()})
def bound_cells_with_section(data, input_id) do
data.notebook
|> Notebook.cells_with_section()
|> Enum.filter(fn {cell, _} ->
info = data.cell_infos[cell.id]
MapSet.member?(info.bound_to_input_ids, input_id)
end)
end
@ -1341,20 +1396,6 @@ defmodule Livebook.Session.Data do
cells_with_section = Notebook.elixir_cells_with_section(data.notebook)
inputs_by_id =
for section <- data.notebook.sections,
cell <- section.cells,
is_struct(cell, Cell.Input),
into: %{},
do: {cell.id, cell}
graph_with_inputs =
Notebook.cell_dependency_graph(data.notebook,
cell_filter: fn cell ->
is_struct(cell, Cell.Elixir) or is_struct(cell, Cell.Input)
end
)
cell_snapshots =
Enum.reduce(cells_with_section, %{}, fn {cell, section}, cell_snapshots ->
info = data.cell_infos[cell.id]
@ -1370,16 +1411,7 @@ defmodule Livebook.Session.Data do
data.cell_infos[prev_cell_id].number_of_evaluations
}
input_deps =
graph_with_inputs
|> Graph.find_path(cell.id, nil)
|> Enum.map(fn cell_id -> cell_id && inputs_by_id[cell_id] end)
|> Enum.reject(&is_nil/1)
|> Enum.map(& &1.name)
|> Enum.sort()
|> Enum.dedup()
deps = {is_branch?, parent_deps, input_deps}
deps = {is_branch?, parent_deps}
deps_snapshot = :erlang.phash2(deps)
inputs_snapshot =
@ -1407,11 +1439,8 @@ defmodule Livebook.Session.Data do
%{bound_to_input_ids: bound_to_input_ids} = data.cell_infos[cell.id]
for(
section <- data.notebook.sections,
cell <- section.cells,
is_struct(cell, Cell.Input),
cell.id in bound_to_input_ids,
do: {cell.name, cell.value}
input_id <- bound_to_input_ids,
do: {input_id, data.input_values[input_id]}
)
|> input_readings_snapshot()
end

View file

@ -153,11 +153,22 @@ defmodule Livebook.Utils do
@doc """
Validates if the given URL is syntactically valid.
## Examples
iex> Livebook.Utils.valid_url?("not_a_url")
false
iex> Livebook.Utils.valid_url?("https://example.com")
true
iex> Livebook.Utils.valid_url?("http://localhost")
true
"""
@spec valid_url?(String.t()) :: boolean()
def valid_url?(url) do
uri = URI.parse(url)
uri.scheme != nil and uri.host != nil and uri.host =~ "."
uri.scheme != nil and uri.host != nil
end
@doc """

View file

@ -2,31 +2,77 @@ defmodule LivebookWeb.Output do
use Phoenix.Component
@doc """
Renders the given cell output.
Renders a list of cell outputs.
"""
@spec render_output(Livebook.Cell.Elixir.output(), %{
id: String.t(),
socket: Phoenix.LiveView.Socket.t(),
runtime: Livebook.Runtime.t()
}) :: Phoenix.LiveView.Rendered.t()
def render_output(output, context)
def outputs(assigns) do
~H"""
<div class="flex flex-col space-y-2">
<%= for {{outputs, standalone?}, group_idx} <- @outputs |> group_outputs() |> Enum.with_index() do %>
<div class={"flex flex-col #{if not standalone?, do: "rounded-lg border border-gray-200 divide-y divide-gray-200"}"}>
<%= for {output, idx} <- Enum.with_index(outputs) do %>
<div class={"max-w-full #{if not standalone?, do: "px-4"} #{if not composite?(output), do: "py-4"}"}>
<%= render_output(output, %{
id: "#{@id}-output#{group_idx}_#{idx}",
socket: @socket,
runtime: @runtime,
input_values: @input_values
}) %>
</div>
<% end %>
</div>
<% end %>
</div>
"""
end
def render_output(text, %{id: id}) when is_binary(text) do
defp group_outputs(outputs) do
outputs = Enum.filter(outputs, &(&1 != :ignored))
group_outputs(outputs, [])
end
defp group_outputs([], groups), do: groups
defp group_outputs([output | outputs], []) do
group_outputs(outputs, [{[output], standalone?(output)}])
end
defp group_outputs([output | outputs], [{group_outputs, group_standalone?} | groups]) do
case standalone?(output) do
^group_standalone? ->
group_outputs(outputs, [{[output | group_outputs], group_standalone?} | groups])
standalone? ->
group_outputs(
outputs,
[{[output], standalone?}, {group_outputs, group_standalone?} | groups]
)
end
end
defp standalone?({:table_dynamic, _}), do: true
defp standalone?({:frame_dynamic, _}), do: true
defp standalone?({:input, _}), do: true
defp standalone?(_output), do: false
defp composite?({:frame_dynamic, _}), do: true
defp composite?(_output), do: false
defp render_output(text, %{id: id}) when is_binary(text) do
# Captured output usually has a trailing newline that we can ignore,
# because each line is itself an HTML block anyway.
text = String.replace_suffix(text, "\n", "")
live_component(LivebookWeb.Output.TextComponent, id: id, content: text, follow: true)
end
def render_output({:text, text}, %{id: id}) do
defp render_output({:text, text}, %{id: id}) do
live_component(LivebookWeb.Output.TextComponent, id: id, content: text, follow: false)
end
def render_output({:markdown, markdown}, %{id: id}) do
defp render_output({:markdown, markdown}, %{id: id}) do
live_component(LivebookWeb.Output.MarkdownComponent, id: id, content: markdown)
end
def render_output({:image, content, mime_type}, %{id: id}) do
defp render_output({:image, content, mime_type}, %{id: id}) do
live_component(LivebookWeb.Output.ImageComponent,
id: id,
content: content,
@ -34,33 +80,41 @@ defmodule LivebookWeb.Output do
)
end
def render_output({:vega_lite_static, spec}, %{id: id}) do
defp render_output({:vega_lite_static, spec}, %{id: id}) do
live_component(LivebookWeb.Output.VegaLiteStaticComponent, id: id, spec: spec)
end
def render_output({:vega_lite_dynamic, pid}, %{id: id, socket: socket}) do
defp render_output({:vega_lite_dynamic, pid}, %{id: id, socket: socket}) do
live_render(socket, LivebookWeb.Output.VegaLiteDynamicLive,
id: id,
session: %{"id" => id, "pid" => pid}
)
end
def render_output({:table_dynamic, pid}, %{id: id, socket: socket}) do
defp render_output({:table_dynamic, pid}, %{id: id, socket: socket}) do
live_render(socket, LivebookWeb.Output.TableDynamicLive,
id: id,
session: %{"id" => id, "pid" => pid}
)
end
def render_output({:frame_dynamic, pid}, %{id: id, socket: socket}) do
defp render_output({:frame_dynamic, pid}, %{id: id, socket: socket, input_values: input_values}) do
live_render(socket, LivebookWeb.Output.FrameDynamicLive,
id: id,
session: %{"id" => id, "pid" => pid}
session: %{"id" => id, "pid" => pid, "input_values" => input_values}
)
end
def render_output({:error, formatted, :runtime_restart_required}, %{runtime: runtime})
when runtime != nil do
defp render_output({:input, attrs}, %{id: id, input_values: input_values}) do
live_component(LivebookWeb.Output.InputComponent,
id: id,
attrs: attrs,
input_values: input_values
)
end
defp render_output({:error, formatted, :runtime_restart_required}, %{runtime: runtime})
when runtime != nil do
assigns = %{formatted: formatted, is_standalone: Livebook.Runtime.standalone?(runtime)}
~H"""
@ -83,11 +137,11 @@ defmodule LivebookWeb.Output do
"""
end
def render_output({:error, formatted, _type}, %{}) do
defp render_output({:error, formatted, _type}, %{}) do
render_error_message_output(formatted)
end
def render_output(output, %{}) do
defp render_output(output, %{}) do
render_error_message_output("""
Unknown output format: #{inspect(output)}. If you're using Kino,
you may want to update Kino and Livebook to the latest version.

View file

@ -2,10 +2,10 @@ defmodule LivebookWeb.Output.FrameDynamicLive do
use LivebookWeb, :live_view
@impl true
def mount(_params, %{"pid" => pid, "id" => id}, socket) do
def mount(_params, %{"pid" => pid, "id" => id, "input_values" => input_values}, socket) do
send(pid, {:connect, self()})
{:ok, assign(socket, id: id, output: nil)}
{:ok, assign(socket, id: id, output: nil, input_values: input_values)}
end
@impl true
@ -13,11 +13,12 @@ defmodule LivebookWeb.Output.FrameDynamicLive do
~H"""
<div>
<%= if @output do %>
<%= LivebookWeb.Output.render_output(@output, %{
id: "#{@id}-frame",
socket: @socket,
runtime: nil
}) %>
<LivebookWeb.Output.outputs
outputs={[@output]}
id={"#{@id}-frame"}
socket={@socket}
runtime={nil}
input_values={@input_values} />
<% else %>
<div class="text-gray-300">
Empty output frame

View file

@ -0,0 +1,226 @@
defmodule LivebookWeb.Output.InputComponent do
use LivebookWeb, :live_component
@impl true
def mount(socket) do
{:ok, assign(socket, error: nil)}
end
@impl true
def update(assigns, socket) do
value = assigns.input_values[assigns.attrs.id]
socket =
socket
|> assign(assigns)
|> assign(value: value, initial_value: value)
{:ok, socket}
end
@impl true
def render(assigns) do
~H"""
<form phx-change="change" phx-submit="submit" phx-target={@myself}>
<div class="input-label">
<%= @attrs.label %>
</div>
<.input
id={"#{@id}-input"}
attrs={@attrs}
value={@value}
error={@error}
myself={@myself} />
<%= if @error do %>
<div class="input-error">
<%= @error %>
</div>
<% end %>
</form>
"""
end
defp input(%{attrs: %{type: :select}} = assigns) do
~H"""
<select
data-element="input"
class="input input-select"
name="value">
<%= for {{key, label}, idx} <- Enum.with_index(@attrs.options) do %>
<option value={idx} selected={@value == key}>
<%= label %>
</option>
<% end %>
</select>
"""
end
defp input(%{attrs: %{type: :checkbox}} = assigns) do
~H"""
<div class="mt-1">
<.switch_checkbox
data-element="input"
name="value"
checked={@value} />
</div>
"""
end
defp input(%{attrs: %{type: :range}} = assigns) do
~H"""
<div class="flex items-center space-x-2">
<div><%= @attrs.min %></div>
<input type="range"
data-element="input"
class="input-range"
name="value"
value={@value}
phx-debounce="300"
spellcheck="false"
autocomplete="off"
min={@attrs.min}
max={@attrs.max}
step={@attrs.step} />
<div><%= @attrs.max %></div>
</div>
"""
end
defp input(%{attrs: %{type: :textarea}} = assigns) do
~H"""
<textarea
data-element="input"
class="input h-[200px] resize-none tiny-scrollbar"
name="value"
phx-debounce="300"
spellcheck="false"><%= [?\n, @value] %></textarea>
"""
end
defp input(%{attrs: %{type: :password}} = assigns) do
~H"""
<.with_password_toggle id={"#{@id}-password-toggle"}>
<input type="password"
data-element="input"
class="input w-auto bg-gray-50"
name="value"
value={@value}
phx-debounce="300"
spellcheck="false"
autocomplete="off" />
</.with_password_toggle>
"""
end
defp input(assigns) do
~H"""
<input type={html_input_type(@attrs.type)}
data-element="input"
class={"input w-auto #{if(@error, do: "input--error")}"}
name="value"
value={to_string(@value)}
phx-debounce="300"
spellcheck="false"
autocomplete="off"
phx-blur="blur"
phx-target={@myself} />
"""
end
defp html_input_type(:number), do: "number"
defp html_input_type(:color), do: "color"
defp html_input_type(:url), do: "text"
defp html_input_type(:text), do: "text"
@impl true
def handle_event("change", %{"value" => html_value}, socket) do
{:noreply, handle_html_value(socket, html_value)}
end
def handle_event("blur", %{}, socket) do
if socket.assigns.error do
{:noreply, assign(socket, value: socket.assigns.initial_value, error: nil)}
else
{:noreply, socket}
end
end
def handle_event("submit", %{"value" => html_value}, socket) do
socket = handle_html_value(socket, html_value)
send(self(), {:queue_bound_cells_evaluation, socket.assigns.attrs.id})
{:noreply, socket}
end
defp handle_html_value(socket, html_value) do
case parse(html_value, socket.assigns.attrs) do
{:ok, value} ->
send(self(), {:set_input_value, socket.assigns.attrs.id, value})
assign(socket, value: value, error: nil)
{:error, error, value} ->
assign(socket, value: value, error: error)
end
end
defp parse(html_value, %{type: :text}) do
{:ok, html_value}
end
defp parse(html_value, %{type: :textarea}) do
# The browser may normalize newlines to \r\n, but we prefer just \n
value = String.replace(html_value, "\r\n", "\n")
{:ok, value}
end
defp parse(html_value, %{type: :password}) do
{:ok, html_value}
end
defp parse(html_value, %{type: :number}) do
if html_value == "" do
{:ok, nil}
else
case Integer.parse(html_value) do
{number, ""} ->
{:ok, number}
_ ->
{number, ""} = Float.parse(html_value)
{:ok, number}
end
end
end
defp parse(html_value, %{type: :url}) do
cond do
html_value == "" -> {:ok, nil}
Livebook.Utils.valid_url?(html_value) -> {:ok, html_value}
true -> {:error, "not a valid URL", html_value}
end
end
defp parse(html_value, %{type: :select, options: options}) do
selected_idx = String.to_integer(html_value)
options
|> Enum.with_index()
|> Enum.find_value(fn {{key, _label}, idx} ->
idx == selected_idx && {:ok, key}
end)
end
defp parse(html_value, %{type: :checkbox}) do
{:ok, html_value == "true"}
end
defp parse(html_value, %{type: :range}) do
{number, ""} = Float.parse(html_value)
{:ok, number}
end
defp parse(html_value, %{type: :color}) do
{:ok, html_value}
end
end

View file

@ -475,9 +475,6 @@ defmodule LivebookWeb.SessionLive do
defp settings_component_for(%Cell.Elixir{}),
do: LivebookWeb.SessionLive.ElixirCellSettingsComponent
defp settings_component_for(%Cell.Input{}),
do: LivebookWeb.SessionLive.InputCellSettingsComponent
defp branching_tooltip_attrs(name, parent_name) do
direction = if String.length(name) >= 16, do: "left", else: "right"
@ -652,16 +649,6 @@ defmodule LivebookWeb.SessionLive do
{:noreply, socket}
end
def handle_event("set_cell_value", %{"cell_id" => cell_id, "value" => value}, socket) do
# The browser may normalize newlines to \r\n, but we want \n
# to more closely imitate an actual shell
value = String.replace(value, "\r\n", "\n")
Session.set_cell_attributes(socket.assigns.session.pid, cell_id, %{value: value})
{:noreply, socket}
end
def handle_event("move_cell", %{"cell_id" => cell_id, "offset" => offset}, socket) do
offset = ensure_integer(offset)
Session.move_cell(socket.assigns.session.pid, cell_id, offset)
@ -702,18 +689,6 @@ 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.pid, 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.pid, cell_id)
@ -933,6 +908,19 @@ defmodule LivebookWeb.SessionLive do
{:noreply, push_event(socket, "location_report", report)}
end
def handle_info({:set_input_value, input_id, value}, socket) do
Session.set_input_value(socket.assigns.session.pid, input_id, value)
{:noreply, socket}
end
def handle_info({:queue_bound_cells_evaluation, input_id}, socket) do
for {bound_cell, _} <- Session.Data.bound_cells_with_section(socket.private.data, input_id) do
Session.queue_cell_evaluation(socket.assigns.session.pid, bound_cell.id)
end
{:noreply, socket}
end
def handle_info(_message, socket), do: {:noreply, socket}
defp handle_relative_path(socket, path) do
@ -1090,18 +1078,9 @@ defmodule LivebookWeb.SessionLive do
push_event(socket, "section_deleted", %{section_id: section_id})
end
defp after_operation(socket, _prev_socket, {:insert_cell, client_pid, _, _, type, cell_id}) do
defp after_operation(socket, _prev_socket, {:insert_cell, client_pid, _, _, _, cell_id}) do
if client_pid == self() do
case type do
:input ->
push_patch(socket,
to: Routes.session_path(socket, :cell_settings, socket.assigns.session.id, cell_id)
)
_ ->
socket
end
|> push_event("cell_inserted", %{cell_id: cell_id})
push_event(socket, "cell_inserted", %{cell_id: cell_id})
else
socket
end
@ -1344,7 +1323,9 @@ defmodule LivebookWeb.SessionLive do
evaluation_status: info.evaluation_status,
evaluation_time_ms: info.evaluation_time_ms,
number_of_evaluations: info.number_of_evaluations,
reevaluate_automatically: cell.reevaluate_automatically
reevaluate_automatically: cell.reevaluate_automatically,
# Pass input values relevant to the given cell
input_values: input_values_for_cell(cell, data)
}
end
@ -1358,20 +1339,13 @@ defmodule LivebookWeb.SessionLive do
}
end
defp cell_to_view(%Cell.Input{} = cell, _data) do
%{
id: cell.id,
type: :input,
input_type: cell.type,
name: cell.name,
value: cell.value,
error:
case Cell.Input.validate(cell) do
:ok -> nil
{:error, error} -> error
end,
props: cell.props
}
defp input_values_for_cell(cell, data) do
input_ids =
for output <- cell.outputs,
attrs <- Cell.Elixir.find_inputs_in_output(output),
do: attrs.id
Map.take(data.input_values, input_ids)
end
# Updates current data_view in response to an operation.

View file

@ -115,147 +115,18 @@ defmodule LivebookWeb.SessionLive.CellComponent do
<%= if @cell_view.outputs != [] do %>
<div class="mt-2" data-element="outputs-container">
<.outputs cell_view={@cell_view} runtime={@runtime} socket={@socket} />
<LivebookWeb.Output.outputs
outputs={@cell_view.outputs}
id={"cell-#{@cell_view.id}-evaluation#{evaluation_number(@cell_view.evaluation_status, @cell_view.number_of_evaluations)}-outputs"}
socket={@socket}
runtime={@runtime}
input_values={@cell_view.input_values} />
</div>
<% end %>
</.cell_body>
"""
end
defp render_cell(%{cell_view: %{type: :input}} = assigns) do
~H"""
<div class="mb-1 flex items-center justify-end">
<div class="relative z-20 flex items-center justify-end space-x-2"
role="toolbar"
aria-label="cell actions"
data-element="actions">
<.cell_settings_button cell_id={@cell_view.id} socket={@socket} session_id={@session_id} />
<.cell_link_button cell_id={@cell_view.id} />
<.move_cell_up_button cell_id={@cell_view.id} />
<.move_cell_down_button cell_id={@cell_view.id} />
<.delete_cell_button cell_id={@cell_view.id} />
</div>
</div>
<.cell_body>
<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 %>
</div>
<.cell_input cell_view={@cell_view} />
<%= if @cell_view.error do %>
<div class="input-error">
<%= String.capitalize(@cell_view.error) %>
</div>
<% end %>
</form>
</.cell_body>
"""
end
defp cell_input(%{cell_view: %{input_type: :textarea}} = assigns) do
~H"""
<textarea
data-element="input"
class={"input w-auto #{if(@cell_view.error, do: "input--error")}"}
name="value"
spellcheck="false"
tabindex="-1"><%= [?\n, @cell_view.value] %></textarea>
"""
end
defp cell_input(%{cell_view: %{input_type: :range}} = assigns) do
~H"""
<div class="flex items-center space-x-2">
<div><%= @cell_view.props.min %></div>
<input type="range"
data-element="input"
class="input-range"
name="value"
value={@cell_view.value}
phx-debounce="300"
spellcheck="false"
autocomplete="off"
tabindex="-1"
min={@cell_view.props.min}
max={@cell_view.props.max}
step={@cell_view.props.step} />
<div><%= @cell_view.props.max %></div>
</div>
"""
end
defp cell_input(%{cell_view: %{input_type: :select}} = assigns) do
~H"""
<div class="flex items-center space-x-2">
<select
data-element="input"
spellcheck="false"
phx-debounce="300"
class="input input-select"
tabindex="-1"
name="value">
<%= for option <- @cell_view.props.options do %>
<option value={option} selected={option == @cell_view.value}>
<%= option %>
</option>
<% end %>
</select>
</div>
"""
end
defp cell_input(%{cell_view: %{input_type: :checkbox}} = assigns) do
~H"""
<div class="mt-1">
<.switch_checkbox
data-element="input"
name="value"
checked={@cell_view.value == "true"} />
</div>
"""
end
defp cell_input(%{cell_view: %{input_type: :password}} = assigns) do
~H"""
<.with_password_toggle id={@cell_view.id}>
<input type="password"
data-element="input"
class={"input w-auto bg-gray-50 #{if(@cell_view.error, do: "input--error")}"}
name="value"
value={@cell_view.value}
phx-debounce="300"
spellcheck="false"
autocomplete="off"
tabindex="-1" />
</.with_password_toggle>
"""
end
defp cell_input(assigns) do
~H"""
<input type={html_input_type(@cell_view.input_type)}
data-element="input"
class={"input w-auto #{if(@cell_view.error, do: "input--error")}"}
name="value"
value={@cell_view.value}
phx-debounce="300"
spellcheck="false"
autocomplete="off"
tabindex="-1" />
"""
end
defp html_input_type(:password), do: "password"
defp html_input_type(:number), do: "number"
defp html_input_type(:color), do: "color"
defp html_input_type(:range), do: "range"
defp html_input_type(:select), do: "select"
defp html_input_type(_), do: "text"
defp cell_body(assigns) do
~H"""
<!-- By setting tabindex we can programmatically focus this element,
@ -466,22 +337,6 @@ defmodule LivebookWeb.SessionLive.CellComponent do
defp evaluated_label(_time_ms), do: nil
defp outputs(assigns) do
~H"""
<div class="flex flex-col rounded-lg border border-gray-200 divide-y divide-gray-200">
<%= for {output, index} <- @cell_view.outputs |> Enum.reverse() |> Enum.with_index(), output != :ignored do %>
<div class="p-4 max-w-full overflow-y-auto tiny-scrollbar">
<%= LivebookWeb.Output.render_output(output, %{
id: "cell-#{@cell_view.id}-evaluation#{evaluation_number(@cell_view.evaluation_status, @cell_view.number_of_evaluations)}-output#{index}",
socket: @socket,
runtime: @runtime
}) %>
</div>
<% end %>
</div>
"""
end
defp evaluation_number(:evaluating, number_of_evaluations), do: number_of_evaluations + 1
defp evaluation_number(_evaluation_status, number_of_evaluations), do: number_of_evaluations
end

View file

@ -1,226 +0,0 @@
defmodule LivebookWeb.SessionLive.InputCellSettingsComponent do
use LivebookWeb, :live_component
alias Livebook.Session
alias Livebook.Notebook.Cell
@impl true
def update(assigns, socket) do
cell = assigns.cell
socket =
socket
|> assign(assigns)
|> assign(:current_type, cell.type)
|> assign_new(:attrs, fn ->
Map.take(cell, [:name, :type, :props])
end)
|> assign_new(:valid, fn -> true end)
{:ok, socket}
end
@impl true
def render(assigns) do
~H"""
<div class="p-6 pb-4 flex flex-col space-y-8">
<h3 class="text-2xl font-semibold text-gray-800">
Cell settings
</h3>
<form
phx-submit="save"
phx-change="validate"
phx-target={@myself}
spellcheck="false"
autocomplete="off">
<div class="flex flex-col space-y-6">
<div>
<div class="input-label">Type</div>
<.select name="attrs[type]" selected={@attrs.type} options={input_types()} />
</div>
<div>
<div class="input-label">Name</div>
<input type="text" class="input" name="attrs[name]" value={@attrs.name} autofocus />
</div>
<.extra_fields type={@attrs.type} props={@attrs.props} myself={@myself} />
</div>
<div class="mt-8 flex justify-end space-x-2">
<%= live_patch "Cancel", to: @return_to, class: "button button-outlined-gray" %>
<button class="button button-blue" type="submit" disabled={not @valid}>
Save
</button>
</div>
</form>
</div>
"""
end
defp extra_fields(%{type: :range} = assigns) do
~H"""
<div class="flex space-x-4">
<div class="flex-grow">
<div class="input-label">Min</div>
<input type="number" class="input" name="attrs[props][min]" value={@props.min} />
</div>
<div class="flex-grow">
<div class="input-label">Max</div>
<input type="number" class="input" name="attrs[props][max]" value={@props.max} />
</div>
<div class="flex-grow">
<div class="input-label">Step</div>
<input type="number" class="input" name="attrs[props][step]" value={@props.step} />
</div>
</div>
"""
end
defp extra_fields(%{type: :select} = assigns) do
~H"""
<div class="flex flex-col">
<div class="input-label mb-0">Options</div>
<div class="my-2 flex flex-col space-y-2">
<%= for {option, idx} <- Enum.with_index(@props.options) do %>
<div class="flex items-center space-x-2">
<input
type="text"
class="input"
name="attrs[props][options][]"
value={option} />
<button
class="button button-gray button-square-icon"
type="button"
tabindex="-1"
phx-target={@myself}
phx-click="select_options_action"
phx-value-action="delete"
phx-value-index={idx}
disabled={length(@props.options) == 1}>
<.remix_icon icon="delete-bin-6-line" />
</button>
</div>
<% end %>
</div>
<div>
<button
class="button button-outlined-gray"
type="button"
phx-target={@myself}
phx-click="select_options_action"
phx-value-action="add">
Add
</button>
</div>
</div>
"""
end
defp extra_fields(assigns), do: ~H""
@impl true
def handle_event("validate", params, socket) do
{valid?, attrs} = validate_attrs(params["attrs"], socket.assigns.attrs)
{:noreply, socket |> assign(attrs: attrs) |> assign(:valid, valid?)}
end
def handle_event("save", params, socket) do
{true, attrs} = validate_attrs(params["attrs"], socket.assigns.attrs)
attrs =
if attrs.type != socket.assigns.current_type do
Map.put(attrs, :value, default_value(attrs.type, attrs.props))
else
attrs
end
Session.set_cell_attributes(socket.assigns.session.pid, socket.assigns.cell.id, attrs)
{:noreply, push_patch(socket, to: socket.assigns.return_to)}
end
def handle_event("select_options_action", params, socket) do
{action, params} = Map.pop!(params, "action")
attrs = socket.assigns.attrs
options = select_options_action(action, params, attrs.props.options)
attrs = put_in(attrs.props.options, options)
valid? = valid_options?(options)
{:noreply, socket |> assign(attrs: attrs) |> assign(valid: valid?)}
end
defp select_options_action("add", _params, options) do
options ++ [""]
end
defp select_options_action("delete", %{"index" => index}, options) do
index = String.to_integer(index)
List.delete_at(options, index)
end
defp validate_attrs(data, prev_attrs) do
name = data["name"]
type = data["type"] |> String.to_existing_atom()
{props_valid?, props} =
if type == prev_attrs.type do
data |> Map.get("props", %{}) |> validate_props(type)
else
{true, Cell.Input.default_props(type)}
end
valid? = name != "" and props_valid?
{valid?, %{name: name, type: type, props: props}}
end
defp validate_props(data, :range) do
min = parse_number(data["min"])
max = parse_number(data["max"])
step = parse_number(data["step"])
valid? = min != nil and max != nil and step != nil and min < max and step > 0
data = %{min: min, max: max, step: step}
{valid?, data}
end
defp validate_props(data, :select) do
options = data["options"] || []
valid? = valid_options?(options)
{valid?, %{options: options}}
end
defp validate_props(_data, _type) do
{true, %{}}
end
defp valid_options?(options) do
options != [] and options == Enum.uniq(options)
end
defp parse_number(string) do
case Float.parse(string) do
{number, _} ->
integer = round(number)
if integer == number, do: integer, else: number
:error ->
nil
end
end
defp default_value(:checkbox, _props), do: "false"
defp default_value(:color, _props), do: "#3E64FF"
defp default_value(:range, %{min: min}), do: to_string(min)
defp default_value(:select, %{options: [option | _]}), do: option
defp default_value(_type, _props), do: ""
defp input_types do
[
checkbox: "Checkbox",
color: "Color",
number: "Number",
password: "Password",
text: "Text",
textarea: "Textarea",
url: "URL",
range: "Range",
select: "Select"
]
end
end

View file

@ -20,12 +20,6 @@ defmodule LivebookWeb.SessionLive.InsertButtonsComponent do
phx-value-section_id={@section_id}
phx-value-cell_id={@cell_id}
>+ Elixir</button>
<button class="button button-small"
phx-click="insert_cell_below"
phx-value-type="input"
phx-value-section_id={@section_id}
phx-value-cell_id={@cell_id}
>+ Input</button>
<button class="button button-small"
phx-click="insert_section_below"
phx-value-section_id={@section_id}

View file

@ -26,164 +26,41 @@ defmodule Livebook.Evaluator.IOProxyTest do
end
test "IO.read", %{io: io} do
pid =
spawn_link(fn ->
reply_to_input_request(:ref, "", :error, 1)
end)
IOProxy.configure(io, pid, :ref)
assert IO.read(io, :all) == {:error, "no matching Livebook input found"}
assert IO.read(io, :all) == {:error, :enotsup}
end
test "IO.gets", %{io: io} do
configure_owner_with_input(io, "name: ", "Jake Peralta")
assert IO.gets(io, "name: ") == {:error, :enotsup}
end
end
assert IO.gets(io, "name: ") == "Jake Peralta"
describe "input" do
test "responds to Livebook input request", %{io: io} do
configure_owner_with_input(io, "input1", :value)
assert livebook_get_input_value(io, "input1") == {:ok, :value}
end
test "IO.gets with no matching input", %{io: io} do
test "responds to subsequent requests with the same value", %{io: io} do
configure_owner_with_input(io, "input1", :value)
assert livebook_get_input_value(io, "input1") == {:ok, :value}
assert livebook_get_input_value(io, "input1") == {:ok, :value}
end
test "clear_input_cache/1 clears all cached input information", %{io: io} do
pid =
spawn_link(fn ->
reply_to_input_request(:ref, "name: ", :error, 1)
reply_to_input_request(:ref, "input1", {:ok, :value1}, 1)
reply_to_input_request(:ref, "input1", {:ok, :value2}, 1)
end)
IOProxy.configure(io, pid, :ref)
assert IO.gets(io, "name: ") == {:error, "no matching Livebook input found"}
assert livebook_get_input_value(io, "input1") == {:ok, :value1}
IOProxy.clear_input_cache(io)
assert livebook_get_input_value(io, "input1") == {:ok, :value2}
end
test "IO.getn with unicode input", %{io: io} do
configure_owner_with_input(io, "name: ", "🐈 test\n")
assert IO.getn(io, "name: ", 3) == "🐈 t"
end
test "IO.getn returns the given number of characters", %{io: io} do
configure_owner_with_input(io, "name: ", "Jake Peralta\nAmy Santiago\n")
assert IO.getn(io, "name: ", 13) == "Jake Peralta\n"
assert IO.getn(io, "name: ", 13) == "Amy Santiago\n"
assert IO.getn(io, "name: ", 13) == :eof
end
test "IO.getn returns all characters if requested more than available", %{io: io} do
configure_owner_with_input(io, "name: ", "Jake Peralta\nAmy Santiago\n")
assert IO.getn(io, "name: ", 10_000) == "Jake Peralta\nAmy Santiago\n"
end
end
# See https://github.com/elixir-lang/elixir/blob/v1.12.1/lib/elixir/test/elixir/string_io_test.exs
defmodule GetUntilCallbacks do
def until_eof(continuation, :eof) do
{:done, continuation, :eof}
end
def until_eof(continuation, content) do
{:more, continuation ++ content}
end
def until_eof_then_try_more('magic-stop-prefix' ++ continuation, :eof) do
{:done, continuation, :eof}
end
def until_eof_then_try_more(continuation, :eof) do
{:more, 'magic-stop-prefix' ++ continuation}
end
def until_eof_then_try_more(continuation, content) do
{:more, continuation ++ content}
end
def up_to_3_bytes(continuation, :eof) do
{:done, continuation, :eof}
end
def up_to_3_bytes(continuation, content) do
case continuation ++ content do
[a, b, c | tail] -> {:done, [a, b, c], tail}
str -> {:more, str}
end
end
def up_to_3_bytes_discard_rest(continuation, :eof) do
{:done, continuation, :eof}
end
def up_to_3_bytes_discard_rest(continuation, content) do
case continuation ++ content do
[a, b, c | _tail] -> {:done, [a, b, c], :eof}
str -> {:more, str}
end
end
end
describe ":get_until" do
test "with up_to_3_bytes", %{io: io} do
configure_owner_with_input(io, "name: ", "abcdefg")
result = get_until(io, :unicode, "name: ", GetUntilCallbacks, :up_to_3_bytes)
assert result == "abc"
assert IO.gets(io, "name: ") == "defg"
end
test "with up_to_3_bytes_discard_rest", %{io: io} do
configure_owner_with_input(io, "name: ", "abcdefg")
result = get_until(io, :unicode, "name: ", GetUntilCallbacks, :up_to_3_bytes_discard_rest)
assert result == "abc"
assert IO.gets(io, "name: ") == :eof
end
test "with until_eof", %{io: io} do
configure_owner_with_input(io, "name: ", "abc\nd")
result = get_until(io, :unicode, "name: ", GetUntilCallbacks, :until_eof)
assert result == "abc\nd"
end
test "with until_eof and \\r\\n", %{io: io} do
configure_owner_with_input(io, "name: ", "abc\r\nd")
result = get_until(io, :unicode, "name: ", GetUntilCallbacks, :until_eof)
assert result == "abc\r\nd"
end
test "with until_eof_then_try_more", %{io: io} do
configure_owner_with_input(io, "name: ", "abc\nd")
result = get_until(io, :unicode, "name: ", GetUntilCallbacks, :until_eof_then_try_more)
assert result == "abc\nd"
end
test "with raw bytes (latin1)", %{io: io} do
configure_owner_with_input(io, "name: ", <<181, 255, 194, ?\n>>)
result = get_until(io, :latin1, "name: ", GetUntilCallbacks, :until_eof)
assert result == <<181, 255, 194, ?\n>>
end
end
test "consumes the given input only once", %{io: io} do
configure_owner_with_input(io, "name: ", "Jake Peralta\nAmy Santiago\n")
assert IO.gets(io, "name: ") == "Jake Peralta\n"
assert IO.gets(io, "name: ") == "Amy Santiago\n"
assert IO.gets(io, "name: ") == :eof
end
test "clear_input_buffers/1 clears all buffered input information", %{io: io} do
pid =
spawn_link(fn ->
reply_to_input_request(:ref, "name: ", {:ok, "Jake Peralta"}, 2)
end)
IOProxy.configure(io, pid, :ref)
assert IO.gets(io, "name: ") == "Jake Peralta"
IOProxy.clear_input_buffers(io)
assert IO.gets(io, "name: ") == "Jake Peralta"
end
test "buffers rapid output", %{io: io} do
@ -205,7 +82,7 @@ defmodule Livebook.Evaluator.IOProxyTest do
end
test "supports direct livebook output forwarding", %{io: io} do
put_livebook_output(io, {:text, "[1, 2, 3]"})
livebook_put_output(io, {:text, "[1, 2, 3]"})
assert_received {:evaluation_output, :ref, {:text, "[1, 2, 3]"}}
end
@ -214,42 +91,79 @@ defmodule Livebook.Evaluator.IOProxyTest do
widget1_pid = IEx.Helpers.pid(0, 0, 0)
widget2_pid = IEx.Helpers.pid(0, 0, 1)
put_livebook_output(io, {:vega_lite_dynamic, widget1_pid})
put_livebook_output(io, {:vega_lite_dynamic, widget2_pid})
put_livebook_output(io, {:vega_lite_dynamic, widget1_pid})
livebook_put_output(io, {:vega_lite_dynamic, widget1_pid})
livebook_put_output(io, {:vega_lite_dynamic, widget2_pid})
livebook_put_output(io, {:vega_lite_dynamic, widget1_pid})
assert IOProxy.flush_widgets(io) == MapSet.new([widget1_pid, widget2_pid])
assert IOProxy.flush_widgets(io) == MapSet.new()
end
# Helpers
describe "token requests" do
test "returns different tokens for subsequent calls", %{io: io} do
IOProxy.configure(io, self(), :ref1)
token1 = livebook_generate_token(io)
token2 = livebook_generate_token(io)
assert token1 != token2
end
defp get_until(pid, encoding, prompt, module, function) do
:io.request(pid, {:get_until, encoding, prompt, module, function, []})
test "returns different tokens for different refs", %{io: io} do
IOProxy.configure(io, self(), :ref1)
token1 = livebook_generate_token(io)
IOProxy.configure(io, self(), :ref2)
token2 = livebook_generate_token(io)
assert token1 != token2
end
test "returns same tokens for the same ref", %{io: io} do
IOProxy.configure(io, self(), :ref)
token1 = livebook_generate_token(io)
token2 = livebook_generate_token(io)
IOProxy.configure(io, self(), :ref)
token3 = livebook_generate_token(io)
token4 = livebook_generate_token(io)
assert token1 == token3
assert token2 == token4
end
end
defp configure_owner_with_input(io, prompt, input) do
# Helpers
defp configure_owner_with_input(io, input_id, value) do
pid =
spawn_link(fn ->
reply_to_input_request(:ref, prompt, {:ok, input}, 1)
reply_to_input_request(:ref, input_id, {:ok, value}, 1)
end)
IOProxy.configure(io, pid, :ref)
end
defp reply_to_input_request(_ref, _prompt, _reply, 0), do: :ok
defp reply_to_input_request(_ref, _input_id, _reply, 0), do: :ok
defp reply_to_input_request(ref, prompt, reply, times) do
defp reply_to_input_request(ref, input_id, reply, times) do
receive do
{:evaluation_input, ^ref, reply_to, ^prompt} ->
{:evaluation_input, ^ref, reply_to, ^input_id} ->
send(reply_to, {:evaluation_input_reply, reply})
reply_to_input_request(ref, prompt, reply, times - 1)
reply_to_input_request(ref, input_id, reply, times - 1)
end
end
defp put_livebook_output(io, output) do
defp livebook_put_output(io, output) do
io_request(io, {:livebook_put_output, output})
end
defp livebook_get_input_value(io, input_id) do
io_request(io, {:livebook_get_input_value, input_id})
end
defp livebook_generate_token(io) do
io_request(io, :livebook_generate_token)
end
defp io_request(io, request) do
ref = make_ref()
send(io, {:io_request, self(), ref, {:livebook_put_output, output}})
assert_receive {:io_reply, ^ref, :ok}
send(io, {:io_request, self(), ref, request})
assert_receive {:io_reply, ^ref, reply}
reply
end
end

View file

@ -55,13 +55,22 @@ defmodule Livebook.EvaluatorTest do
assert_receive {:evaluation_output, :code_1, "hey\n"}
end
test "using standard input sends input request to the caller", %{evaluator: evaluator} do
Evaluator.evaluate_code(evaluator, self(), ~s{IO.gets("name: ")}, :code_1)
test "using livebook input sends input request to the caller", %{evaluator: evaluator} do
code = """
ref = make_ref()
send(Process.group_leader(), {:io_request, self(), ref, {:livebook_get_input_value, "input1"}})
assert_receive {:evaluation_input, :code_1, reply_to, "name: "}
send(reply_to, {:evaluation_input_reply, {:ok, "Jake Peralta\n"}})
receive do
{:io_reply, ^ref, {:ok, value}} -> value
end
"""
assert_receive {:evaluation_response, :code_1, {:ok, "Jake Peralta\n"},
Evaluator.evaluate_code(evaluator, self(), code, :code_1)
assert_receive {:evaluation_input, :code_1, reply_to, "input1"}
send(reply_to, {:evaluation_input_reply, {:ok, :value}})
assert_receive {:evaluation_response, :code_1, {:ok, :value},
%{evaluation_time_ms: _time_ms}}
end

View file

@ -46,24 +46,11 @@ defmodule Livebook.LiveMarkdown.ExportTest do
| id: "s2",
name: "Section 2",
cells: [
%{
Notebook.Cell.new(:input)
| type: :text,
name: "length",
value: "100"
},
%{
Notebook.Cell.new(:elixir)
| source: """
IO.gets("length: ")\
"""
},
%{
Notebook.Cell.new(:input)
| type: :range,
name: "length",
value: "100",
props: %{min: 50, max: 150, step: 2}
}
]
},
@ -106,14 +93,10 @@ defmodule Livebook.LiveMarkdown.ExportTest do
## Section 2
<!-- livebook:{"livebook_object":"cell_input","name":"length","type":"text","value":"100"} -->
```elixir
IO.gets("length: ")
```
<!-- livebook:{"livebook_object":"cell_input","name":"length","props":{"max":150,"min":50,"step":2},"type":"range","value":"100"} -->
<!-- livebook:{"branch_parent_index":1} -->
## Section 3
@ -388,39 +371,6 @@ defmodule Livebook.LiveMarkdown.ExportTest do
assert expected_document == document
end
test "saves password as empty string" do
notebook = %{
Notebook.new()
| name: "My Notebook",
sections: [
%{
Notebook.Section.new()
| name: "Section 1",
cells: [
%{
Notebook.Cell.new(:input)
| type: :password,
name: "pass",
value: "0123456789"
}
]
}
]
}
expected_document = """
# My Notebook
## Section 1
<!-- livebook:{"livebook_object":"cell_input","name":"pass","type":"password","value":""} -->
"""
document = Export.notebook_to_markdown(notebook)
assert expected_document == document
end
test "handles backticks in code cell" do
notebook = %{
Notebook.new()

View file

@ -29,14 +29,10 @@ defmodule Livebook.LiveMarkdown.ImportTest do
## Section 2
<!-- livebook:{"livebook_object":"cell_input","name":"length","type":"text","value":"100"} -->
```elixir
IO.gets("length: ")
```
<!-- livebook:{"livebook_object":"cell_input","name":"length","props":{"max":150,"min":50,"step":2},"type":"range","value":"100"} -->
<!-- livebook:{"branch_parent_index":1} -->
## Section 3
@ -85,21 +81,10 @@ defmodule Livebook.LiveMarkdown.ImportTest do
id: section2_id,
name: "Section 2",
cells: [
%Cell.Input{
type: :text,
name: "length",
value: "100"
},
%Cell.Elixir{
source: """
IO.gets("length: ")\
"""
},
%Cell.Input{
type: :range,
name: "length",
value: "100",
props: %{min: 50, max: 150, step: 2}
}
]
},
@ -451,35 +436,6 @@ defmodule Livebook.LiveMarkdown.ImportTest do
} = notebook
end
test "sets default input types props if not provided" do
markdown = """
# My Notebook
## Section 1
<!-- livebook:{"livebook_object":"cell_input","name":"length","props":{"extra":100,"max":150},"type":"range","value":"100"} -->
"""
{notebook, []} = Import.notebook_from_markdown(markdown)
expected_props = %{min: 0, max: 150, step: 1}
assert %Notebook{
name: "My Notebook",
sections: [
%Notebook.Section{
name: "Section 1",
cells: [
%Cell.Input{
type: :range,
props: ^expected_props
}
]
}
]
} = notebook
end
test "imports markdown content into separate cells when a break annotation is encountered" do
markdown = """
# My Notebook
@ -762,23 +718,23 @@ defmodule Livebook.LiveMarkdown.ImportTest do
} = notebook
end
test "skips invalid input type and returns a message" do
markdown = """
# My Notebook
## Section 1
<!-- livebook:{"livebook_object":"cell_input","type":"input_from_the_future"} -->
"""
{_notebook, messages} = Import.notebook_from_markdown(markdown)
assert [
~s{unrecognised input type "input_from_the_future", if it's a valid type it means your Livebook version doesn't support it}
] == messages
end
describe "backward compatibility" do
test "warns if the imported notebook includes an input" do
markdown = """
# My Notebook
## Section 1
<!-- livebook:{"livebook_object":"cell_input","name":"length","type":"text","value":"100"} -->
"""
{_notebook, messages} = Import.notebook_from_markdown(markdown)
assert [
"found an input cell, but those are no longer supported, please use Kino.Input instead"
] == messages
end
test "warns if the imported notebook includes a reactive input" do
markdown = """
# My Notebook
@ -791,7 +747,8 @@ defmodule Livebook.LiveMarkdown.ImportTest do
{_notebook, messages} = Import.notebook_from_markdown(markdown)
assert [
"found a reactive input, but those are no longer supported, you can use automatically reevaluating cell instead"
"found an input cell, but those are no longer supported, please use Kino.Input instead." <>
" Also, to make the input reactive you can use an automatically reevaluating cell"
] == messages
end
end

View file

@ -1,71 +0,0 @@
defmodule Livebook.Notebook.Cell.InputText do
use ExUnit.Case, async: true
alias Livebook.Notebook.Cell.Input
describe "validate/1" do
test "given text input allows any value" do
input = %{Input.new() | type: :text, value: "some 🐈 text"}
assert Input.validate(input) == :ok
end
test "given url input allows full urls" do
input = %{Input.new() | type: :url, value: "https://example.com"}
assert Input.validate(input) == :ok
input = %{Input.new() | type: :url, value: "https://example.com/some/path"}
assert Input.validate(input) == :ok
input = %{Input.new() | type: :url, value: ""}
assert Input.validate(input) == {:error, "not a valid URL"}
input = %{Input.new() | type: :url, value: "example.com"}
assert Input.validate(input) == {:error, "not a valid URL"}
input = %{Input.new() | type: :url, value: "https://"}
assert Input.validate(input) == {:error, "not a valid URL"}
end
test "given number input allows integers and floats" do
input = %{Input.new() | type: :number, value: "-12"}
assert Input.validate(input) == :ok
input = %{Input.new() | type: :number, value: "3.14"}
assert Input.validate(input) == :ok
input = %{Input.new() | type: :number, value: ""}
assert Input.validate(input) == {:error, "not a valid number"}
input = %{Input.new() | type: :number, value: "1."}
assert Input.validate(input) == {:error, "not a valid number"}
input = %{Input.new() | type: :number, value: ".0"}
assert Input.validate(input) == {:error, "not a valid number"}
input = %{Input.new() | type: :number, value: "-"}
assert Input.validate(input) == {:error, "not a valid number"}
end
test "given color input allows valid hex colors" do
input = %{Input.new() | type: :color, value: "#111111"}
assert Input.validate(input) == :ok
input = %{Input.new() | type: :color, value: "ABCDEF"}
assert Input.validate(input) == {:error, "not a valid hex color"}
end
test "given range input allows numbers in the configured range" do
input = %{Input.new() | type: :range, value: "0", props: %{min: -5, max: 5, step: 1}}
assert Input.validate(input) == :ok
input = %{Input.new() | type: :range, value: "", props: %{min: -5, max: 5, step: 1}}
assert Input.validate(input) == {:error, "not a valid number"}
input = %{Input.new() | type: :range, value: "-10", props: %{min: -5, max: 5, step: 1}}
assert Input.validate(input) == {:error, "number too small"}
input = %{Input.new() | type: :range, value: "10", props: %{min: -5, max: 5, step: 1}}
assert Input.validate(input) == {:error, "number too big"}
end
end
end

View file

@ -43,24 +43,11 @@ defmodule Livebook.Notebook.Export.ElixirTest do
| id: "s2",
name: "Section 2",
cells: [
%{
Notebook.Cell.new(:input)
| type: :text,
name: "length",
value: "100"
},
%{
Notebook.Cell.new(:elixir)
| source: """
IO.gets("length: ")\
"""
},
%{
Notebook.Cell.new(:input)
| type: :range,
name: "length",
value: "100",
props: %{min: 50, max: 150, step: 2}
}
]
},

View file

@ -81,7 +81,7 @@ defmodule Livebook.NotebookTest do
Section.new()
| id: "s2",
cells: [
%{Cell.new(:input) | id: "c3"},
%{Cell.new(:markdown) | id: "c3"},
%{Cell.new(:elixir) | id: "c4"}
]
}
@ -254,63 +254,4 @@ defmodule Livebook.NotebookTest do
}
end
end
describe "input_cell_for_prompt/3" do
test "returns an error if no input matches the given prompt" do
cell1 = Cell.new(:elixir)
notebook = %{
Notebook.new()
| sections: [
%{Section.new() | cells: [cell1]}
]
}
assert :error = Notebook.input_cell_for_prompt(notebook, cell1.id, "name")
end
test "returns an input field if one is matching" do
cell1 = %{Cell.new(:input) | name: "name", value: "Jake Peralta"}
cell2 = Cell.new(:elixir)
notebook = %{
Notebook.new()
| sections: [
%{Section.new() | cells: [cell1, cell2]}
]
}
assert {:ok, ^cell1} = Notebook.input_cell_for_prompt(notebook, cell2.id, "name")
end
test "returns the first input if there are many with the same name" do
cell1 = %{Cell.new(:input) | name: "name", value: "Amy Santiago"}
cell2 = %{Cell.new(:input) | name: "name", value: "Jake Peralta"}
cell3 = Cell.new(:elixir)
notebook = %{
Notebook.new()
| sections: [
%{Section.new() | cells: [cell1, cell2, cell3]}
]
}
assert {:ok, ^cell2} = Notebook.input_cell_for_prompt(notebook, cell3.id, "name")
end
test "returns longest-prefix input if many match the prompt" do
cell1 = %{Cell.new(:input) | name: "name", value: "Amy Santiago"}
cell2 = %{Cell.new(:input) | name: "nam", value: "Jake Peralta"}
cell3 = Cell.new(:elixir)
notebook = %{
Notebook.new()
| sections: [
%{Section.new() | cells: [cell1, cell2, cell3]}
]
}
assert {:ok, ^cell1} = Notebook.input_cell_for_prompt(notebook, cell3.id, "name: ")
end
end
end

View file

@ -2170,20 +2170,24 @@ defmodule Livebook.Session.DataTest do
end
test "if bound input value changes during cell evaluation, the cell is marked as stale afterwards" do
input = %{id: "i1", type: :text, label: "Text", default: "hey"}
data =
data_after_operations!([
{:insert_section, self(), 0, "s1"},
{:insert_cell, self(), "s1", 0, :input, "c1"},
{:insert_cell, self(), "s1", 0, :elixir, "c1"},
{:insert_cell, self(), "s1", 1, :elixir, "c2"},
{:set_runtime, self(), NoopRuntime.new()},
{:queue_cell_evaluation, self(), "c1"},
{:add_cell_evaluation_response, self(), "c1", {:input, input}, @eval_meta},
{:queue_cell_evaluation, self(), "c2"},
{:add_cell_evaluation_response, self(), "c2", @eval_resp, @eval_meta},
# Make the Elixir cell evaluating
{:queue_cell_evaluation, self(), "c2"},
# Bind the input (effectively read the current value)
{:bind_input, self(), "c2", "c1"},
{:bind_input, self(), "c2", "i1"},
# Change the input value, while the cell is evaluating
{:set_cell_attributes, self(), "c1", %{value: "stuff"}}
{:set_input_value, self(), "i1", "stuff"}
])
operation = {:add_cell_evaluation_response, self(), "c2", @eval_resp, @eval_meta}
@ -2234,6 +2238,113 @@ defmodule Livebook.Session.DataTest do
assert {:ok, %{dirty: true}, []} = Data.apply_operation(data, operation)
end
test "stores default values for new inputs" do
input = %{id: "i1", type: :text, label: "Text", default: "hey"}
data =
data_after_operations!([
{:insert_section, self(), 0, "s1"},
{:insert_cell, self(), "s1", 0, :elixir, "c1"},
{:set_runtime, self(), NoopRuntime.new()},
{:queue_cell_evaluation, self(), "c1"}
])
operation = {:add_cell_evaluation_response, self(), "c1", {:input, input}, @eval_meta}
assert {:ok, %{input_values: %{"i1" => "hey"}}, _} = Data.apply_operation(data, operation)
end
test "keeps input values for inputs that existed" do
input = %{id: "i1", type: :text, label: "Text", default: "hey"}
data =
data_after_operations!([
{:insert_section, self(), 0, "s1"},
{:insert_cell, self(), "s1", 0, :elixir, "c1"},
{:set_runtime, self(), NoopRuntime.new()},
{:queue_cell_evaluation, self(), "c1"},
{:add_cell_evaluation_response, self(), "c1", {:input, input}, @eval_meta},
{:set_input_value, self(), "i1", "value"},
{:queue_cell_evaluation, self(), "c1"}
])
# Output the same input again
operation = {:add_cell_evaluation_response, self(), "c1", {:input, input}, @eval_meta}
assert {:ok, %{input_values: %{"i1" => "value"}}, _} = Data.apply_operation(data, operation)
end
test "garbage collects input values that are no longer used" do
input = %{id: "i1", type: :text, label: "Text", default: "hey"}
data =
data_after_operations!([
{:insert_section, self(), 0, "s1"},
{:insert_cell, self(), "s1", 0, :elixir, "c1"},
{:set_runtime, self(), NoopRuntime.new()},
{:queue_cell_evaluation, self(), "c1"},
{:add_cell_evaluation_response, self(), "c1", {:input, input}, @eval_meta},
{:set_input_value, self(), "i1", "value"},
{:queue_cell_evaluation, self(), "c1"}
])
# This time w don't output the input
operation = {:add_cell_evaluation_response, self(), "c1", {:ok, 10}, @eval_meta}
empty_map = %{}
assert {:ok, %{input_values: ^empty_map}, _} = Data.apply_operation(data, operation)
end
test "does not garbage collect inputs if present in another cell" do
input = %{id: "i1", type: :text, label: "Text", default: "hey"}
data =
data_after_operations!([
{:insert_section, self(), 0, "s1"},
{:insert_cell, self(), "s1", 0, :elixir, "c1"},
{:insert_cell, self(), "s1", 1, :elixir, "c2"},
{:set_runtime, self(), NoopRuntime.new()},
{:queue_cell_evaluation, self(), "c1"},
{:queue_cell_evaluation, self(), "c2"},
{:add_cell_evaluation_response, self(), "c1", {:input, input}, @eval_meta},
{:add_cell_evaluation_response, self(), "c2", {:input, input}, @eval_meta},
{:set_input_value, self(), "i1", "value"},
{:queue_cell_evaluation, self(), "c1"}
])
# This time w don't output the input
operation = {:add_cell_evaluation_response, self(), "c1", {:ok, 10}, @eval_meta}
assert {:ok, %{input_values: %{"i1" => "value"}}, _} = Data.apply_operation(data, operation)
end
test "does not garbage collect inputs if another evaluation is ongoing" do
input = %{id: "i1", type: :text, label: "Text", default: "hey"}
data =
data_after_operations!([
{:insert_section, self(), 0, "s1"},
{:insert_section, self(), 1, "s2"},
{:insert_section, self(), 2, "s3"},
{:set_section_parent, self(), "s2", "s1"},
{:set_section_parent, self(), "s3", "s1"},
{:insert_cell, self(), "s2", 0, :elixir, "c1"},
{:insert_cell, self(), "s3", 0, :elixir, "c2"},
{:set_runtime, self(), NoopRuntime.new()},
{:queue_cell_evaluation, self(), "c1"},
{:add_cell_evaluation_response, self(), "c1", {:input, input}, @eval_meta},
{:set_input_value, self(), "i1", "value"},
{:queue_cell_evaluation, self(), "c1"},
{:queue_cell_evaluation, self(), "c2"}
])
# This time w don't output the input
operation = {:add_cell_evaluation_response, self(), "c1", {:ok, 10}, @eval_meta}
assert {:ok, %{input_values: %{"i1" => "value"}}, _} = Data.apply_operation(data, operation)
end
end
describe "apply_operation/2 given :bind_input" do
@ -2261,16 +2372,22 @@ defmodule Livebook.Session.DataTest do
end
test "updates elixir cell info with binding to the input cell" do
input = %{id: "i1", type: :text, label: "Text", default: "hey"}
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", 0, :elixir, "c1"},
{:insert_cell, self(), "s1", 1, :elixir, "c2"},
{:set_runtime, self(), NoopRuntime.new()},
{:queue_cell_evaluation, self(), "c1"},
{:add_cell_evaluation_response, self(), "c1", {:input, input}, @eval_meta},
{:queue_cell_evaluation, self(), "c2"}
])
operation = {:bind_input, self(), "c2", "c1"}
operation = {:bind_input, self(), "c2", "i1"}
bound_to_input_ids = MapSet.new(["c1"])
bound_to_input_ids = MapSet.new(["i1"])
assert {:ok,
%{
@ -3041,38 +3158,6 @@ defmodule Livebook.Session.DataTest do
}, _} = Data.apply_operation(data, operation)
end
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"},
# 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"},
{: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"}
operation = {:set_cell_attributes, self(), "c1", attrs}
assert {:ok,
%{
cell_infos: %{
"c2" => %{validity_status: :evaluated},
"c3" => %{validity_status: :stale},
"c4" => %{validity_status: :stale}
}
}, _} = Data.apply_operation(data, operation)
end
test "setting reevaluate_automatically on stale cell marks it for evaluation" do
data =
data_after_operations!([
@ -3102,6 +3187,67 @@ defmodule Livebook.Session.DataTest do
end
end
describe "apply_operation/2 given :set_input_value" do
test "returns an error given invalid input id" do
data = Data.new()
operation = {:set_input_value, self(), "nonexistent", "stuff"}
assert :error = Data.apply_operation(data, operation)
end
test "stores new input value" do
input = %{id: "i1", type: :text, label: "Text", default: "hey"}
data =
data_after_operations!([
{:insert_section, self(), 0, "s1"},
{:insert_cell, self(), "s1", 0, :elixir, "c1"},
{:set_runtime, self(), NoopRuntime.new()},
{:queue_cell_evaluation, self(), "c1"},
{:add_cell_evaluation_response, self(), "c1", {:input, input}, @eval_meta}
])
operation = {:set_input_value, self(), "i1", "stuff"}
assert {:ok, %{input_values: %{"i1" => "stuff"}}, _} = Data.apply_operation(data, operation)
end
test "given input value change, marks evaluated bound cells and their dependants as stale" do
input = %{id: "i1", type: :text, label: "Text", default: "hey"}
data =
data_after_operations!([
{:insert_section, self(), 0, "s1"},
{:insert_cell, self(), "s1", 0, :elixir, "c1"},
# 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(), "c1"},
{:queue_cell_evaluation, self(), "c2"},
{:queue_cell_evaluation, self(), "c3"},
{:queue_cell_evaluation, self(), "c4"},
{:add_cell_evaluation_response, self(), "c1", {:input, input}, @eval_meta},
{: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", "i1"}
])
operation = {:set_input_value, self(), "i1", "stuff"}
assert {:ok,
%{
cell_infos: %{
"c2" => %{validity_status: :evaluated},
"c3" => %{validity_status: :stale},
"c4" => %{validity_status: :stale}
}
}, _} = Data.apply_operation(data, operation)
end
end
describe "apply_operation/2 given :set_runtime" do
test "updates data with the given runtime" do
data = Data.new()
@ -3210,39 +3356,29 @@ defmodule Livebook.Session.DataTest do
end
describe "bound_cells_with_section/2" do
test "returns an empty list when an invalid cell id is given" do
test "returns an empty list when an invalid input 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
test "returns elixir cells bound to the given input" do
input = %{id: "i1", type: :text, label: "Text", default: "hey"}
data =
data_after_operations!([
{:insert_section, self(), 0, "s1"},
{:insert_cell, self(), "s1", 0, :input, "c1"},
{:insert_cell, self(), "s1", 0, :elixir, "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"}
{:set_runtime, self(), NoopRuntime.new()},
{:queue_cell_evaluation, self(), "c1"},
{:add_cell_evaluation_response, self(), "c1", {:input, input}, @eval_meta},
{:bind_input, self(), "c2", "i1"},
{:bind_input, self(), "c4", "i1"}
])
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")
assert [{%{id: "c2"}, _}, {%{id: "c4"}, _}] = Data.bound_cells_with_section(data, "i1")
end
end
end

View file

@ -436,21 +436,35 @@ defmodule Livebook.SessionTest do
# Integration tests concerning input communication
# between runtime and session
@livebook_put_input_code """
input = %{id: "input1", type: :number, label: "Name", default: "hey"}
send(
Process.group_leader(),
{:io_request, self(), make_ref(), {:livebook_put_output, {:input, input}}}
)
"""
@livebook_get_input_value_code """
ref = make_ref()
send(Process.group_leader(), {:io_request, self(), ref, {:livebook_get_input_value, "input1"}})
receive do
{:io_reply, ^ref, reply} -> reply
end
"""
describe "user input" do
test "replies to runtime input request" do
input_cell = %{Notebook.Cell.new(:input) | name: "name", value: "Jake Peralta"}
input_elixir_cell = %{Notebook.Cell.new(:elixir) | source: @livebook_put_input_code}
elixir_cell = %{
Notebook.Cell.new(:elixir)
| source: """
IO.gets("name: ")
"""
}
elixir_cell = %{Notebook.Cell.new(:elixir) | source: @livebook_get_input_value_code}
notebook = %{
Notebook.new()
| sections: [
%{Notebook.Section.new() | cells: [input_cell, elixir_cell]}
%{Notebook.Section.new() | cells: [input_elixir_cell, elixir_cell]}
]
}
@ -465,16 +479,11 @@ defmodule Livebook.SessionTest do
{:add_cell_evaluation_response, _, ^cell_id, {:text, text_output},
%{evaluation_time_ms: _time_ms}}}
assert text_output =~ "Jake Peralta"
assert text_output =~ "hey"
end
test "replies with error when no matching input is found" do
elixir_cell = %{
Notebook.Cell.new(:elixir)
| source: """
IO.gets("name: ")
"""
}
elixir_cell = %{Notebook.Cell.new(:elixir) | source: @livebook_get_input_value_code}
notebook = %{
Notebook.new()
@ -492,42 +501,9 @@ defmodule Livebook.SessionTest do
assert_receive {:operation,
{:add_cell_evaluation_response, _, ^cell_id, {:text, text_output},
%{evaluation_time_ms: _time_ms}}},
2_000
%{evaluation_time_ms: _time_ms}}}
assert text_output =~ "no matching Livebook input found"
end
test "replies with error when the matching input is invalid" do
input_cell = %{Notebook.Cell.new(:input) | type: :url, name: "url", value: "invalid"}
elixir_cell = %{
Notebook.Cell.new(:elixir)
| source: """
IO.gets("name: ")
"""
}
notebook = %{
Notebook.new()
| sections: [
%{Notebook.Section.new() | cells: [input_cell, elixir_cell]}
]
}
session = start_session(notebook: notebook)
cell_id = elixir_cell.id
Phoenix.PubSub.subscribe(Livebook.PubSub, "sessions:#{session.id}")
Session.queue_cell_evaluation(session.pid, cell_id)
assert_receive {:operation,
{:add_cell_evaluation_response, _, ^cell_id, {:text, text_output},
%{evaluation_time_ms: _time_ms}}},
2_000
assert text_output =~ "no matching Livebook input found"
assert text_output =~ ":error"
end
end

View file

@ -167,18 +167,42 @@ defmodule LivebookWeb.SessionLiveTest do
assert %{notebook: %{sections: [%{cells: []}]}} = Session.get_data(session.pid)
end
test "newlines in input values are normalized", %{conn: conn, session: session} do
test "editing input field in cell output", %{conn: conn, session: session} do
section_id = insert_section(session.pid)
cell_id = insert_input_cell(session.pid, section_id)
insert_cell_with_input(session.pid, section_id, %{
id: "input1",
type: :number,
label: "Name",
default: "hey"
})
{:ok, view, _} = live(conn, "/sessions/#{session.id}")
view
|> element(~s/form[phx-change="set_cell_value"]/)
|> element(~s/[data-element="outputs-container"] form/)
|> render_change(%{"value" => "10"})
assert %{input_values: %{"input1" => 10}} = Session.get_data(session.pid)
end
test "newlines in text input are normalized", %{conn: conn, session: session} do
section_id = insert_section(session.pid)
insert_cell_with_input(session.pid, section_id, %{
id: "input1",
type: :textarea,
label: "Name",
default: "hey"
})
{:ok, view, _} = live(conn, "/sessions/#{session.id}")
view
|> element(~s/[data-element="outputs-container"] form/)
|> render_change(%{"value" => "line\r\nline"})
assert %{notebook: %{sections: [%{cells: [%{id: ^cell_id, value: "line\nline"}]}]}} =
Session.get_data(session.pid)
assert %{input_values: %{"input1" => "line\nline"}} = Session.get_data(session.pid)
end
end
@ -414,51 +438,6 @@ defmodule LivebookWeb.SessionLiveTest do
end
end
describe "input cell settings" do
test "setting input cell attributes updates data", %{conn: conn, session: session} do
section_id = insert_section(session.pid)
cell_id = insert_input_cell(session.pid, section_id)
{:ok, view, _} = live(conn, "/sessions/#{session.id}/cell-settings/#{cell_id}")
form_selector = ~s/[role="dialog"] form/
assert view
|> element(form_selector)
|> render_change(%{attrs: %{type: "range"}}) =~
~s{<div class="input-label">Min</div>}
view
|> element(form_selector)
|> render_change(%{attrs: %{name: "length"}})
view
|> element(form_selector)
|> render_change(%{attrs: %{props: %{min: "10"}}})
view
|> element(form_selector)
|> render_submit()
assert %{
notebook: %{
sections: [
%{
cells: [
%{
id: ^cell_id,
type: :range,
name: "length",
props: %{min: 10, max: 100, step: 1}
}
]
}
]
}
} = Session.get_data(session.pid)
end
end
describe "relative paths" do
test "renders an info message when the path doesn't have notebook extension",
%{conn: conn, session: session} do
@ -721,10 +700,19 @@ defmodule LivebookWeb.SessionLiveTest do
cell.id
end
defp insert_input_cell(session_pid, section_id) do
Session.insert_cell(session_pid, section_id, 0, :input)
%{notebook: %{sections: [%{cells: [cell]}]}} = Session.get_data(session_pid)
cell.id
defp insert_cell_with_input(session_pid, section_id, input) do
code =
quote do
send(
Process.group_leader(),
{:io_request, self(), make_ref(), {:livebook_put_output, {:input, unquote(input)}}}
)
end
|> Macro.to_string()
cell_id = insert_text_cell(session_pid, section_id, :elixir, code)
Session.queue_cell_evaluation(session_pid, cell_id)
cell_id
end
defp create_user_with_name(name) do