Add support for form control (#790)

* Add support for form control

* Handle report_changes map

* Assert on input/control events
This commit is contained in:
Jonatan Kłosko 2021-12-12 00:09:35 +01:00 committed by GitHub
parent 460668402f
commit 6f53e3db6a
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
7 changed files with 211 additions and 32 deletions

View file

@ -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

View file

@ -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}, %{

View file

@ -39,6 +39,17 @@ defmodule LivebookWeb.Output.ControlComponent do
"""
end
def render(%{attrs: %{type: :form}} = assigns) do
~H"""
<div>
<.live_component module={LivebookWeb.Output.ControlFormComponent}
id={@id}
attrs={@attrs}
input_values={@input_values} />
</div>
"""
end
def render(assigns) do
~H"""
<div class="text-red-600">

View file

@ -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"""
<div class="flex flex-col space-y-3">
<%= 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 %>
<div>
<button class="button-base button-blue"
type="button"
phx-click="submit"
phx-target={@myself}>
<%= @attrs.submit %>
</button>
</div>
<% end %>
</div>
"""
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

View file

@ -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} ->

View file

@ -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

View file

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