mirror of
https://github.com/livebook-dev/livebook.git
synced 2025-11-08 05:04:46 +08:00
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:
parent
460668402f
commit
6f53e3db6a
7 changed files with 211 additions and 32 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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}, %{
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
85
lib/livebook_web/live/output/control_form_component.ex
Normal file
85
lib/livebook_web/live/output/control_form_component.ex
Normal 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
|
||||
|
|
@ -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} ->
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue