Add support for input properties (#440)

This commit is contained in:
Jonatan Kłosko 2021-07-08 11:35:09 +02:00 committed by GitHub
parent 73845e4f6a
commit 7203813f8d
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
11 changed files with 347 additions and 101 deletions

View file

@ -79,28 +79,27 @@
@apply py-0 w-16; @apply py-0 w-16;
} }
.input[type="range"] {
@apply outline-none appearance-none bg-gray-200 border-none;
padding: 0;
height: 15px;
}
.input[type="range"]::-webkit-slider-thumb {
width: 22px;
height: 22px;
@apply appearance-none border-transparent bg-blue-600 hover:bg-blue-700 cursor-pointer rounded-xl;
}
.input[type="range"]::-moz-range-thumb {
width: 22px;
height: 22px;
@apply appearance-none border-transparent bg-blue-600 hover:bg-blue-700 cursor-pointer rounded-xl;
}
.input--error { .input--error {
@apply bg-red-50 border-red-600 text-red-600; @apply bg-red-50 border-red-600 text-red-600;
} }
.input-range {
height: 8px;
@apply appearance-none bg-gray-200 rounded-lg;
}
.input-range::-webkit-slider-thumb {
width: 20px;
height: 20px;
@apply appearance-none border-transparent bg-blue-600 hover:bg-blue-700 cursor-pointer rounded-xl;
}
.input-range::-moz-range-thumb {
width: 20px;
height: 20px;
@apply appearance-none border-transparent bg-blue-600 hover:bg-blue-700 cursor-pointer rounded-xl;
}
.input-label { .input-label {
@apply mb-0.5 text-sm text-gray-800 font-medium; @apply mb-0.5 text-sm text-gray-800 font-medium;
} }

View file

@ -59,7 +59,7 @@ defmodule Livebook.LiveMarkdown.Export do
name: cell.name, name: cell.name,
value: value value: value
} }
|> put_truthy(reactive: cell.reactive) |> put_unless_implicit(reactive: cell.reactive, props: cell.props)
|> Jason.encode!() |> Jason.encode!()
"<!-- livebook:#{json} -->" "<!-- livebook:#{json} -->"
@ -135,12 +135,12 @@ defmodule Livebook.LiveMarkdown.Export do
String.duplicate("`", max_streak + 1) String.duplicate("`", max_streak + 1)
end end
defp put_truthy(map, entries) do defp put_unless_implicit(map, entries) do
Enum.reduce(entries, map, fn {key, value}, map -> Enum.reduce(entries, map, fn {key, value}, map ->
if value do if value in [false, %{}] do
Map.put(map, key, value)
else
map map
else
Map.put(map, key, value)
end end
end) end)
end end

View file

@ -195,17 +195,8 @@ defmodule Livebook.LiveMarkdown.Import do
defp build_notebook([{:cell, :input, data} | elems], cells, sections) do defp build_notebook([{:cell, :input, data} | elems], cells, sections) do
{metadata, elems} = grab_metadata(elems) {metadata, elems} = grab_metadata(elems)
attrs = parse_input_attrs(data)
cell = %{ cell = %{Notebook.Cell.new(:input) | metadata: metadata} |> Map.merge(attrs)
Notebook.Cell.new(:input)
| metadata: metadata,
type: data["type"] |> String.to_existing_atom(),
name: data["name"],
value: data["value"],
# Optional flags
reactive: Map.get(data, "reactive", false)
}
build_notebook(elems, [cell | cells], sections) build_notebook(elems, [cell | cells], sections)
end end
@ -246,4 +237,26 @@ defmodule Livebook.LiveMarkdown.Import do
end end
defp grab_metadata(elems), do: {%{}, elems} defp grab_metadata(elems), do: {%{}, elems}
defp parse_input_attrs(data) do
type = data["type"] |> String.to_existing_atom()
%{
type: type,
name: data["name"],
value: data["value"],
# Fields with implicit value
reactive: Map.get(data, "reactive", false),
props: data |> Map.get("props", %{}) |> parse_input_props(type)
}
end
defp parse_input_props(data, type) do
default_props = Notebook.Cell.Input.default_props(type)
Map.new(default_props, fn {key, default_value} ->
value = Map.get(data, to_string(key), default_value)
{key, value}
end)
end
end end

View file

@ -6,7 +6,7 @@ defmodule Livebook.Notebook.Cell.Input do
# It consists of an input that the user may fill # It consists of an input that the user may fill
# and then read during code evaluation. # and then read during code evaluation.
defstruct [:id, :metadata, :type, :name, :value, :reactive] defstruct [:id, :metadata, :type, :name, :value, :reactive, :props]
alias Livebook.Utils alias Livebook.Utils
alias Livebook.Notebook.Cell alias Livebook.Notebook.Cell
@ -17,10 +17,16 @@ defmodule Livebook.Notebook.Cell.Input do
type: type(), type: type(),
name: String.t(), name: String.t(),
value: String.t(), value: String.t(),
reactive: boolean() reactive: boolean(),
props: props()
} }
@type type :: :text | :url | :number | :password | :textarea | :range @type type :: :text | :url | :number | :password | :textarea | :color | :range
@typedoc """
Additional properties adjusting the given input type.
"""
@type props :: %{atom() => term()}
@doc """ @doc """
Returns an empty cell. Returns an empty cell.
@ -33,7 +39,8 @@ defmodule Livebook.Notebook.Cell.Input do
type: :text, type: :text,
name: "input", name: "input",
value: "", value: "",
reactive: false reactive: false,
props: %{}
} }
end end
@ -42,17 +49,9 @@ defmodule Livebook.Notebook.Cell.Input do
for its type. for its type.
""" """
@spec validate(t()) :: :ok | {:error, String.t()} @spec validate(t()) :: :ok | {:error, String.t()}
def validate(cell) do def validate(cell)
validate_value(cell.value, cell.type)
end
defp validate_value(_value, :text), do: :ok def validate(%{value: value, type: :url}) do
defp validate_value(_value, :password), do: :ok
defp validate_value(_value, :textarea), do: :ok
defp validate_value(value, :url) do
if Utils.valid_url?(value) do if Utils.valid_url?(value) do
:ok :ok
else else
@ -60,14 +59,14 @@ defmodule Livebook.Notebook.Cell.Input do
end end
end end
defp validate_value(value, :number) do def validate(%{value: value, type: :number}) do
case Float.parse(value) do case Float.parse(value) do
{_number, ""} -> :ok {_number, ""} -> :ok
_ -> {:error, "not a valid number"} _ -> {:error, "not a valid number"}
end end
end end
defp validate_value(value, :color) do def validate(%{value: value, type: :color}) do
if Utils.valid_hex_color?(value) do if Utils.valid_hex_color?(value) do
:ok :ok
else else
@ -75,13 +74,31 @@ defmodule Livebook.Notebook.Cell.Input do
end end
end end
defp validate_value(value, :range) do def validate(%{value: value, type: :range, props: props}) do
case Integer.parse(value) do case Float.parse(value) do
{_number, ""} -> :ok {number, ""} ->
_ -> {:error, "not a valid number"} cond do
number < props.min -> {:error, "number too small"}
number > props.max -> {:error, "number too big"}
true -> :ok
end
_ ->
{:error, "not a valid number"}
end end
end end
def validate(_cell), do: :ok
@doc """
Returns default properties for input of the given type.
"""
@spec default_props(type()) :: props()
def default_props(type)
def default_props(:range), do: %{min: 0, max: 100, step: 1}
def default_props(_type), do: %{}
@doc """ @doc """
Checks if the input changed in terms of content. Checks if the input changed in terms of content.
""" """

View file

@ -987,7 +987,8 @@ defmodule LivebookWeb.SessionLive do
case Cell.Input.validate(cell) do case Cell.Input.validate(cell) do
:ok -> nil :ok -> nil
{:error, error} -> error {:error, error} -> error
end end,
props: cell.props
} }
end end

View file

@ -115,24 +115,7 @@ defmodule LivebookWeb.SessionLive.CellComponent do
<%= @cell_view.name %> <%= @cell_view.name %>
</div> </div>
<%= if @cell_view.input_type == :textarea do %> <.cell_input cell_view={@cell_view} />
<textarea
data-element="input"
class={"input w-auto #{if(@cell_view.error, do: "input--error")}"}
name="value"
spellcheck="false"
tabindex="-1"><%= [?\n, @cell_view.value] %></textarea>
<% else %>
<input type={html_input_type(@cell_view.input_type)}
data-element="input"
class={"input w-auto #{if(@cell_view.error, do: "input--error")}"}
name="value"
value={@cell_view.value}
phx-debounce="300"
spellcheck="false"
autocomplete="off"
tabindex="-1" />
<% end %>
<%= if @cell_view.error do %> <%= if @cell_view.error do %>
<div class="input-error"> <div class="input-error">
@ -144,6 +127,58 @@ defmodule LivebookWeb.SessionLive.CellComponent do
""" """
end end
defp cell_input(%{cell_view: %{input_type: :textarea}} = assigns) do
~H"""
<textarea
data-element="input"
class={"input w-auto #{if(@cell_view.error, do: "input--error")}"}
name="value"
spellcheck="false"
tabindex="-1"><%= [?\n, @cell_view.value] %></textarea>
"""
end
defp cell_input(%{cell_view: %{input_type: :range}} = assigns) do
~H"""
<div class="flex items-center space-x-2">
<div><%= @cell_view.props.min %></div>
<input type="range"
data-element="input"
class="input-range"
name="value"
value={@cell_view.value}
phx-debounce="300"
spellcheck="false"
autocomplete="off"
tabindex="-1"
min={@cell_view.props.min}
max={@cell_view.props.max}
step={@cell_view.props.step} />
<div><%= @cell_view.props.max %></div>
</div>
"""
end
defp cell_input(assigns) do
~H"""
<input type={html_input_type(@cell_view.input_type)}
data-element="input"
class={"input w-auto #{if(@cell_view.error, do: "input--error")}"}
name="value"
value={@cell_view.value}
phx-debounce="300"
spellcheck="false"
autocomplete="off"
tabindex="-1" />
"""
end
defp html_input_type(:password), do: "password"
defp html_input_type(:number), do: "number"
defp html_input_type(:color), do: "color"
defp html_input_type(:range), do: "range"
defp html_input_type(_), do: "text"
defp cell_body(assigns) do defp cell_body(assigns) do
~H""" ~H"""
<div class="flex relative"> <div class="flex relative">
@ -433,10 +468,4 @@ defmodule LivebookWeb.SessionLive.CellComponent do
<div class="overflow-auto whitespace-pre text-red-600 tiny-scrollbar"><%= @message %></div> <div class="overflow-auto whitespace-pre text-red-600 tiny-scrollbar"><%= @message %></div>
""" """
end end
defp html_input_type(:password), do: "password"
defp html_input_type(:number), do: "number"
defp html_input_type(:color), do: "color"
defp html_input_type(:range), do: "range"
defp html_input_type(_), do: "text"
end end

View file

@ -2,6 +2,7 @@ defmodule LivebookWeb.SessionLive.InputCellSettingsComponent do
use LivebookWeb, :live_component use LivebookWeb, :live_component
alias Livebook.Session alias Livebook.Session
alias Livebook.Notebook.Cell
@impl true @impl true
def update(assigns, socket) do def update(assigns, socket) do
@ -10,9 +11,11 @@ defmodule LivebookWeb.SessionLive.InputCellSettingsComponent do
socket = socket =
socket socket
|> assign(assigns) |> assign(assigns)
|> assign_new(:name, fn -> cell.name end) |> assign(:current_type, cell.type)
|> assign_new(:type, fn -> cell.type end) |> assign_new(:attrs, fn ->
|> assign_new(:reactive, fn -> cell.reactive end) Map.take(cell, [:name, :type, :reactive, :props])
end)
|> assign_new(:valid, fn -> true end)
{:ok, socket} {:ok, socket}
end end
@ -24,26 +27,30 @@ defmodule LivebookWeb.SessionLive.InputCellSettingsComponent do
<h3 class="text-2xl font-semibold text-gray-800"> <h3 class="text-2xl font-semibold text-gray-800">
Cell settings Cell settings
</h3> </h3>
<form phx-submit="save" phx-change="validate" phx-target={@myself}> <form
phx-submit="save"
phx-change="validate"
phx-target={@myself}
spellcheck="false"
autocomplete="off">
<div class="flex flex-col space-y-6"> <div class="flex flex-col space-y-6">
<div> <div>
<div class="input-label">Type</div> <div class="input-label">Type</div>
<.select name="type" selected={@type} options={input_types()} /> <.select name="attrs[type]" selected={@attrs.type} options={input_types()} />
</div> </div>
<div> <div>
<div class="input-label">Name</div> <div class="input-label">Name</div>
<input type="text" class="input" name="name" value={@name} spellcheck="false" autocomplete="off" autofocus /> <input type="text" class="input" name="attrs[name]" value={@attrs.name} autofocus />
</div>
<div>
<.switch_checkbox
name="reactive"
label="Reactive (reevaluates dependent cells on change)"
checked={@reactive} />
</div> </div>
<.extra_fields type={@attrs.type} props={@attrs.props} />
<.switch_checkbox
name="attrs[reactive]"
label="Reactive (reevaluates dependent cells on change)"
checked={@attrs.reactive} />
</div> </div>
<div class="mt-8 flex justify-end space-x-2"> <div class="mt-8 flex justify-end space-x-2">
<%= live_patch "Cancel", to: @return_to, class: "button button-outlined-gray" %> <%= live_patch "Cancel", to: @return_to, class: "button button-outlined-gray" %>
<button class="button button-blue" type="submit" disabled={@name == ""}> <button class="button button-blue" type="submit" disabled={not @valid}>
Save Save
</button> </button>
</div> </div>
@ -52,26 +59,92 @@ defmodule LivebookWeb.SessionLive.InputCellSettingsComponent do
""" """
end end
defp extra_fields(%{type: :range} = assigns) do
~H"""
<div class="flex space-x-4">
<div class="flex-grow">
<div class="input-label">Min</div>
<input type="number" class="input" name="attrs[props][min]" value={@props.min} />
</div>
<div class="flex-grow">
<div class="input-label">Max</div>
<input type="number" class="input" name="attrs[props][max]" value={@props.max} />
</div>
<div class="flex-grow">
<div class="input-label">Step</div>
<input type="number" class="input" name="attrs[props][step]" value={@props.step} />
</div>
</div>
"""
end
defp extra_fields(assigns), do: ~H""
@impl true @impl true
def handle_event("validate", params, socket) do def handle_event("validate", params, socket) do
attrs = params_to_attrs(params) {valid?, attrs} = validate_attrs(params["attrs"], socket.assigns.attrs)
{:noreply, assign(socket, attrs)} {:noreply, socket |> assign(attrs: attrs) |> assign(:valid, valid?)}
end end
def handle_event("save", params, socket) do def handle_event("save", params, socket) do
attrs = params_to_attrs(params) {true, attrs} = validate_attrs(params["attrs"], socket.assigns.attrs)
attrs =
if attrs.type != socket.assigns.current_type do
Map.put(attrs, :value, default_value(attrs.type, attrs.props))
else
attrs
end
Session.set_cell_attributes(socket.assigns.session_id, socket.assigns.cell.id, attrs) Session.set_cell_attributes(socket.assigns.session_id, socket.assigns.cell.id, attrs)
{:noreply, push_patch(socket, to: socket.assigns.return_to)} {:noreply, push_patch(socket, to: socket.assigns.return_to)}
end end
defp params_to_attrs(params) do defp validate_attrs(data, prev_attrs) do
name = params["name"] name = data["name"]
type = params["type"] |> String.to_existing_atom() type = data["type"] |> String.to_existing_atom()
reactive = Map.has_key?(params, "reactive") reactive = Map.has_key?(data, "reactive")
%{name: name, type: type, reactive: reactive} {props_valid?, props} =
if type == prev_attrs.type do
data |> Map.get("props", %{}) |> validate_props(type)
else
{true, Cell.Input.default_props(type)}
end
valid? = name != "" and props_valid?
{valid?, %{name: name, type: type, reactive: reactive, props: props}}
end end
defp validate_props(data, :range) do
min = parse_number(data["min"])
max = parse_number(data["max"])
step = parse_number(data["step"])
valid? = min != nil and max != nil and step != nil and min < max and step > 0
data = %{min: min, max: max, step: step}
{valid?, data}
end
defp validate_props(_data, _type) do
{true, %{}}
end
defp parse_number(string) do
case Float.parse(string) do
{number, _} ->
integer = round(number)
if integer == number, do: integer, else: number
:error ->
nil
end
end
defp default_value(:color, _props), do: "#3E64FF"
defp default_value(:range, %{min: min}), do: to_string(min)
defp default_value(_type, _props), do: ""
defp input_types do defp input_types do
[ [
color: "Color", color: "Color",

View file

@ -60,6 +60,13 @@ defmodule Livebook.LiveMarkdown.ExportTest do
source: """ source: """
IO.gets("length: ") IO.gets("length: ")
""" """
},
%{
Notebook.Cell.new(:input)
| type: :range,
name: "length",
value: "100",
props: %{min: 50, max: 150, step: 2}
} }
] ]
} }
@ -98,6 +105,8 @@ defmodule Livebook.LiveMarkdown.ExportTest do
```elixir ```elixir
IO.gets("length: ") IO.gets("length: ")
``` ```
<!-- livebook:{"livebook_object":"cell_input","name":"length","props":{"max":150,"min":50,"step":2},"type":"range","value":"100"} -->
""" """
document = Export.notebook_to_markdown(notebook) document = Export.notebook_to_markdown(notebook)
@ -384,7 +393,7 @@ defmodule Livebook.LiveMarkdown.ExportTest do
assert expected_document == document assert expected_document == document
end end
test "save password as empty string" do test "saves password as empty string" do
notebook = %{ notebook = %{
Notebook.new() Notebook.new()
| name: "My Notebook", | name: "My Notebook",

View file

@ -38,6 +38,8 @@ defmodule Livebook.LiveMarkdown.ImportTest do
```elixir ```elixir
IO.gets("length: ") IO.gets("length: ")
``` ```
<!-- livebook:{"livebook_object":"cell_input","name":"length","props":{"max":150,"min":50,"step":2},"type":"range","value":"100"} -->
""" """
{notebook, []} = Import.notebook_from_markdown(markdown) {notebook, []} = Import.notebook_from_markdown(markdown)
@ -92,6 +94,13 @@ defmodule Livebook.LiveMarkdown.ImportTest do
source: """ source: """
IO.gets("length: ")\ IO.gets("length: ")\
""" """
},
%Cell.Input{
metadata: %{},
type: :range,
name: "length",
value: "100",
props: %{min: 50, max: 150, step: 2}
} }
] ]
} }
@ -442,4 +451,33 @@ defmodule Livebook.LiveMarkdown.ImportTest do
] ]
} = notebook } = notebook
end end
test "sets default input types props if not provided" do
markdown = """
# My Notebook
## Section 1
<!-- livebook:{"livebook_object":"cell_input","name":"length","props":{"extra":100,"max":150},"type":"range","value":"100"} -->
"""
{notebook, []} = Import.notebook_from_markdown(markdown)
expected_props = %{min: 0, max: 150, step: 1}
assert %Notebook{
name: "My Notebook",
sections: [
%Notebook.Section{
name: "Section 1",
cells: [
%Cell.Input{
type: :range,
props: ^expected_props
}
]
}
]
} = notebook
end
end end

View file

@ -45,6 +45,28 @@ defmodule Livebook.Notebook.Cell.InputText do
input = %{Input.new() | type: :number, value: "-"} input = %{Input.new() | type: :number, value: "-"}
assert Input.validate(input) == {:error, "not a valid number"} assert Input.validate(input) == {:error, "not a valid number"}
end end
test "given color input allows valid hex colors" do
input = %{Input.new() | type: :color, value: "#111111"}
assert Input.validate(input) == :ok
input = %{Input.new() | type: :color, value: "ABCDEF"}
assert Input.validate(input) == {:error, "not a valid hex color"}
end
test "given range input allows numbers in the configured range" do
input = %{Input.new() | type: :range, value: "0", props: %{min: -5, max: 5, step: 1}}
assert Input.validate(input) == :ok
input = %{Input.new() | type: :range, value: "", props: %{min: -5, max: 5, step: 1}}
assert Input.validate(input) == {:error, "not a valid number"}
input = %{Input.new() | type: :range, value: "-10", props: %{min: -5, max: 5, step: 1}}
assert Input.validate(input) == {:error, "number too small"}
input = %{Input.new() | type: :range, value: "10", props: %{min: -5, max: 5, step: 1}}
assert Input.validate(input) == {:error, "number too big"}
end
end end
describe "invalidated?/2" do describe "invalidated?/2" do

View file

@ -360,6 +360,51 @@ defmodule LivebookWeb.SessionLiveTest do
end end
end end
describe "input cell settings" do
test "setting input cell attributes updates data", %{conn: conn, session_id: session_id} do
section_id = insert_section(session_id)
cell_id = insert_input_cell(session_id, section_id)
{:ok, view, _} = live(conn, "/sessions/#{session_id}/cell-settings/#{cell_id}")
form_selector = ~s/[role="dialog"] form/
assert view
|> element(form_selector)
|> render_change(%{attrs: %{type: "range"}}) =~
~s{<div class="input-label">Min</div>}
view
|> element(form_selector)
|> render_change(%{attrs: %{name: "length"}})
view
|> element(form_selector)
|> render_change(%{attrs: %{props: %{min: "10"}}})
view
|> element(form_selector)
|> render_submit()
assert %{
notebook: %{
sections: [
%{
cells: [
%{
id: ^cell_id,
type: :range,
name: "length",
props: %{min: 10, max: 100, step: 1}
}
]
}
]
}
} = Session.get_data(session_id)
end
end
# Helpers # Helpers
defp wait_for_session_update(session_id) do defp wait_for_session_update(session_id) do