diff --git a/lib/livebook/notebook/cell/elixir.ex b/lib/livebook/notebook/cell/elixir.ex
index 60abad694..cb04381ef 100644
--- a/lib/livebook/notebook/cell/elixir.ex
+++ b/lib/livebook/notebook/cell/elixir.ex
@@ -65,6 +65,13 @@ defmodule Livebook.Notebook.Cell.Elixir do
@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({:input, attrs}) do
+ [attrs]
+ end
+
+ def find_inputs_in_output({:control, %{type: :form, fields: fields}}) do
+ Keyword.values(fields)
+ end
+
def find_inputs_in_output(_output), do: []
end
diff --git a/lib/livebook_web/live/output.ex b/lib/livebook_web/live/output.ex
index 0fdaf87b5..a1723505c 100644
--- a/lib/livebook_web/live/output.ex
+++ b/lib/livebook_web/live/output.ex
@@ -53,6 +53,7 @@ defmodule LivebookWeb.Output do
defp standalone?({:table_dynamic, _}), do: true
defp standalone?({:frame_dynamic, _}), do: true
defp standalone?({:input, _}), do: true
+ defp standalone?({:control, _}), do: true
defp standalone?(_output), do: false
defp composite?({:frame_dynamic, _}), do: true
@@ -124,8 +125,12 @@ defmodule LivebookWeb.Output do
)
end
- defp render_output({:control, attrs}, %{id: id}) do
- live_component(LivebookWeb.Output.ControlComponent, id: id, attrs: attrs)
+ defp render_output({:control, attrs}, %{id: id, input_values: input_values}) do
+ live_component(LivebookWeb.Output.ControlComponent,
+ id: id,
+ attrs: attrs,
+ input_values: input_values
+ )
end
defp render_output({:error, formatted, :runtime_restart_required}, %{
diff --git a/lib/livebook_web/live/output/control_component.ex b/lib/livebook_web/live/output/control_component.ex
index d6d2f49a2..fa0620e6b 100644
--- a/lib/livebook_web/live/output/control_component.ex
+++ b/lib/livebook_web/live/output/control_component.ex
@@ -39,6 +39,17 @@ defmodule LivebookWeb.Output.ControlComponent do
"""
end
+ def render(%{attrs: %{type: :form}} = assigns) do
+ ~H"""
+
+ <.live_component module={LivebookWeb.Output.ControlFormComponent}
+ id={@id}
+ attrs={@attrs}
+ input_values={@input_values} />
+
+ """
+ end
+
def render(assigns) do
~H"""
diff --git a/lib/livebook_web/live/output/control_form_component.ex b/lib/livebook_web/live/output/control_form_component.ex
new file mode 100644
index 000000000..12e0b99b0
--- /dev/null
+++ b/lib/livebook_web/live/output/control_form_component.ex
@@ -0,0 +1,85 @@
+defmodule LivebookWeb.Output.ControlFormComponent do
+ use LivebookWeb, :live_component
+
+ @impl true
+ def mount(socket) do
+ {:ok, assign(socket, data: %{})}
+ end
+
+ @impl true
+ def update(assigns, socket) do
+ prev_data = socket.assigns.data
+
+ socket = assign(socket, assigns)
+
+ data =
+ Map.new(assigns.attrs.fields, fn {field, input_attrs} ->
+ {field, assigns.input_values[input_attrs.id]}
+ end)
+
+ if data != prev_data do
+ change_data =
+ for {field, value} <- data,
+ assigns.attrs.report_changes[field],
+ into: %{},
+ do: {field, value}
+
+ if change_data != %{} do
+ report_event(socket, %{type: :change, data: change_data})
+ end
+ end
+
+ {:ok, assign(socket, data: data)}
+ end
+
+ @impl true
+ def render(%{attrs: %{type: :form}} = assigns) do
+ ~H"""
+
+ <%= for {_field, input_attrs} <- @attrs.fields do %>
+ <.live_component module={LivebookWeb.Output.InputComponent}
+ id={"#{@id}-#{input_attrs.id}"}
+ attrs={input_attrs}
+ input_values={@input_values}
+ local={true} />
+ <% end %>
+ <%= if @attrs.submit do %>
+
+
+
+ <% end %>
+
+ """
+ end
+
+ @impl true
+ def handle_event("submit", %{}, socket) do
+ report_event(socket, %{type: :submit, data: socket.assigns.data})
+
+ if socket.assigns.attrs.reset_on_submit do
+ reset_inputs(socket)
+ end
+
+ {:noreply, socket}
+ end
+
+ defp report_event(socket, attrs) do
+ topic = socket.assigns.attrs.ref
+ event = Map.merge(%{origin: self()}, attrs)
+ send(socket.assigns.attrs.destination, {:event, topic, event})
+ end
+
+ defp reset_inputs(socket) do
+ values =
+ for {field, input_attrs} <- socket.assigns.attrs.fields,
+ field in socket.assigns.attrs.reset_on_submit,
+ do: {input_attrs.id, input_attrs.default}
+
+ send(self(), {:set_input_values, values, true})
+ end
+end
diff --git a/lib/livebook_web/live/output/input_component.ex b/lib/livebook_web/live/output/input_component.ex
index d8730f89f..415ff6a41 100644
--- a/lib/livebook_web/live/output/input_component.ex
+++ b/lib/livebook_web/live/output/input_component.ex
@@ -3,7 +3,7 @@ defmodule LivebookWeb.Output.InputComponent do
@impl true
def mount(socket) do
- {:ok, assign(socket, error: nil)}
+ {:ok, assign(socket, error: nil, local: false)}
end
@impl true
@@ -177,8 +177,15 @@ defmodule LivebookWeb.Output.InputComponent do
socket
{:ok, value} ->
- send(self(), {:set_input_value, socket.assigns.attrs.id, value})
- report_event(socket, value)
+ send(
+ self(),
+ {:set_input_values, [{socket.assigns.attrs.id, value}], socket.assigns.local}
+ )
+
+ unless socket.assigns.local do
+ report_event(socket, value)
+ end
+
assign(socket, value: value, error: nil)
{:error, error, value} ->
diff --git a/lib/livebook_web/live/session_live.ex b/lib/livebook_web/live/session_live.ex
index 9cb855d04..7b4b8dce3 100644
--- a/lib/livebook_web/live/session_live.ex
+++ b/lib/livebook_web/live/session_live.ex
@@ -834,20 +834,7 @@ defmodule LivebookWeb.SessionLive do
@impl true
def handle_info({:operation, operation}, socket) do
- case Session.Data.apply_operation(socket.private.data, operation) do
- {:ok, data, actions} ->
- new_socket =
- socket
- |> assign_private(data: data)
- |> assign(data_view: update_data_view(socket.assigns.data_view, data, operation))
- |> after_operation(socket, operation)
- |> handle_actions(actions)
-
- {:noreply, new_socket}
-
- :error ->
- {:noreply, socket}
- end
+ {:noreply, handle_operation(socket, operation)}
end
def handle_info({:error, error}, socket) do
@@ -903,9 +890,22 @@ 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}
+ def handle_info({:set_input_values, values, local}, socket) do
+ if local do
+ socket =
+ Enum.reduce(values, socket, fn {input_id, value}, socket ->
+ operation = {:set_input_value, self(), input_id, value}
+ handle_operation(socket, operation)
+ end)
+
+ {:noreply, socket}
+ else
+ for {input_id, value} <- values do
+ Session.set_input_value(socket.assigns.session.pid, input_id, value)
+ end
+
+ {:noreply, socket}
+ end
end
def handle_info({:queue_bound_cells_evaluation, input_id}, socket) do
@@ -1026,6 +1026,20 @@ defmodule LivebookWeb.SessionLive do
push_patch(socket, to: Routes.session_path(socket, :page, socket.assigns.session.id))
end
+ defp handle_operation(socket, operation) do
+ case Session.Data.apply_operation(socket.private.data, operation) do
+ {:ok, data, actions} ->
+ socket
+ |> assign_private(data: data)
+ |> assign(data_view: update_data_view(socket.assigns.data_view, data, operation))
+ |> after_operation(socket, operation)
+ |> handle_actions(actions)
+
+ :error ->
+ socket
+ end
+ end
+
defp after_operation(socket, _prev_socket, {:client_join, client_pid, user}) do
push_event(socket, "client_joined", %{client: client_info(client_pid, user)})
end
diff --git a/test/livebook_web/live/session_live_test.exs b/test/livebook_web/live/session_live_test.exs
index ab3740547..ad5c991b2 100644
--- a/test/livebook_web/live/session_live_test.exs
+++ b/test/livebook_web/live/session_live_test.exs
@@ -172,14 +172,16 @@ defmodule LivebookWeb.SessionLiveTest do
Process.register(self(), test)
- insert_cell_with_input(session.pid, section_id, %{
- ref: :reference,
+ input = %{
+ ref: :input_ref,
id: "input1",
type: :number,
label: "Name",
default: "hey",
destination: test
- })
+ }
+
+ insert_cell_with_output(session.pid, section_id, {:input, input})
{:ok, view, _} = live(conn, "/sessions/#{session.id}")
@@ -188,6 +190,8 @@ defmodule LivebookWeb.SessionLiveTest do
|> render_change(%{"value" => "10"})
assert %{input_values: %{"input1" => 10}} = Session.get_data(session.pid)
+
+ assert_receive {:event, :input_ref, %{value: 10, type: :change}}
end
test "newlines in text input are normalized", %{conn: conn, session: session, test: test} do
@@ -195,14 +199,16 @@ defmodule LivebookWeb.SessionLiveTest do
Process.register(self(), test)
- insert_cell_with_input(session.pid, section_id, %{
- ref: :reference,
+ input = %{
+ ref: :input_ref,
id: "input1",
type: :textarea,
label: "Name",
default: "hey",
destination: test
- })
+ }
+
+ insert_cell_with_output(session.pid, section_id, {:input, input})
{:ok, view, _} = live(conn, "/sessions/#{session.id}")
@@ -212,6 +218,51 @@ defmodule LivebookWeb.SessionLiveTest do
assert %{input_values: %{"input1" => "line\nline"}} = Session.get_data(session.pid)
end
+
+ test "form input changes are reflected only in local LV data",
+ %{conn: conn, session: session, test: test} do
+ section_id = insert_section(session.pid)
+
+ Process.register(self(), test)
+
+ form_control = %{
+ type: :form,
+ ref: :form_ref,
+ destination: test,
+ fields: [
+ name: %{
+ ref: :input_ref,
+ id: "input1",
+ type: :text,
+ label: "Name",
+ default: "initial",
+ destination: test
+ }
+ ],
+ submit: "Send",
+ report_changes: %{},
+ reset_on_submit: []
+ }
+
+ insert_cell_with_output(session.pid, section_id, {:control, form_control})
+
+ {:ok, view, _} = live(conn, "/sessions/#{session.id}")
+
+ view
+ |> element(~s/[data-element="outputs-container"] form/)
+ |> render_change(%{"value" => "sherlock"})
+
+ # The new value is on the page
+ assert render(view) =~ "sherlock"
+ # but it's not reflected in the synchronized session data
+ assert %{input_values: %{"input1" => "initial"}} = Session.get_data(session.pid)
+
+ view
+ |> element(~s/[data-element="outputs-container"] button/, "Send")
+ |> render_click()
+
+ assert_receive {:event, :form_ref, %{data: %{name: "sherlock"}, type: :submit}}
+ end
end
describe "outputs" do
@@ -739,13 +790,12 @@ defmodule LivebookWeb.SessionLiveTest do
cell.id
end
- defp insert_cell_with_input(session_pid, section_id, input) do
+ defp insert_cell_with_output(session_pid, section_id, output) do
code =
quote do
send(
Process.group_leader(),
- {:io_request, self(), make_ref(),
- {:livebook_put_output, {:input, unquote(Macro.escape(input))}}}
+ {:io_request, self(), make_ref(), {:livebook_put_output, unquote(Macro.escape(output))}}
)
end
|> Macro.to_string()