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()