diff --git a/assets/js/cell/index.js b/assets/js/cell/index.js index 40c9a93ed22..cba754524b1 100644 --- a/assets/js/cell/index.js +++ b/assets/js/cell/index.js @@ -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(); - } } } diff --git a/assets/js/session/index.js b/assets/js/session/index.js index 0073588e9f7..23c7e36d589 100644 --- a/assets/js/session/index.js +++ b/assets/js/session/index.js @@ -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); } diff --git a/lib/livebook/evaluator.ex b/lib/livebook/evaluator.ex index 9b9e657d04a..6026a1d0bd1 100644 --- a/lib/livebook/evaluator.ex +++ b/lib/livebook/evaluator.ex @@ -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} diff --git a/lib/livebook/evaluator/io_proxy.ex b/lib/livebook/evaluator/io_proxy.ex index 5e290db7b36..f50d19c3853 100644 --- a/lib/livebook/evaluator/io_proxy.ex +++ b/lib/livebook/evaluator/io_proxy.ex @@ -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(<>, count, acc), - do: utf8_split_at(t, count - 1, acc + byte_size(<>)) - - 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 diff --git a/lib/livebook/live_markdown/export.ex b/lib/livebook/live_markdown/export.ex index cda0fada489..4a2dcb3815a 100644 --- a/lib/livebook/live_markdown/export.ex +++ b/lib/livebook/live_markdown/export.ex @@ -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) - - "" - |> prepend_metadata(metadata) - end - defp cell_metadata(%Cell.Elixir{} = cell) do put_unless_default( %{}, diff --git a/lib/livebook/live_markdown/import.ex b/lib/livebook/live_markdown/import.ex index ef6760fcaed..06b4843709a 100644 --- a/lib/livebook/live_markdown/import.ex +++ b/lib/livebook/live_markdown/import.ex @@ -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 -> diff --git a/lib/livebook/notebook.ex b/lib/livebook/notebook.ex index 00716daed1b..a0362cf1ed6 100644 --- a/lib/livebook/notebook.ex +++ b/lib/livebook/notebook.ex @@ -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. """ diff --git a/lib/livebook/notebook/cell.ex b/lib/livebook/notebook/cell.ex index fec0ec96124..11f9f88556d 100644 --- a/lib/livebook/notebook/cell.ex +++ b/lib/livebook/notebook/cell.ex @@ -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 diff --git a/lib/livebook/notebook/cell/elixir.ex b/lib/livebook/notebook/cell/elixir.ex index 8b137f711ee..60abad694fb 100644 --- a/lib/livebook/notebook/cell/elixir.ex +++ b/lib/livebook/notebook/cell/elixir.ex @@ -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 diff --git a/lib/livebook/notebook/cell/input.ex b/lib/livebook/notebook/cell/input.ex deleted file mode 100644 index dcf589dc062..00000000000 --- a/lib/livebook/notebook/cell/input.ex +++ /dev/null @@ -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 diff --git a/lib/livebook/notebook/explore/distributed_portals_with_elixir.livemd b/lib/livebook/notebook/explore/distributed_portals_with_elixir.livemd index ec378672977..92a605150a8 100644 --- a/lib/livebook/notebook/explore/distributed_portals_with_elixir.livemd +++ b/lib/livebook/notebook/explore/distributed_portals_with_elixir.livemd @@ -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: - +```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: - +```elixir +node_input = Kino.Input.text("Other node") +``` - +```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) diff --git a/lib/livebook/notebook/explore/elixir_and_livebook.livemd b/lib/livebook/notebook/explore/elixir_and_livebook.livemd index e0ca1f0ea37..6dce5effa2a 100644 --- a/lib/livebook/notebook/explore/elixir_and_livebook.livemd +++ b/lib/livebook/notebook/explore/elixir_and_livebook.livemd @@ -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: - +```elixir +date_input = Kino.Input.text("Date") +``` ```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)}" diff --git a/lib/livebook/notebook/explore/vm_introspection.livemd b/lib/livebook/notebook/explore/vm_introspection.livemd index e2ff0fe2133..f99a314b744 100644 --- a/lib/livebook/notebook/explore/vm_introspection.livemd +++ b/lib/livebook/notebook/explore/vm_introspection.livemd @@ -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: - +```elixir +node_input = Kino.Input.text("Node") +``` - +```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) diff --git a/lib/livebook/runtime.ex b/lib/livebook/runtime.ex index 34162116eb6..65a87dfe5ef 100644 --- a/lib/livebook/runtime.ex +++ b/lib/livebook/runtime.ex @@ -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. diff --git a/lib/livebook/session.ex b/lib/livebook/session.ex index 35bb99cb9e5..098fb77c3aa 100644 --- a/lib/livebook/session.ex +++ b/lib/livebook/session.ex @@ -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 diff --git a/lib/livebook/session/data.ex b/lib/livebook/session/data.ex index 07f2044151a..2359a5e4ebf 100644 --- a/lib/livebook/session/data.ex +++ b/lib/livebook/session/data.ex @@ -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_actions, cell, output) do + data_actions + |> add_cell_output(cell, output) end - defp add_cell_evaluation_response({data, _} = data_actions, cell, output) do + 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 diff --git a/lib/livebook/utils.ex b/lib/livebook/utils.ex index f72af92f03c..466a481872c 100644 --- a/lib/livebook/utils.ex +++ b/lib/livebook/utils.ex @@ -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 """ diff --git a/lib/livebook_web/live/output.ex b/lib/livebook_web/live/output.ex index aa70e2d21d3..df2d7f0e962 100644 --- a/lib/livebook_web/live/output.ex +++ b/lib/livebook_web/live/output.ex @@ -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 render_output(text, %{id: id}) when is_binary(text) do + def outputs(assigns) do + ~H""" +
+ <%= for {{outputs, standalone?}, group_idx} <- @outputs |> group_outputs() |> Enum.with_index() do %> +
+ <%= for {output, idx} <- Enum.with_index(outputs) do %> +
+ <%= render_output(output, %{ + id: "#{@id}-output#{group_idx}_#{idx}", + socket: @socket, + runtime: @runtime, + input_values: @input_values + }) %> +
+ <% end %> +
+ <% end %> +
+ """ + end + + 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 + + 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 - def render_output({:error, formatted, :runtime_restart_required}, %{runtime: runtime}) - when runtime != nil do + 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. diff --git a/lib/livebook_web/live/output/frame_dynamic.ex b/lib/livebook_web/live/output/frame_dynamic.ex index 48935862aba..dc40add2fcc 100644 --- a/lib/livebook_web/live/output/frame_dynamic.ex +++ b/lib/livebook_web/live/output/frame_dynamic.ex @@ -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"""
<%= if @output do %> - <%= LivebookWeb.Output.render_output(@output, %{ - id: "#{@id}-frame", - socket: @socket, - runtime: nil - }) %> + <% else %>
Empty output frame diff --git a/lib/livebook_web/live/output/input_component.ex b/lib/livebook_web/live/output/input_component.ex new file mode 100644 index 00000000000..d13fb33c245 --- /dev/null +++ b/lib/livebook_web/live/output/input_component.ex @@ -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""" +
+
+ <%= @attrs.label %> +
+ + <.input + id={"#{@id}-input"} + attrs={@attrs} + value={@value} + error={@error} + myself={@myself} /> + + <%= if @error do %> +
+ <%= @error %> +
+ <% end %> +
+ """ + end + + defp input(%{attrs: %{type: :select}} = assigns) do + ~H""" + + """ + end + + defp input(%{attrs: %{type: :checkbox}} = assigns) do + ~H""" +
+ <.switch_checkbox + data-element="input" + name="value" + checked={@value} /> +
+ """ + end + + defp input(%{attrs: %{type: :range}} = assigns) do + ~H""" +
+
<%= @attrs.min %>
+ +
<%= @attrs.max %>
+
+ """ + end + + defp input(%{attrs: %{type: :textarea}} = assigns) do + ~H""" + + """ + end + + defp input(%{attrs: %{type: :password}} = assigns) do + ~H""" + <.with_password_toggle id={"#{@id}-password-toggle"}> + + + """ + end + + defp input(assigns) do + ~H""" + + """ + 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 diff --git a/lib/livebook_web/live/session_live.ex b/lib/livebook_web/live/session_live.ex index c9e94f0289e..b45eb472482 100644 --- a/lib/livebook_web/live/session_live.ex +++ b/lib/livebook_web/live/session_live.ex @@ -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. diff --git a/lib/livebook_web/live/session_live/cell_component.ex b/lib/livebook_web/live/session_live/cell_component.ex index 1e4a47f102f..056a46f2337 100644 --- a/lib/livebook_web/live/session_live/cell_component.ex +++ b/lib/livebook_web/live/session_live/cell_component.ex @@ -115,147 +115,18 @@ defmodule LivebookWeb.SessionLive.CellComponent do <%= if @cell_view.outputs != [] do %>
- <.outputs cell_view={@cell_view} runtime={@runtime} socket={@socket} /> +
<% end %> """ end - defp render_cell(%{cell_view: %{type: :input}} = assigns) do - ~H""" -
- -
- - <.cell_body> -
- -
- <%= @cell_view.name %> -
- - <.cell_input cell_view={@cell_view} /> - - <%= if @cell_view.error do %> -
- <%= String.capitalize(@cell_view.error) %> -
- <% end %> -
- - """ - end - - defp cell_input(%{cell_view: %{input_type: :textarea}} = assigns) do - ~H""" - - """ - end - - defp cell_input(%{cell_view: %{input_type: :range}} = assigns) do - ~H""" -
-
<%= @cell_view.props.min %>
- -
<%= @cell_view.props.max %>
-
- """ - end - - defp cell_input(%{cell_view: %{input_type: :select}} = assigns) do - ~H""" -
- -
- """ - end - - defp cell_input(%{cell_view: %{input_type: :checkbox}} = assigns) do - ~H""" -
- <.switch_checkbox - data-element="input" - name="value" - checked={@cell_view.value == "true"} /> -
- """ - end - - defp cell_input(%{cell_view: %{input_type: :password}} = assigns) do - ~H""" - <.with_password_toggle id={@cell_view.id}> - - - """ - end - - defp cell_input(assigns) do - ~H""" - - """ - 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""" - ```elixir IO.gets("length: ") ``` - - ## 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 - - - """ - - document = Export.notebook_to_markdown(notebook) - - assert expected_document == document - end - test "handles backticks in code cell" do notebook = %{ Notebook.new() diff --git a/test/livebook/live_markdown/import_test.exs b/test/livebook/live_markdown/import_test.exs index ed1b175003d..325cce16f82 100644 --- a/test/livebook/live_markdown/import_test.exs +++ b/test/livebook/live_markdown/import_test.exs @@ -29,14 +29,10 @@ defmodule Livebook.LiveMarkdown.ImportTest do ## Section 2 - - ```elixir IO.gets("length: ") ``` - - ## 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 - - - """ - - {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 + describe "backward compatibility" do + test "warns if the imported notebook includes an input" do + markdown = """ + # My Notebook - ## Section 1 + ## Section 1 - - """ + + """ - {_notebook, messages} = Import.notebook_from_markdown(markdown) + {_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 + assert [ + "found an input cell, but those are no longer supported, please use Kino.Input instead" + ] == messages + end - describe "backward compatibility" do 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 diff --git a/test/livebook/notebook/cell/input_test.exs b/test/livebook/notebook/cell/input_test.exs deleted file mode 100644 index eb31ec596c1..00000000000 --- a/test/livebook/notebook/cell/input_test.exs +++ /dev/null @@ -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 diff --git a/test/livebook/notebook/export/elixir_test.exs b/test/livebook/notebook/export/elixir_test.exs index e5b281e55d6..f1613bc509a 100644 --- a/test/livebook/notebook/export/elixir_test.exs +++ b/test/livebook/notebook/export/elixir_test.exs @@ -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} } ] }, diff --git a/test/livebook/notebook_test.exs b/test/livebook/notebook_test.exs index 8dfb15fe2c7..9f1c69255fb 100644 --- a/test/livebook/notebook_test.exs +++ b/test/livebook/notebook_test.exs @@ -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 diff --git a/test/livebook/session/data_test.exs b/test/livebook/session/data_test.exs index 4dddb691f63..1be92e2c966 100644 --- a/test/livebook/session/data_test.exs +++ b/test/livebook/session/data_test.exs @@ -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,62 +3158,91 @@ 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 + test "setting reevaluate_automatically on stale cell marks it for evaluation" 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", 0, :elixir, "c1"}, {:insert_cell, self(), "s1", 1, :elixir, "c2"}, - {:insert_cell, self(), "s1", 2, :elixir, "c3"}, - {:insert_cell, self(), "s1", 3, :elixir, "c4"}, + # Evaluate cells {:set_runtime, self(), NoopRuntime.new()}, + {:queue_cell_evaluation, self(), "c1"}, + {:add_cell_evaluation_response, self(), "c1", @eval_resp, @eval_meta}, {: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"} + {:queue_cell_evaluation, self(), "c1"}, + {:add_cell_evaluation_response, self(), "c1", @eval_resp, @eval_meta} ]) - attrs = %{value: "stuff"} - operation = {:set_cell_attributes, self(), "c1", attrs} + attrs = %{reevaluate_automatically: true} + operation = {:set_cell_attributes, self(), "c2", attrs} assert {:ok, %{ cell_infos: %{ - "c2" => %{validity_status: :evaluated}, - "c3" => %{validity_status: :stale}, - "c4" => %{validity_status: :stale} + "c1" => %{evaluation_status: :ready}, + "c2" => %{evaluation_status: :evaluating} } }, _} = Data.apply_operation(data, operation) 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"} - test "setting reevaluate_automatically on stale cell marks it for evaluation" do 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"}, - # Evaluate cells + {:insert_cell, self(), "s1", 2, :elixir, "c3"}, + {:insert_cell, self(), "s1", 3, :elixir, "c4"}, {:set_runtime, self(), NoopRuntime.new()}, {:queue_cell_evaluation, self(), "c1"}, - {:add_cell_evaluation_response, self(), "c1", @eval_resp, @eval_meta}, {: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}, - {:queue_cell_evaluation, self(), "c1"}, - {:add_cell_evaluation_response, self(), "c1", @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"} ]) - attrs = %{reevaluate_automatically: true} - operation = {:set_cell_attributes, self(), "c2", attrs} + operation = {:set_input_value, self(), "i1", "stuff"} assert {:ok, %{ cell_infos: %{ - "c1" => %{evaluation_status: :ready}, - "c2" => %{evaluation_status: :evaluating} + "c2" => %{validity_status: :evaluated}, + "c3" => %{validity_status: :stale}, + "c4" => %{validity_status: :stale} } }, _} = Data.apply_operation(data, operation) end @@ -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"} - ]) - - 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"} + {: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"}, _}] = Data.bound_cells_with_section(data, "c1") + assert [{%{id: "c2"}, _}, {%{id: "c4"}, _}] = Data.bound_cells_with_section(data, "i1") end end end diff --git a/test/livebook/session_test.exs b/test/livebook/session_test.exs index 3699a7c1ec2..6d0581b6af8 100644 --- a/test/livebook/session_test.exs +++ b/test/livebook/session_test.exs @@ -436,50 +436,35 @@ defmodule Livebook.SessionTest do # Integration tests concerning input communication # between runtime and session - describe "user input" do - test "replies to runtime input request" do - input_cell = %{Notebook.Cell.new(:input) | name: "name", value: "Jake Peralta"} - elixir_cell = %{ - Notebook.Cell.new(:elixir) - | source: """ - IO.gets("name: ") - """ - } + @livebook_put_input_code """ + input = %{id: "input1", type: :number, label: "Name", default: "hey"} - notebook = %{ - Notebook.new() - | sections: [ - %{Notebook.Section.new() | cells: [input_cell, elixir_cell]} - ] - } + send( + Process.group_leader(), + {:io_request, self(), make_ref(), {:livebook_put_output, {:input, input}}} + ) + """ - session = start_session(notebook: notebook) - - cell_id = elixir_cell.id + @livebook_get_input_value_code """ + ref = make_ref() + send(Process.group_leader(), {:io_request, self(), ref, {:livebook_get_input_value, "input1"}}) - 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}}} + receive do + {:io_reply, ^ref, reply} -> reply + end + """ - assert text_output =~ "Jake Peralta" - end + describe "user input" do + test "replies to runtime input request" do + input_elixir_cell = %{Notebook.Cell.new(:elixir) | source: @livebook_put_input_code} - 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() | sections: [ - %{Notebook.Section.new() | cells: [elixir_cell]} + %{Notebook.Section.new() | cells: [input_elixir_cell, elixir_cell]} ] } @@ -492,26 +477,18 @@ 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" + assert text_output =~ "hey" 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: ") - """ - } + test "replies with error when no matching input is found" do + 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: [elixir_cell]} ] } @@ -524,10 +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" + assert text_output =~ ":error" end end diff --git a/test/livebook_web/live/session_live_test.exs b/test/livebook_web/live/session_live_test.exs index 36d551c5776..d0413854f3f 100644 --- a/test/livebook_web/live/session_live_test.exs +++ b/test/livebook_web/live/session_live_test.exs @@ -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{
Min
} - - 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