mirror of
https://github.com/livebook-dev/livebook.git
synced 2025-11-10 06:01:44 +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())
|
@spec find_inputs_in_output(output()) :: list(input_attrs :: map())
|
||||||
def find_inputs_in_output(output)
|
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: []
|
def find_inputs_in_output(_output), do: []
|
||||||
end
|
end
|
||||||
|
|
|
||||||
|
|
@ -53,6 +53,7 @@ defmodule LivebookWeb.Output do
|
||||||
defp standalone?({:table_dynamic, _}), do: true
|
defp standalone?({:table_dynamic, _}), do: true
|
||||||
defp standalone?({:frame_dynamic, _}), do: true
|
defp standalone?({:frame_dynamic, _}), do: true
|
||||||
defp standalone?({:input, _}), do: true
|
defp standalone?({:input, _}), do: true
|
||||||
|
defp standalone?({:control, _}), do: true
|
||||||
defp standalone?(_output), do: false
|
defp standalone?(_output), do: false
|
||||||
|
|
||||||
defp composite?({:frame_dynamic, _}), do: true
|
defp composite?({:frame_dynamic, _}), do: true
|
||||||
|
|
@ -124,8 +125,12 @@ defmodule LivebookWeb.Output do
|
||||||
)
|
)
|
||||||
end
|
end
|
||||||
|
|
||||||
defp render_output({:control, attrs}, %{id: id}) do
|
defp render_output({:control, attrs}, %{id: id, input_values: input_values}) do
|
||||||
live_component(LivebookWeb.Output.ControlComponent, id: id, attrs: attrs)
|
live_component(LivebookWeb.Output.ControlComponent,
|
||||||
|
id: id,
|
||||||
|
attrs: attrs,
|
||||||
|
input_values: input_values
|
||||||
|
)
|
||||||
end
|
end
|
||||||
|
|
||||||
defp render_output({:error, formatted, :runtime_restart_required}, %{
|
defp render_output({:error, formatted, :runtime_restart_required}, %{
|
||||||
|
|
|
||||||
|
|
@ -39,6 +39,17 @@ defmodule LivebookWeb.Output.ControlComponent do
|
||||||
"""
|
"""
|
||||||
end
|
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
|
def render(assigns) do
|
||||||
~H"""
|
~H"""
|
||||||
<div class="text-red-600">
|
<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
|
@impl true
|
||||||
def mount(socket) do
|
def mount(socket) do
|
||||||
{:ok, assign(socket, error: nil)}
|
{:ok, assign(socket, error: nil, local: false)}
|
||||||
end
|
end
|
||||||
|
|
||||||
@impl true
|
@impl true
|
||||||
|
|
@ -177,8 +177,15 @@ defmodule LivebookWeb.Output.InputComponent do
|
||||||
socket
|
socket
|
||||||
|
|
||||||
{:ok, value} ->
|
{:ok, value} ->
|
||||||
send(self(), {:set_input_value, socket.assigns.attrs.id, value})
|
send(
|
||||||
report_event(socket, value)
|
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)
|
assign(socket, value: value, error: nil)
|
||||||
|
|
||||||
{:error, error, value} ->
|
{:error, error, value} ->
|
||||||
|
|
|
||||||
|
|
@ -834,20 +834,7 @@ defmodule LivebookWeb.SessionLive do
|
||||||
|
|
||||||
@impl true
|
@impl true
|
||||||
def handle_info({:operation, operation}, socket) do
|
def handle_info({:operation, operation}, socket) do
|
||||||
case Session.Data.apply_operation(socket.private.data, operation) do
|
{:noreply, handle_operation(socket, operation)}
|
||||||
{: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
|
|
||||||
end
|
end
|
||||||
|
|
||||||
def handle_info({:error, error}, socket) do
|
def handle_info({:error, error}, socket) do
|
||||||
|
|
@ -903,9 +890,22 @@ defmodule LivebookWeb.SessionLive do
|
||||||
{:noreply, push_event(socket, "location_report", report)}
|
{:noreply, push_event(socket, "location_report", report)}
|
||||||
end
|
end
|
||||||
|
|
||||||
def handle_info({:set_input_value, input_id, value}, socket) do
|
def handle_info({:set_input_values, values, local}, socket) do
|
||||||
Session.set_input_value(socket.assigns.session.pid, input_id, value)
|
if local do
|
||||||
{:noreply, socket}
|
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
|
end
|
||||||
|
|
||||||
def handle_info({:queue_bound_cells_evaluation, input_id}, socket) do
|
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))
|
push_patch(socket, to: Routes.session_path(socket, :page, socket.assigns.session.id))
|
||||||
end
|
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
|
defp after_operation(socket, _prev_socket, {:client_join, client_pid, user}) do
|
||||||
push_event(socket, "client_joined", %{client: client_info(client_pid, user)})
|
push_event(socket, "client_joined", %{client: client_info(client_pid, user)})
|
||||||
end
|
end
|
||||||
|
|
|
||||||
|
|
@ -172,14 +172,16 @@ defmodule LivebookWeb.SessionLiveTest do
|
||||||
|
|
||||||
Process.register(self(), test)
|
Process.register(self(), test)
|
||||||
|
|
||||||
insert_cell_with_input(session.pid, section_id, %{
|
input = %{
|
||||||
ref: :reference,
|
ref: :input_ref,
|
||||||
id: "input1",
|
id: "input1",
|
||||||
type: :number,
|
type: :number,
|
||||||
label: "Name",
|
label: "Name",
|
||||||
default: "hey",
|
default: "hey",
|
||||||
destination: test
|
destination: test
|
||||||
})
|
}
|
||||||
|
|
||||||
|
insert_cell_with_output(session.pid, section_id, {:input, input})
|
||||||
|
|
||||||
{:ok, view, _} = live(conn, "/sessions/#{session.id}")
|
{:ok, view, _} = live(conn, "/sessions/#{session.id}")
|
||||||
|
|
||||||
|
|
@ -188,6 +190,8 @@ defmodule LivebookWeb.SessionLiveTest do
|
||||||
|> render_change(%{"value" => "10"})
|
|> render_change(%{"value" => "10"})
|
||||||
|
|
||||||
assert %{input_values: %{"input1" => 10}} = Session.get_data(session.pid)
|
assert %{input_values: %{"input1" => 10}} = Session.get_data(session.pid)
|
||||||
|
|
||||||
|
assert_receive {:event, :input_ref, %{value: 10, type: :change}}
|
||||||
end
|
end
|
||||||
|
|
||||||
test "newlines in text input are normalized", %{conn: conn, session: session, test: test} do
|
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)
|
Process.register(self(), test)
|
||||||
|
|
||||||
insert_cell_with_input(session.pid, section_id, %{
|
input = %{
|
||||||
ref: :reference,
|
ref: :input_ref,
|
||||||
id: "input1",
|
id: "input1",
|
||||||
type: :textarea,
|
type: :textarea,
|
||||||
label: "Name",
|
label: "Name",
|
||||||
default: "hey",
|
default: "hey",
|
||||||
destination: test
|
destination: test
|
||||||
})
|
}
|
||||||
|
|
||||||
|
insert_cell_with_output(session.pid, section_id, {:input, input})
|
||||||
|
|
||||||
{:ok, view, _} = live(conn, "/sessions/#{session.id}")
|
{: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)
|
assert %{input_values: %{"input1" => "line\nline"}} = Session.get_data(session.pid)
|
||||||
end
|
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
|
end
|
||||||
|
|
||||||
describe "outputs" do
|
describe "outputs" do
|
||||||
|
|
@ -739,13 +790,12 @@ defmodule LivebookWeb.SessionLiveTest do
|
||||||
cell.id
|
cell.id
|
||||||
end
|
end
|
||||||
|
|
||||||
defp insert_cell_with_input(session_pid, section_id, input) do
|
defp insert_cell_with_output(session_pid, section_id, output) do
|
||||||
code =
|
code =
|
||||||
quote do
|
quote do
|
||||||
send(
|
send(
|
||||||
Process.group_leader(),
|
Process.group_leader(),
|
||||||
{:io_request, self(), make_ref(),
|
{:io_request, self(), make_ref(), {:livebook_put_output, unquote(Macro.escape(output))}}
|
||||||
{:livebook_put_output, {:input, unquote(Macro.escape(input))}}}
|
|
||||||
)
|
)
|
||||||
end
|
end
|
||||||
|> Macro.to_string()
|
|> Macro.to_string()
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue