Unify several forms in the application (#1392)

* Define .input_wrapper and .hex_color_input components

* Define Tailwind variants from Phoenix v1.6 to help styling

* Make sure single-action modals redirect on save

* Add pill to session secrets in the "Secrets" session sidebar

* Fix alignments of "Users" in the session sidebar
This commit is contained in:
José Valim 2022-09-05 22:59:13 +01:00 committed by GitHub
parent 357ba7320a
commit be862173a3
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
16 changed files with 183 additions and 210 deletions

View file

@ -83,7 +83,7 @@
/* Form fields */
.input {
@apply w-full px-3 py-2 bg-gray-50 text-sm border border-gray-200 rounded-lg placeholder-gray-400 text-gray-600;
@apply w-full px-3 py-2 bg-gray-50 text-sm border border-gray-200 rounded-lg placeholder-gray-400 text-gray-600 phx-form-error:border-red-300;
}
.input[type="color"] {

View file

@ -1,3 +1,5 @@
const plugin = require("tailwindcss/plugin")
module.exports = {
content: [
"../lib/**/*.ex",
@ -101,5 +103,15 @@ module.exports = {
},
},
},
plugins: [],
plugins: [
plugin(({ addVariant }) => {
addVariant("phx-loading", [".phx-loading&", ".phx-loading &"]);
addVariant("phx-connected", [".phx-connected&", ".phx-connected &"]);
addVariant("phx-error", [".phx-error&", ".phx-error &"]);
addVariant("phx-form-error", [":not(.phx-no-feedback).show-errors &"]);
addVariant("phx-click-loading", [".phx-click-loading&", ".phx-click-loading &"]);
addVariant("phx-submit-loading", [".phx-submit-loading&", ".phx-submit-loading &"]);
addVariant("phx-change-loading", [".phx-change-loading&", ".phx-change-loading &"]);
})
],
};

View file

@ -93,7 +93,7 @@ defmodule Livebook.Hubs.Fly do
end
end
def changeset(fly, attrs \\ %{}) do
defp changeset(fly, attrs) do
fly
|> cast(attrs, @fields)
|> validate_required(@fields)

View file

@ -62,12 +62,12 @@ defmodule LivebookWeb do
# Import basic rendering functionality (render, render_layout, etc)
import Phoenix.View
import LivebookWeb.ErrorHelpers
alias Phoenix.LiveView.JS
alias LivebookWeb.Router.Helpers, as: Routes
# Custom helpers
import LivebookWeb.Helpers
import LivebookWeb.FormHelpers
import LivebookWeb.LiveHelpers
end
end

View file

@ -0,0 +1,68 @@
defmodule LivebookWeb.FormHelpers do
@moduledoc """
Conveniences for translating and building error messages.
"""
use Phoenix.Component
import Phoenix.HTML.Form
import LivebookWeb.LiveHelpers
@doc """
A wrapper for inputs with conveniences.
"""
def input_wrapper(assigns) do
assigns = assign_new(assigns, :class, fn -> [] end)
~H"""
<div
phx-feedback-for={input_name(@form, @field)}
class={[@class, if(@form.errors[@field], do: "show-errors", else: "")]}
>
<%= render_slot(@inner_block) %>
<%= for error <- Keyword.get_values(@form.errors, @field) do %>
<span class="hidden text-red-600 text-sm phx-form-error:block">
<%= translate_error(error) %>
</span>
<% end %>
</div>
"""
end
@doc """
Hex color input.
"""
def hex_color_input(assigns) do
~H"""
<div class="flex space-x-4 items-center">
<div
class="border-[3px] rounded-lg p-1 flex justify-center items-center"
style={"border-color: #{input_value(@form, @field)}"}
>
<div class="rounded h-5 w-5" style={"background-color: #{input_value(@form, @field)}"}></div>
</div>
<div class="relative grow">
<%= text_input(@form, @field,
class: "input",
spellcheck: "false",
maxlength: 7
) %>
<button class="icon-button absolute right-2 top-1" type="button" phx-click={@randomize}>
<.remix_icon icon="refresh-line" class="text-xl" />
</button>
</div>
</div>
"""
end
@doc """
Translates an error message.
"""
def translate_error({msg, opts}) do
# Because the error messages we show in our forms and APIs
# are defined inside Ecto, we need to translate them dynamically.
Enum.reduce(opts, msg, fn {key, value}, acc ->
String.replace(acc, "%{#{key}}", fn _ -> to_string(value) end)
end)
end
end

View file

@ -1,8 +1,6 @@
defmodule LivebookWeb.Hub.Edit.FlyComponent do
use LivebookWeb, :live_component
import Ecto.Changeset, only: [get_field: 2]
alias Livebook.EctoTypes.HexColor
alias Livebook.Hubs.{Fly, FlyClient}
@ -63,44 +61,19 @@ defmodule LivebookWeb.Hub.Edit.FlyComponent do
phx-debounce="blur"
>
<div class="grid grid-cols-1 md:grid-cols-2 gap-3">
<div class="flex flex-col space-y-1">
<h3 class="text-gray-800 font-semibold">
Name
</h3>
<.input_wrapper form={f} field={:hub_name} class="flex flex-col space-y-1">
<div class="input-label">Name</div>
<%= text_input(f, :hub_name, class: "input") %>
<%= error_tag(f, :hub_name) %>
</div>
</.input_wrapper>
<div class="flex flex-col space-y-1">
<h3 class="text-gray-800 font-semibold">
Color
</h3>
<div class="flex space-x-4 items-center">
<div
class="border-[3px] rounded-lg p-1 flex justify-center items-center"
style={"border-color: #{hub_color(@changeset)}"}
>
<div class="rounded h-5 w-5" style={"background-color: #{hub_color(@changeset)}"} />
</div>
<div class="relative grow">
<%= text_input(f, :hub_color,
class: "input",
spellcheck: "false",
maxlength: 7
) %>
<button
class="icon-button absolute right-2 top-1"
type="button"
phx-click="randomize_color"
phx-target={@myself}
>
<.remix_icon icon="refresh-line" class="text-xl" />
</button>
<%= error_tag(f, :hub_color) %>
</div>
</div>
</div>
<.input_wrapper form={f} field={:hub_color} class="flex flex-col space-y-1">
<div class="input-label">Color</div>
<.hex_color_input
form={f}
field={:hub_color}
randomize={JS.push("randomize_color", target: @myself)}
/>
</.input_wrapper>
</div>
<%= submit("Update Hub",
@ -239,9 +212,7 @@ defmodule LivebookWeb.Hub.Edit.FlyComponent do
<%= text_input(f, :key,
value: @data["key"],
class: "input",
placeholder: "environment variable key",
autofocus: true,
aria_labelledby: "env-var-key",
spellcheck: "false"
) %>
</div>
@ -250,8 +221,6 @@ defmodule LivebookWeb.Hub.Edit.FlyComponent do
<%= text_input(f, :value,
value: @data["value"],
class: "input",
placeholder: "environment variable value",
aria_labelledby: "env-var-value",
spellcheck: "false"
) %>
</div>
@ -349,6 +318,4 @@ defmodule LivebookWeb.Hub.Edit.FlyComponent do
valid? = String.match?(attrs["key"], ~r/^\w+$/) and attrs["value"] not in ["", nil]
{:noreply, assign(socket, valid_env_var?: valid?, env_var_data: attrs)}
end
defp hub_color(changeset), do: get_field(changeset, :hub_color)
end

View file

@ -12,6 +12,7 @@ defmodule LivebookWeb.Hub.New.FlyComponent do
socket
|> assign(assigns)
|> assign(
base: %Fly{},
changeset: Fly.change_hub(%Fly{}),
selected_app: nil,
select_options: [],
@ -33,71 +34,40 @@ defmodule LivebookWeb.Hub.New.FlyComponent do
phx-target={@myself}
phx-debounce="blur"
>
<div class="flex flex-col space-y-1">
<h3 class="text-gray-800 font-semibold">
Access Token
</h3>
<.input_wrapper form={f} field={:access_token} class="flex flex-col space-y-1">
<div class="input-label">Access Token</div>
<%= password_input(f, :access_token,
phx_change: "fetch_data",
phx_debounce: "blur",
phx_target: @myself,
value: access_token(@changeset),
class: "input w-full",
class: "input w-full phx-form-error:border-red-300",
autofocus: true,
spellcheck: "false",
autocomplete: "off"
) %>
<%= error_tag(f, :access_token) %>
</div>
</.input_wrapper>
<%= if length(@apps) > 0 do %>
<div class="flex flex-col space-y-1">
<h3 class="text-gray-800 font-semibold">
Application
</h3>
<.input_wrapper form={f} field={:application_id} class="flex flex-col space-y-1">
<div class="input-label">Application</div>
<%= select(f, :application_id, @select_options, class: "input") %>
<%= error_tag(f, :application_id) %>
</div>
</.input_wrapper>
<div class="grid grid-cols-1 md:grid-cols-2 gap-3">
<div class="flex flex-col space-y-1">
<h3 class="text-gray-800 font-semibold">
Name
</h3>
<.input_wrapper form={f} field={:hub_name} class="flex flex-col space-y-1">
<div class="input-label">Name</div>
<%= text_input(f, :hub_name, class: "input") %>
<%= error_tag(f, :hub_name) %>
</div>
</.input_wrapper>
<div class="flex flex-col space-y-1">
<h3 class="text-gray-800 font-semibold">
Color
</h3>
<div class="flex space-x-4 items-center">
<div
class="border-[3px] rounded-lg p-1 flex justify-center items-center"
style={"border-color: #{hub_color(@changeset)}"}
>
<div class="rounded h-5 w-5" style={"background-color: #{hub_color(@changeset)}"} />
</div>
<div class="relative grow">
<%= text_input(f, :hub_color,
class: "input",
spellcheck: "false",
maxlength: 7
) %>
<button
class="icon-button absolute right-2 top-1"
type="button"
phx-click="randomize_color"
phx-target={@myself}
>
<.remix_icon icon="refresh-line" class="text-xl" />
</button>
<%= error_tag(f, :hub_color) %>
</div>
</div>
</div>
<.input_wrapper form={f} field={:hub_color} class="flex flex-col space-y-1">
<div class="input-label">Color</div>
<.hex_color_input
form={f}
field={:hub_color}
randomize={JS.push("randomize_color", target: @myself)}
/>
</.input_wrapper>
</div>
<%= submit("Add Hub",
@ -116,9 +86,11 @@ defmodule LivebookWeb.Hub.New.FlyComponent do
case FlyClient.fetch_apps(token) do
{:ok, apps} ->
opts = select_options(apps)
changeset = Fly.change_hub(%Fly{}, %{access_token: token, hub_color: HexColor.random()})
base = %Fly{access_token: token, hub_color: HexColor.random()}
changeset = Fly.change_hub(base)
{:noreply, assign(socket, changeset: changeset, select_options: opts, apps: apps)}
{:noreply,
assign(socket, changeset: changeset, base: base, select_options: opts, apps: apps)}
{:error, _} ->
changeset =
@ -126,7 +98,8 @@ defmodule LivebookWeb.Hub.New.FlyComponent do
|> Fly.change_hub(%{access_token: token})
|> add_error(:access_token, "is invalid")
{:noreply, assign(socket, changeset: changeset, select_options: [], apps: [])}
{:noreply,
assign(socket, changeset: changeset, base: %Fly{}, select_options: [], apps: [])}
end
end
@ -157,15 +130,7 @@ defmodule LivebookWeb.Hub.New.FlyComponent do
application_id = params["application_id"]
selected_app = Enum.find(socket.assigns.apps, &(&1.application_id == application_id))
opts = select_options(socket.assigns.apps, application_id)
changeset =
if selected_app do
Fly.change_hub(selected_app, params)
else
socket.assigns.changeset
|> Fly.changeset(params)
|> Map.replace!(:action, :validate)
end
changeset = Fly.change_hub(selected_app || socket.assigns.base, params)
{:noreply,
assign(socket, changeset: changeset, selected_app: selected_app, select_options: opts)}
@ -186,6 +151,5 @@ defmodule LivebookWeb.Hub.New.FlyComponent do
[disabled_option] ++ options
end
defp hub_color(changeset), do: get_field(changeset, :hub_color)
defp access_token(changeset), do: get_field(changeset, :access_token)
end

View file

@ -95,7 +95,7 @@ defmodule LivebookWeb.Hub.NewLive do
<%= render_slot(@logo) %>
</div>
<div class="card-item-body px-6 py-4 rounded-b-2xl grow">
<p class="text-gray-800 font-semibold cursor-pointer mt-2 text-sm text-gray-600">
<p class="text-gray-800 font-semibold cursor-pointer">
<%= @title %>
</p>

View file

@ -244,10 +244,10 @@ defmodule LivebookWeb.Output do
~H"""
<div class="-m-4 space-x-4 py-4">
<div
class="flex items-center justify-between font-editor border-b px-4 pb-4 mb-4"
class="flex items-center justify-between border-b px-4 pb-4 mb-4"
style="color: var(--ansi-color-red);"
>
<div class="flex space-x-2">
<div class="flex space-x-2 font-editor">
<.remix_icon icon="close-circle-line" />
<span>Missing secret <%= inspect(@secret_label) %></span>
</div>

View file

@ -489,7 +489,7 @@ defmodule LivebookWeb.SessionLive do
defp clients_list(assigns) do
~H"""
<div class="flex flex-col grow">
<div class="flex items-center justify-between space-x-4">
<div class="flex items-center justify-between space-x-4 -mt-1">
<h3 class="uppercase text-sm font-semibold text-gray-500">
Users
</h3>
@ -498,7 +498,7 @@ defmodule LivebookWeb.SessionLive do
<span><%= length(@data_view.clients) %> connected</span>
</span>
</div>
<div class="flex flex-col mt-4 space-y-4">
<div class="flex flex-col mt-5 space-y-4">
<%= for {client_id, user} <- @data_view.clients do %>
<div
class="flex items-center justify-between space-x-2"
@ -512,7 +512,7 @@ defmodule LivebookWeb.SessionLive do
data-el-client-link
>
<.user_avatar user={user} class="shrink-0 h-7 w-7" text_class="text-xs" />
<span><%= user.name || "Anonymous" %></span>
<span class="text-left"><%= user.name || "Anonymous" %></span>
</button>
<%= if client_id != @client_id do %>
<span
@ -551,10 +551,13 @@ defmodule LivebookWeb.SessionLive do
</h3>
<div class="flex flex-col mt-4 space-y-4">
<%= for secret <- @data_view.secrets do %>
<div class="flex items-center text-gray-500">
<span class="flex items-center space-x-1">
<div class="flex justify-between items-center text-gray-500">
<span class="break-all">
<%= secret.label %>
</span>
<span class="rounded-full bg-gray-200 px-2 text-xs text-gray-600">
Session
</span>
</div>
<% end %>
</div>
@ -749,7 +752,7 @@ defmodule LivebookWeb.SessionLive do
def handle_params(params, _url, socket)
when socket.assigns.live_action == :secrets do
{:noreply, assign(socket, prefill_secret_label: Map.get(params, "secret_label", ""))}
{:noreply, assign(socket, prefill_secret_label: params["secret_label"])}
end
def handle_params(_params, _url, socket) do

View file

@ -38,11 +38,11 @@ defmodule LivebookWeb.SessionLive.CodeCellSettingsComponent do
checked={@reevaluate_automatically}
/>
</div>
<div class="mt-8 flex justify-end space-x-2">
<%= live_patch("Cancel", to: @return_to, class: "button-base button-outlined-gray") %>
<div class="mt-8 flex justify-begin space-x-2">
<button class="button-base button-blue" type="submit">
Save
</button>
<%= live_patch("Cancel", to: @return_to, class: "button-base button-outlined-gray") %>
</div>
</form>
</div>

View file

@ -223,17 +223,7 @@ defmodule LivebookWeb.SessionLive.PersistenceLive do
Session.save_sync(assigns.session.pid)
end
running_files =
[new_attrs.file | assigns.running_files]
|> List.delete(attrs.file)
|> Enum.reject(&is_nil/1)
{:noreply,
assign(socket,
running_files: running_files,
attrs: assigns.new_attrs,
saved_file: new_attrs.file
)}
{:noreply, push_patch(socket, to: Routes.session_path(socket, :page, assigns.session.id))}
end
@impl true

View file

@ -9,7 +9,7 @@ defmodule LivebookWeb.SessionLive.SecretsComponent do
if socket.assigns[:data] do
socket
else
assign(socket, data: %{"label" => assigns.prefill_secret_label, "value" => ""})
assign(socket, data: %{"label" => assigns.prefill_secret_label || "", "value" => ""})
end
{:ok, socket}
@ -32,33 +32,31 @@ defmodule LivebookWeb.SessionLive.SecretsComponent do
phx-change="validate"
autocomplete="off"
phx-target={@myself}
errors={data_errors(@data)}
>
<div class="flex flex-col space-y-4">
<div>
<.input_wrapper form={f} field={:label}>
<div class="input-label">
Label <span class="text-xs text-gray-500">(alphanumeric and underscore)</span>
</div>
<%= text_input(f, :label,
value: @data["label"],
class: "input",
placeholder: "secret label",
autofocus: true,
aria_labelledby: "secret-label",
autofocus: !@prefill_secret_label,
spellcheck: "false"
) %>
</div>
<div>
</.input_wrapper>
<.input_wrapper form={f} field={:value}>
<div class="input-label">Value</div>
<%= text_input(f, :value,
value: @data["value"],
class: "input",
placeholder: "secret value",
aria_labelledby: "secret-value",
autofocus: !!@prefill_secret_label,
spellcheck: "false"
) %>
</div>
</.input_wrapper>
<div class="flex space-x-2">
<button class="button-base button-blue" type="submit" disabled={not data_valid?(@data)}>
<button class="button-base button-blue" type="submit" disabled={f.errors != []}>
Save
</button>
<%= live_patch("Cancel", to: @return_to, class: "button-base button-outlined-gray") %>
@ -71,16 +69,37 @@ defmodule LivebookWeb.SessionLive.SecretsComponent do
@impl true
def handle_event("save", %{"data" => data}, socket) do
secret = %{label: String.upcase(data["label"]), value: data["value"]}
Livebook.Session.put_secret(socket.assigns.session.pid, secret)
{:noreply, assign(socket, data: %{"label" => "", "value" => ""})}
if data_errors(data) == [] do
secret = %{label: String.upcase(data["label"]), value: data["value"]}
Livebook.Session.put_secret(socket.assigns.session.pid, secret)
{:noreply, push_patch(socket, to: socket.assigns.return_to)}
else
{:noreply, assign(socket, data: data)}
end
end
def handle_event("validate", %{"data" => data}, socket) do
{:noreply, assign(socket, data: data)}
end
defp data_valid?(data) do
String.match?(data["label"], ~r/^\w+$/) and data["value"] != ""
defp data_errors(data) do
Enum.flat_map(data, fn {key, value} ->
if error = data_error(key, value) do
[{String.to_existing_atom(key), {error, []}}]
else
[]
end
end)
end
defp data_error("label", value) do
cond do
String.match?(value, ~r/^\w+$/) -> nil
value == "" -> "can't be blank"
true -> "is invalid"
end
end
defp data_error("value", ""), do: "can't be blank"
defp data_error(_key, _value), do: nil
end

View file

@ -35,39 +35,18 @@ defmodule LivebookWeb.UserComponent do
phx-hook="UserForm"
>
<div class="flex flex-col space-y-5">
<div>
<.input_wrapper form={f} field={:name}>
<div class="input-label">Display name</div>
<%= text_input(f, :name, class: "input", spellcheck: "false") %>
<%= error_tag(f, :name) %>
</div>
<div>
</.input_wrapper>
<.input_wrapper form={f} field={:hex_color}>
<div class="input-label">Cursor color</div>
<div class="flex space-x-4 items-center">
<div
class="border-[3px] rounded-lg p-1 flex justify-center items-center"
style={"border-color: #{hex_color(@changeset)}"}
>
<div class="rounded h-5 w-5" style={"background-color: #{hex_color(@changeset)}"}>
</div>
</div>
<div class="relative grow">
<%= text_input(f, :hex_color,
class: "input",
spellcheck: "false",
maxlength: 7
) %>
<button
class="icon-button absolute right-2 top-1"
type="button"
phx-click="randomize_color"
phx-target={@myself}
>
<.remix_icon icon="refresh-line" class="text-xl" />
</button>
<%= error_tag(f, :hex_color) %>
</div>
</div>
</div>
<.hex_color_input
form={f}
field={:hex_color}
randomize={JS.push("randomize_color", target: @myself)}
/>
</.input_wrapper>
<button
class="button-base button-blue flex space-x-1 justify-center items-center"
type="submit"
@ -112,6 +91,4 @@ defmodule LivebookWeb.UserComponent do
{:noreply, assign(socket, changeset: changeset, valid?: changeset.valid?)}
end
end
defp hex_color(changeset), do: Ecto.Changeset.get_field(changeset, :hex_color)
end

View file

@ -1,29 +0,0 @@
defmodule LivebookWeb.ErrorHelpers do
@moduledoc """
Conveniences for translating and building error messages.
"""
use Phoenix.HTML
@doc """
Generates tag for inlined form input errors.
"""
def error_tag(form, field) do
for error <- Keyword.get_values(form.errors, field) do
content_tag(:span, translate_error(error),
class: "invalid-feedback text-red-600",
phx_feedback_for: input_name(form, field)
)
end
end
@doc """
Translates an error message.
"""
def translate_error({msg, opts}) do
# Because the error messages we show in our forms and APIs
# are defined inside Ecto, we need to translate them dynamically.
Enum.reduce(opts, msg, fn {key, value}, acc ->
String.replace(acc, "%{#{key}}", fn _ -> to_string(value) end)
end)
end
end

View file

@ -441,7 +441,6 @@ defmodule LivebookWeb.SessionLiveTest do
{:ok, view, _} = live(conn, "/sessions/#{session.id}/settings/file")
assert view = find_live_child(view, "persistence")
path = Path.join(tmp_dir, "notebook.livemd")
view
@ -460,6 +459,9 @@ defmodule LivebookWeb.SessionLiveTest do
|> element(~s{button}, "Save now")
|> render_click()
{:ok, view, _} = live(conn, "/sessions/#{session.id}/settings/file")
assert view = find_live_child(view, "persistence")
view
|> element("button", "Change file")
|> render_click()