mirror of
https://github.com/livebook-dev/livebook.git
synced 2025-01-07 23:47:53 +08:00
487 lines
12 KiB
Elixir
487 lines
12 KiB
Elixir
defmodule LivebookWeb.Output.InputComponent do
|
|
use LivebookWeb, :live_component
|
|
|
|
@impl true
|
|
def mount(socket) do
|
|
{:ok, assign(socket, local: false, counter: 0)}
|
|
end
|
|
|
|
@impl true
|
|
def update(%{event: :change, value: value}, socket) do
|
|
{:ok, handle_change(socket, value)}
|
|
end
|
|
|
|
def update(assigns, socket) do
|
|
%{value: value, changed: changed} = assigns.input_views[assigns.input.id]
|
|
|
|
socket =
|
|
socket
|
|
|> assign(assigns)
|
|
|> assign(value: value, changed: changed)
|
|
|
|
{:ok, socket}
|
|
end
|
|
|
|
@impl true
|
|
def render(assigns) when assigns.input.attrs.type == :image do
|
|
~H"""
|
|
<div id={"#{@id}-form-#{@counter}"}>
|
|
<.input_label label={@input.attrs.label} changed={@changed} />
|
|
<.live_component
|
|
module={LivebookWeb.Output.ImageInputComponent}
|
|
id={"#{@id}-input"}
|
|
input_component_id={@id}
|
|
value={@value}
|
|
height={@input.attrs.size && elem(@input.attrs.size, 0)}
|
|
width={@input.attrs.size && elem(@input.attrs.size, 1)}
|
|
format={@input.attrs.format}
|
|
fit={@input.attrs.fit}
|
|
input_id={@input.id}
|
|
session_pid={@session_pid}
|
|
client_id={@client_id}
|
|
local={@local}
|
|
/>
|
|
</div>
|
|
"""
|
|
end
|
|
|
|
def render(assigns) when assigns.input.attrs.type == :audio do
|
|
~H"""
|
|
<div id={"#{@id}-form-#{@counter}"}>
|
|
<.input_label label={@input.attrs.label} changed={@changed} />
|
|
<.live_component
|
|
module={LivebookWeb.Output.AudioInputComponent}
|
|
id={"#{@id}-input"}
|
|
input_component_id={@id}
|
|
value={@value}
|
|
format={@input.attrs.format}
|
|
sampling_rate={@input.attrs.sampling_rate}
|
|
input_id={@input.id}
|
|
session_pid={@session_pid}
|
|
client_id={@client_id}
|
|
local={@local}
|
|
/>
|
|
</div>
|
|
"""
|
|
end
|
|
|
|
def render(assigns) when assigns.input.attrs.type == :file do
|
|
~H"""
|
|
<div id={"#{@id}-form-#{@counter}"}>
|
|
<.input_label label={@input.attrs.label} changed={@changed} />
|
|
<.live_component
|
|
module={LivebookWeb.Output.FileInputComponent}
|
|
id={"#{@id}-input"}
|
|
input_component_id={@id}
|
|
value={@value}
|
|
accept={@input.attrs.accept}
|
|
input_id={@input.id}
|
|
session_pid={@session_pid}
|
|
client_id={@client_id}
|
|
local={@local}
|
|
/>
|
|
</div>
|
|
"""
|
|
end
|
|
|
|
def render(assigns) when assigns.input.attrs.type == :utc_datetime do
|
|
~H"""
|
|
<div id={"#{@id}-form-#{@counter}"}>
|
|
<.input_label
|
|
label={@input.attrs.label}
|
|
changed={@changed}
|
|
help="Choose the time in your local time zone"
|
|
/>
|
|
<div class="inline-flex">
|
|
<.text_field
|
|
class="w-auto"
|
|
id={@id}
|
|
type="datetime-local"
|
|
data-el-input
|
|
name="html_value"
|
|
step="60"
|
|
autocomplete="off"
|
|
phx-hook="UtcDateTimeInput"
|
|
value={nil}
|
|
data-p-utc-value={hook_prop(@value && NaiveDateTime.to_iso8601(@value))}
|
|
data-p-utc-min={hook_prop(@input.attrs.min && NaiveDateTime.to_iso8601(@input.attrs.min))}
|
|
data-p-utc-max={hook_prop(@input.attrs.max && NaiveDateTime.to_iso8601(@input.attrs.max))}
|
|
data-p-phx-target={hook_prop(@myself)}
|
|
/>
|
|
</div>
|
|
</div>
|
|
"""
|
|
end
|
|
|
|
def render(assigns) when assigns.input.attrs.type == :utc_time do
|
|
~H"""
|
|
<div id={"#{@id}-form-#{@counter}"}>
|
|
<.input_label
|
|
label={@input.attrs.label}
|
|
changed={@changed}
|
|
help="Choose the time in your local time zone"
|
|
/>
|
|
<div class="inline-flex">
|
|
<.text_field
|
|
id={@id}
|
|
type="time"
|
|
data-el-input
|
|
name="html_value"
|
|
step="60"
|
|
autocomplete="off"
|
|
phx-hook="UtcTimeInput"
|
|
value={nil}
|
|
data-p-utc-value={hook_prop(@value && Time.to_iso8601(@value))}
|
|
data-p-utc-min={hook_prop(@input.attrs.min && Time.to_iso8601(@input.attrs.min))}
|
|
data-p-utc-max={hook_prop(@input.attrs.max && Time.to_iso8601(@input.attrs.max))}
|
|
data-p-phx-target={hook_prop(@myself)}
|
|
/>
|
|
</div>
|
|
</div>
|
|
"""
|
|
end
|
|
|
|
def render(assigns) do
|
|
~H"""
|
|
<form id={"#{@id}-form-#{@counter}"} phx-change="change" phx-submit="submit" phx-target={@myself}>
|
|
<.input_label label={@input.attrs.label} changed={@changed} />
|
|
<.input_output id={"#{@id}-input"} attrs={@input.attrs} value={@value} myself={@myself} />
|
|
</form>
|
|
"""
|
|
end
|
|
|
|
defp input_output(%{attrs: %{type: :select}} = assigns) do
|
|
~H"""
|
|
<div class="w-60">
|
|
<.select_field
|
|
id={@id}
|
|
name="html_value"
|
|
value={Enum.find_index(@attrs.options, fn {key, _label} -> key == @value end)}
|
|
options={Enum.with_index(@attrs.options, fn {_key, label}, idx -> {label, idx} end)}
|
|
data-el-input
|
|
/>
|
|
</div>
|
|
"""
|
|
end
|
|
|
|
defp input_output(%{attrs: %{type: :checkbox}} = assigns) do
|
|
~H"""
|
|
<div class="mt-1">
|
|
<.switch_field data-el-input name="html_value" value={@value} id={@id} />
|
|
</div>
|
|
"""
|
|
end
|
|
|
|
defp input_output(%{attrs: %{type: :range}} = assigns) do
|
|
~H"""
|
|
<div class="flex items-center space-x-2">
|
|
<div><%= @attrs.min %></div>
|
|
<input
|
|
type="range"
|
|
data-el-input
|
|
class="range-input"
|
|
id={@id}
|
|
name="html_value"
|
|
value={@value}
|
|
phx-debounce={@attrs.debounce}
|
|
phx-target={@myself}
|
|
spellcheck="false"
|
|
autocomplete="off"
|
|
min={@attrs.min}
|
|
max={@attrs.max}
|
|
step={@attrs.step}
|
|
/>
|
|
<div><%= @attrs.max %></div>
|
|
</div>
|
|
"""
|
|
end
|
|
|
|
defp input_output(%{attrs: %{type: :textarea}} = assigns) do
|
|
~H"""
|
|
<.textarea_field
|
|
id={@id}
|
|
class="min-h-[38px] max-h-[300px]"
|
|
monospace={@attrs.monospace}
|
|
data-el-input
|
|
name="html_value"
|
|
value={@value}
|
|
phx-hook="TextareaAutosize"
|
|
phx-debounce={@attrs.debounce}
|
|
phx-target={@myself}
|
|
spellcheck="false"
|
|
/>
|
|
"""
|
|
end
|
|
|
|
defp input_output(%{attrs: %{type: :password}} = assigns) do
|
|
~H"""
|
|
<div class="inline-flex">
|
|
<.password_field
|
|
id={@id}
|
|
data-el-input
|
|
name="html_value"
|
|
value={@value}
|
|
phx-debounce={@attrs.debounce}
|
|
phx-target={@myself}
|
|
spellcheck="false"
|
|
autocomplete="off"
|
|
/>
|
|
</div>
|
|
"""
|
|
end
|
|
|
|
defp input_output(%{attrs: %{type: :date}} = assigns) do
|
|
~H"""
|
|
<div class="inline-flex">
|
|
<.text_field
|
|
type="date"
|
|
data-el-input
|
|
id={@id}
|
|
name="html_value"
|
|
value={@value}
|
|
phx-debounce="blur"
|
|
phx-target={@myself}
|
|
min={@attrs.min}
|
|
max={@attrs.max}
|
|
autocomplete="off"
|
|
/>
|
|
</div>
|
|
"""
|
|
end
|
|
|
|
defp input_output(%{attrs: %{type: :color}} = assigns) do
|
|
~H"""
|
|
<div class="w-16">
|
|
<.text_field
|
|
type="color"
|
|
class="h-12"
|
|
data-el-input
|
|
id={@id}
|
|
name="html_value"
|
|
value={to_string(@value)}
|
|
phx-debounce={@attrs.debounce}
|
|
phx-target={@myself}
|
|
spellcheck="false"
|
|
autocomplete="off"
|
|
/>
|
|
</div>
|
|
"""
|
|
end
|
|
|
|
defp input_output(%{attrs: %{type: type}} = assigns)
|
|
when type in [:number, :url, :text] do
|
|
~H"""
|
|
<div class="inline-flex">
|
|
<.text_field
|
|
type={html_input_type(@attrs.type)}
|
|
data-el-input
|
|
id={@id}
|
|
name="html_value"
|
|
value={to_string(@value)}
|
|
phx-debounce={@attrs.debounce}
|
|
phx-target={@myself}
|
|
spellcheck="false"
|
|
autocomplete="off"
|
|
/>
|
|
</div>
|
|
"""
|
|
end
|
|
|
|
defp input_output(assigns) do
|
|
~H"""
|
|
<div class="text-red-600">
|
|
Unknown input type <%= @input.attrs.type %>
|
|
</div>
|
|
"""
|
|
end
|
|
|
|
attr :label, :string, required: true
|
|
attr :changed, :boolean, required: true
|
|
attr :help, :string, default: nil
|
|
|
|
defp input_label(assigns) do
|
|
~H"""
|
|
<.label help={@help}>
|
|
<div class="flex items-center justify-between gap-1">
|
|
<span><%= @label %></span>
|
|
<span :if={@changed} class="cursor-pointer tooltip top" data-tooltip="This input has changed.">
|
|
<.remix_icon icon="error-warning-line text-gray-500" />
|
|
</span>
|
|
</div>
|
|
</.label>
|
|
"""
|
|
end
|
|
|
|
defp html_input_type(:number), do: "number"
|
|
defp html_input_type(:url), do: "url"
|
|
defp html_input_type(:text), do: "text"
|
|
|
|
@impl true
|
|
def handle_event("change", %{"html_value" => html_value}, socket) do
|
|
case parse(html_value, socket.assigns.input.attrs) do
|
|
{:ok, value} ->
|
|
{:noreply, handle_change(socket, value)}
|
|
|
|
:error ->
|
|
# Force the current value
|
|
{:noreply, update(socket, :counter, &(&1 + 1))}
|
|
end
|
|
end
|
|
|
|
def handle_event("submit", %{"html_value" => html_value}, socket) do
|
|
case parse(html_value, socket.assigns.input.attrs) do
|
|
{:ok, value} ->
|
|
socket = handle_change(socket, value)
|
|
send(self(), {:queue_bound_cells_evaluation, socket.assigns.input.id})
|
|
{:noreply, socket}
|
|
|
|
:error ->
|
|
{:noreply, socket}
|
|
end
|
|
end
|
|
|
|
defp handle_change(socket, value) do
|
|
prev_value = socket.assigns.value
|
|
|
|
socket = assign(socket, value: value)
|
|
|
|
if value != prev_value do
|
|
report_change(socket)
|
|
end
|
|
|
|
socket
|
|
end
|
|
|
|
defp report_change(%{assigns: assigns} = socket) do
|
|
send(self(), {:set_input_values, [{assigns.input.id, assigns.value}], assigns.local})
|
|
|
|
unless assigns.local do
|
|
report_event(socket, assigns.value)
|
|
end
|
|
end
|
|
|
|
defp parse(html_value, %{type: :text}) do
|
|
{:ok, html_value}
|
|
end
|
|
|
|
defp parse(html_value, %{type: :textarea}) do
|
|
# The browser may normalize newlines to \r\n, but we prefer just \n
|
|
value = String.replace(html_value, "\r\n", "\n")
|
|
{:ok, value}
|
|
end
|
|
|
|
defp parse(html_value, %{type: :password}) do
|
|
{:ok, html_value}
|
|
end
|
|
|
|
defp parse(html_value, %{type: :number}) do
|
|
if html_value == "" do
|
|
{:ok, nil}
|
|
else
|
|
case Integer.parse(html_value) do
|
|
{number, ""} ->
|
|
{:ok, number}
|
|
|
|
_ ->
|
|
{number, ""} = Float.parse(html_value)
|
|
{:ok, number}
|
|
end
|
|
end
|
|
end
|
|
|
|
defp parse(html_value, %{type: :url}) do
|
|
cond do
|
|
html_value == "" -> {:ok, nil}
|
|
Livebook.Utils.valid_url?(html_value) -> {:ok, html_value}
|
|
true -> :error
|
|
end
|
|
end
|
|
|
|
defp parse(html_value, %{type: :select, options: options}) do
|
|
selected_idx = String.to_integer(html_value)
|
|
|
|
options
|
|
|> Enum.with_index()
|
|
|> Enum.find_value(fn {{key, _label}, idx} ->
|
|
idx == selected_idx && {:ok, key}
|
|
end)
|
|
end
|
|
|
|
defp parse(html_value, %{type: :checkbox}) do
|
|
{:ok, html_value == "true"}
|
|
end
|
|
|
|
defp parse(html_value, %{type: :range}) do
|
|
{number, ""} = Float.parse(html_value)
|
|
{:ok, number}
|
|
end
|
|
|
|
defp parse(html_value, %{type: :color}) do
|
|
{:ok, html_value}
|
|
end
|
|
|
|
defp parse(html_value, %{type: :utc_datetime} = attrs) do
|
|
if html_value do
|
|
with {:ok, datetime} <- NaiveDateTime.from_iso8601(html_value),
|
|
datetime <- truncate_datetime(datetime),
|
|
true <- in_range?(datetime, attrs.min, attrs.max) do
|
|
{:ok, datetime}
|
|
else
|
|
_ -> :error
|
|
end
|
|
else
|
|
{:ok, nil}
|
|
end
|
|
end
|
|
|
|
defp parse(html_value, %{type: :utc_time} = attrs) do
|
|
if html_value do
|
|
with {:ok, time} <- Time.from_iso8601(html_value),
|
|
time <- truncate_time(time),
|
|
true <- in_range?(time, attrs.min, attrs.max) do
|
|
{:ok, time}
|
|
else
|
|
_ -> :error
|
|
end
|
|
else
|
|
{:ok, nil}
|
|
end
|
|
end
|
|
|
|
defp parse(html_value, %{type: :date} = attrs) do
|
|
if html_value == "" do
|
|
{:ok, nil}
|
|
else
|
|
with {:ok, date} <- Date.from_iso8601(html_value),
|
|
true <- in_range?(date, attrs.min, attrs.max) do
|
|
{:ok, date}
|
|
else
|
|
_ -> :error
|
|
end
|
|
end
|
|
end
|
|
|
|
defp truncate_datetime(datetime) do
|
|
datetime
|
|
|> NaiveDateTime.truncate(:second)
|
|
|> Map.replace!(:second, 0)
|
|
end
|
|
|
|
defp truncate_time(time) do
|
|
time
|
|
|> Time.truncate(:second)
|
|
|> Map.replace!(:second, 0)
|
|
end
|
|
|
|
defp in_range?(%struct{} = datetime, min, max)
|
|
when struct in [NaiveDateTime, Time, Date] do
|
|
(min == nil or struct.compare(datetime, min) != :lt) and
|
|
(max == nil or struct.compare(datetime, max) != :gt)
|
|
end
|
|
|
|
defp report_event(socket, value) do
|
|
topic = socket.assigns.input.ref
|
|
event = %{value: value, origin: socket.assigns.client_id, type: :change}
|
|
send(socket.assigns.input.destination, {:event, topic, event})
|
|
end
|
|
end
|