mirror of
https://github.com/livebook-dev/livebook.git
synced 2025-12-11 14:16:44 +08:00
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:
parent
b6ddf1883c
commit
c2636b8220
34 changed files with 964 additions and 1570 deletions
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
%{},
|
||||
|
|
|
|||
|
|
@ -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 ->
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
"""
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)}"
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 """
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
226
lib/livebook_web/live/output/input_component.ex
Normal file
226
lib/livebook_web/live/output/input_component.ex
Normal 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
|
||||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -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}
|
||||
}
|
||||
]
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue