User and Hub forms with Changeset validations and input errors (#1347)

This commit is contained in:
Alexandre de Souza 2022-08-22 18:12:54 -03:00 committed by GitHub
parent 4ce7df62e4
commit 77c8804d62
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
28 changed files with 614 additions and 349 deletions

View file

@ -1,5 +1,5 @@
[ [
import_deps: [:phoenix], import_deps: [:phoenix, :ecto],
plugins: [Phoenix.LiveView.HTMLFormatter], plugins: [Phoenix.LiveView.HTMLFormatter],
inputs: ["*.{heex,ex,exs}", "{config,lib,test}/**/*.{heex,ex,exs}"] inputs: ["*.{heex,ex,exs}", "{config,lib,test}/**/*.{heex,ex,exs}"]
] ]

View file

@ -0,0 +1,70 @@
defmodule Livebook.EctoTypes.HexColor do
@moduledoc false
use Ecto.Type
def type, do: :string
def load(value), do: {:ok, value}
def dump(value), do: {:ok, value}
def cast(value) do
if valid?(value) do
{:ok, value}
else
{:error, message: "not a valid color"}
end
end
@doc """
Returns a random hex color for a user.
## Options
* `:except` - a list of colors to omit
"""
def random(opts \\ []) do
colors = [
# red
"#F87171",
# yellow
"#FBBF24",
# green
"#6EE7B7",
# blue
"#60A5FA",
# purple
"#A78BFA",
# pink
"#F472B6",
# salmon
"#FA8072",
# mat green
"#9ED9CC"
]
except = opts[:except] || []
colors = colors -- except
Enum.random(colors)
end
@doc """
Validates if the given hex color is the correct format
## Examples
iex> Livebook.EctoTypes.HexColor.valid?("#111111")
true
iex> Livebook.EctoTypes.HexColor.valid?("#ABC123")
true
iex> Livebook.EctoTypes.HexColor.valid?("ABCDEF")
false
iex> Livebook.EctoTypes.HexColor.valid?("#111")
false
"""
@spec valid?(String.t()) :: boolean()
def valid?(hex_color), do: hex_color =~ ~r/^#[0-9a-fA-F]{6}$/
end

View file

@ -1,31 +1,104 @@
defmodule Livebook.Hubs.Fly do defmodule Livebook.Hubs.Fly do
@moduledoc false @moduledoc false
defstruct [ use Ecto.Schema
:id, import Ecto.Changeset
:access_token,
:hub_name, alias Livebook.Hubs
:hub_color,
:organization_id,
:organization_type,
:organization_name,
:application_id
]
@type t :: %__MODULE__{ @type t :: %__MODULE__{
id: Livebook.Utils.id(), id: Livebook.Utils.id(),
access_token: String.t(), access_token: String.t(),
hub_name: String.t(), hub_name: String.t(),
hub_color: Livebook.Users.User.hex_color(), hub_color: String.t(),
organization_id: String.t(), organization_id: String.t(),
organization_type: String.t(), organization_type: String.t(),
organization_name: String.t(), organization_name: String.t(),
application_id: String.t() application_id: String.t()
} }
def save_fly(fly, params) do embedded_schema do
fly = %{fly | hub_name: params["hub_name"], hub_color: params["hub_color"]} field :access_token, :string
field :hub_name, :string
field :hub_color, Livebook.EctoTypes.HexColor
field :organization_id, :string
field :organization_type, :string
field :organization_name, :string
field :application_id, :string
end
Livebook.Hubs.save_hub(fly) @fields ~w(
access_token
hub_name
hub_color
organization_id
organization_name
organization_type
application_id
)a
@doc """
Returns an `%Ecto.Changeset{}` for tracking hub changes.
"""
@spec change_hub(t(), map()) :: Ecto.Changeset.t()
def change_hub(%__MODULE__{} = fly, attrs \\ %{}) do
fly
|> changeset(attrs)
|> Map.put(:action, :validate)
end
@doc """
Creates a Hub.
With success, notifies interested processes about hub metadatas data change.
Otherwise, it will return an error tuple with changeset.
"""
@spec create_hub(t(), map()) :: {:ok, t()} | {:error, Ecto.Changeset.t()}
def create_hub(%__MODULE__{} = fly, attrs) do
changeset = changeset(fly, attrs)
if Hubs.hub_exists?(fly.id) do
{:error, add_error(changeset, :application_id, "already exists")}
else
with {:ok, struct} <- apply_action(changeset, :insert) do
Hubs.save_hub(struct)
{:ok, struct}
end
end
end
@doc """
Updates a Hub.
With success, notifies interested processes about hub metadatas data change.
Otherwise, it will return an error tuple with changeset.
"""
@spec update_hub(t(), map()) :: {:ok, t()} | {:error, Ecto.Changeset.t()}
def update_hub(%__MODULE__{} = fly, attrs) do
changeset = changeset(fly, attrs)
if Hubs.hub_exists?(fly.id) do
with {:ok, struct} <- apply_action(changeset, :update) do
Hubs.save_hub(struct)
{:ok, struct}
end
else
{:error, add_error(changeset, :application_id, "does not exists")}
end
end
def changeset(fly, attrs \\ %{}) do
fly
|> cast(attrs, @fields)
|> validate_required(@fields)
|> add_id()
end
defp add_id(changeset) do
if application_id = get_field(changeset, :application_id) do
change(changeset, %{id: "fly-#{application_id}"})
else
changeset
end
end end
end end

View file

@ -3,13 +3,37 @@ defmodule Livebook.Users do
alias Livebook.Users.User alias Livebook.Users.User
@doc """
Returns an `%Ecto.Changeset{}` for tracking user changes.
"""
@spec change_user(User.t(), map()) :: Ecto.Changeset.t()
def change_user(%User{} = user, attrs \\ %{}) do
user
|> User.changeset(attrs)
|> Map.put(:action, :validate)
end
@doc """
Updates an User from given changeset.
With success, notifies interested processes about user data change.
Otherwise, it will return an error tuple with changeset.
"""
@spec update_user(Ecto.Changeset.t()) :: {:ok, User.t()} | {:error, Ecto.Changeset.t()}
def update_user(%Ecto.Changeset{} = changeset) do
with {:ok, user} <- Ecto.Changeset.apply_action(changeset, :update) do
broadcast_change(user)
{:ok, user}
end
end
@doc """ @doc """
Notifies interested processes about user data change. Notifies interested processes about user data change.
Broadcasts `{:user_change, user}` message under the `"user:{id}"` topic. Broadcasts `{:user_change, user}` message under the `"user:{id}"` topic.
""" """
@spec broadcast_change(User.t()) :: :ok @spec broadcast_change(User.t()) :: :ok
def broadcast_change(user) do def broadcast_change(%User{} = user) do
broadcast_user_message(user.id, {:user_change, user}) broadcast_user_message(user.id, {:user_change, user})
:ok :ok
end end

View file

@ -9,7 +9,8 @@ defmodule Livebook.Users.User do
# can provide data like name and cursor color # can provide data like name and cursor color
# to improve visibility during collaboration. # to improve visibility during collaboration.
defstruct [:id, :name, :hex_color] use Ecto.Schema
import Ecto.Changeset
alias Livebook.Utils alias Livebook.Utils
@ -22,6 +23,11 @@ defmodule Livebook.Users.User do
@type id :: Utils.id() @type id :: Utils.id()
@type hex_color :: String.t() @type hex_color :: String.t()
embedded_schema do
field :name, :string
field :hex_color, Livebook.EctoTypes.HexColor
end
@doc """ @doc """
Generates a new user. Generates a new user.
""" """
@ -30,79 +36,13 @@ defmodule Livebook.Users.User do
%__MODULE__{ %__MODULE__{
id: Utils.random_id(), id: Utils.random_id(),
name: nil, name: nil,
hex_color: random_hex_color() hex_color: Livebook.EctoTypes.HexColor.random()
} }
end end
@doc """ def changeset(user, attrs \\ %{}) do
Validates `attrs` and returns an updated user. user
|> cast(attrs, [:id, :name, :hex_color])
In case of validation errors `{:error, errors, user}` tuple |> validate_required([:id, :name, :hex_color])
is returned, where `user` is partially updated by using
only the valid attributes.
"""
@spec change(t(), %{binary() => any()}) ::
{:ok, t()} | {:error, list({atom(), String.t()}), t()}
def change(user, attrs \\ %{}) do
{user, []}
|> change_name(attrs)
|> change_hex_color(attrs)
|> case do
{user, []} -> {:ok, user}
{user, errors} -> {:error, errors, user}
end
end
defp change_name({user, errors}, %{"name" => ""}) do
{%{user | name: nil}, errors}
end
defp change_name({user, errors}, %{"name" => name}) do
{%{user | name: name}, errors}
end
defp change_name({user, errors}, _attrs), do: {user, errors}
defp change_hex_color({user, errors}, %{"hex_color" => hex_color}) do
if Utils.valid_hex_color?(hex_color) do
{%{user | hex_color: hex_color}, errors}
else
{user, [{:hex_color, "not a valid color"} | errors]}
end
end
defp change_hex_color({user, errors}, _attrs), do: {user, errors}
@doc """
Returns a random hex color for a user.
## Options
* `:except` - a list of colors to omit
"""
def random_hex_color(opts \\ []) do
colors = [
# red
"#F87171",
# yellow
"#FBBF24",
# green
"#6EE7B7",
# blue
"#60A5FA",
# purple
"#A78BFA",
# pink
"#F472B6",
# salmon
"#FA8072",
# mat green
"#9ED9CC"
]
except = opts[:except] || []
colors = colors -- except
Enum.random(colors)
end end
end end

View file

@ -176,26 +176,6 @@ defmodule Livebook.Utils do
uri.scheme != nil and uri.host not in [nil, ""] uri.scheme != nil and uri.host not in [nil, ""]
end end
@doc """
Validates if the given hex color is the correct format
## Examples
iex> Livebook.Utils.valid_hex_color?("#111111")
true
iex> Livebook.Utils.valid_hex_color?("#ABC123")
true
iex> Livebook.Utils.valid_hex_color?("ABCDEF")
false
iex> Livebook.Utils.valid_hex_color?("#111")
false
"""
@spec valid_hex_color?(String.t()) :: boolean()
def valid_hex_color?(hex_color), do: hex_color =~ ~r/^#[0-9a-fA-F]{6}$/
@doc ~S""" @doc ~S"""
Validates if the given string forms valid CLI flags. Validates if the given string forms valid CLI flags.

View file

@ -62,6 +62,7 @@ defmodule LivebookWeb do
# Import basic rendering functionality (render, render_layout, etc) # Import basic rendering functionality (render, render_layout, etc)
import Phoenix.View import Phoenix.View
import LivebookWeb.ErrorHelpers
alias Phoenix.LiveView.JS alias Phoenix.LiveView.JS
alias LivebookWeb.Router.Helpers, as: Routes alias LivebookWeb.Router.Helpers, as: Routes

View file

@ -33,13 +33,16 @@ defmodule LivebookWeb.UserHook do
# `user_data` from session. # `user_data` from session.
defp build_current_user(session, socket) do defp build_current_user(session, socket) do
%{"current_user_id" => current_user_id} = session %{"current_user_id" => current_user_id} = session
user = %{User.new() | id: current_user_id}
connect_params = get_connect_params(socket) || %{} connect_params = get_connect_params(socket) || %{}
user_data = connect_params["user_data"] || session["user_data"] || %{} attrs = connect_params["user_data"] || session["user_data"] || %{}
case User.change(%{User.new() | id: current_user_id}, user_data) do changeset = User.changeset(user, attrs)
case Livebook.Users.update_user(changeset) do
{:ok, user} -> user {:ok, user} -> user
{:error, _errors, user} -> user {:error, _changeset} -> user
end end
end end
end end

View file

@ -12,13 +12,19 @@ defmodule LivebookWeb.HubLive do
@impl true @impl true
def mount(_params, _session, socket) do def mount(_params, _session, socket) do
{:ok, assign(socket, selected_provider: nil, hub: nil, page_title: "Livebook - Hub")} {:ok,
assign(socket,
selected_provider: nil,
hub: nil,
page_title: "Livebook - Hub"
)}
end end
@impl true @impl true
def render(assigns) do def render(assigns) do
~H""" ~H"""
<div class="flex grow h-full"> <div class="flex grow h-full">
<.live_region role="alert" />
<SidebarHelpers.sidebar <SidebarHelpers.sidebar
socket={@socket} socket={@socket}
current_user={@current_user} current_user={@current_user}
@ -52,7 +58,11 @@ defmodule LivebookWeb.HubLive do
<.card_item id="enterprise" selected={@selected_provider} title="Livebook Enterprise"> <.card_item id="enterprise" selected={@selected_provider} title="Livebook Enterprise">
<:logo> <:logo>
<img src="/images/logo.png" class="max-h-full max-w-[75%]" alt="Fly logo" /> <img
src="/images/enterprise.png"
class="max-h-full max-w-[75%]"
alt="Livebook Enterprise logo"
/>
</:logo> </:logo>
<:headline> <:headline>
Control access, manage secrets, and deploy notebooks within your team and company. Control access, manage secrets, and deploy notebooks within your team and company.
@ -137,9 +147,4 @@ defmodule LivebookWeb.HubLive do
def handle_event("select_provider", %{"value" => service}, socket) do def handle_event("select_provider", %{"value" => service}, socket) do
{:noreply, assign(socket, selected_provider: service)} {:noreply, assign(socket, selected_provider: service)}
end end
@impl true
def handle_info({:flash_error, message}, socket) do
{:noreply, put_flash(socket, :error, message)}
end
end end

View file

@ -1,9 +1,10 @@
defmodule LivebookWeb.HubLive.FlyComponent do defmodule LivebookWeb.HubLive.FlyComponent do
use LivebookWeb, :live_component use LivebookWeb, :live_component
alias Livebook.Hubs import Ecto.Changeset, only: [get_field: 2, add_error: 3]
alias Livebook.EctoTypes.HexColor
alias Livebook.Hubs.{Fly, FlyClient} alias Livebook.Hubs.{Fly, FlyClient}
alias Livebook.Users.User
@impl true @impl true
def update(assigns, socket) do def update(assigns, socket) do
@ -21,9 +22,9 @@ defmodule LivebookWeb.HubLive.FlyComponent do
id={@id} id={@id}
class="flex flex-col space-y-4" class="flex flex-col space-y-4"
let={f} let={f}
for={:fly} for={@changeset}
phx-submit="save_hub" phx-submit="save"
phx-change="update_data" phx-change="validate"
phx-target={@myself} phx-target={@myself}
phx-debounce="blur" phx-debounce="blur"
> >
@ -35,14 +36,14 @@ defmodule LivebookWeb.HubLive.FlyComponent do
phx_change: "fetch_data", phx_change: "fetch_data",
phx_debounce: "blur", phx_debounce: "blur",
phx_target: @myself, phx_target: @myself,
value: access_token(@changeset),
disabled: @operation == :edit, disabled: @operation == :edit,
value: @data["access_token"],
class: "input w-full", class: "input w-full",
autofocus: true, autofocus: true,
spellcheck: "false", spellcheck: "false",
required: true,
autocomplete: "off" autocomplete: "off"
) %> ) %>
<%= error_tag(f, :access_token) %>
</div> </div>
<%= if length(@apps) > 0 do %> <%= if length(@apps) > 0 do %>
@ -52,11 +53,9 @@ defmodule LivebookWeb.HubLive.FlyComponent do
</h3> </h3>
<%= select(f, :application_id, @select_options, <%= select(f, :application_id, @select_options,
class: "input", class: "input",
required: true,
phx_target: @myself,
phx_change: "select_app",
disabled: @operation == :edit disabled: @operation == :edit
) %> ) %>
<%= error_tag(f, :application_id) %>
</div> </div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-3"> <div class="grid grid-cols-1 md:grid-cols-2 gap-3">
@ -64,7 +63,8 @@ defmodule LivebookWeb.HubLive.FlyComponent do
<h3 class="text-gray-800 font-semibold"> <h3 class="text-gray-800 font-semibold">
Name Name
</h3> </h3>
<%= text_input(f, :hub_name, value: @data["hub_name"], class: "input", required: true) %> <%= text_input(f, :hub_name, class: "input") %>
<%= error_tag(f, :hub_name) %>
</div> </div>
<div class="flex flex-col space-y-1"> <div class="flex flex-col space-y-1">
@ -75,17 +75,14 @@ defmodule LivebookWeb.HubLive.FlyComponent do
<div class="flex space-x-4 items-center"> <div class="flex space-x-4 items-center">
<div <div
class="border-[3px] rounded-lg p-1 flex justify-center items-center" class="border-[3px] rounded-lg p-1 flex justify-center items-center"
style={"border-color: #{@data["hub_color"]}"} style={"border-color: #{hub_color(@changeset)}"}
> >
<div class="rounded h-5 w-5" style={"background-color: #{@data["hub_color"]}"}> <div class="rounded h-5 w-5" style={"background-color: #{hub_color(@changeset)}"} />
</div>
</div> </div>
<div class="relative grow"> <div class="relative grow">
<%= text_input(f, :hub_color, <%= text_input(f, :hub_color,
value: @data["hub_color"],
class: "input", class: "input",
spellcheck: "false", spellcheck: "false",
required: true,
maxlength: 7 maxlength: 7
) %> ) %>
<button <button
@ -96,12 +93,17 @@ defmodule LivebookWeb.HubLive.FlyComponent do
> >
<.remix_icon icon="refresh-line" class="text-xl" /> <.remix_icon icon="refresh-line" class="text-xl" />
</button> </button>
<%= error_tag(f, :hub_color) %>
</div> </div>
</div> </div>
</div> </div>
</div> </div>
<%= submit("Save", class: "button-base button-blue", phx_disable_with: "Saving...") %> <%= submit("Save",
class: "button-base button-blue",
phx_disable_with: "Saving...",
disabled: not @valid?
) %>
<% end %> <% end %>
</.form> </.form>
</div> </div>
@ -109,75 +111,102 @@ defmodule LivebookWeb.HubLive.FlyComponent do
end end
defp load_data(%{assigns: %{operation: :new}} = socket) do defp load_data(%{assigns: %{operation: :new}} = socket) do
assign(socket, data: %{}, select_options: [], apps: []) assign(socket,
changeset: Fly.change_hub(%Fly{}),
selected_app: nil,
select_options: [],
apps: [],
valid?: false
)
end end
defp load_data(%{assigns: %{operation: :edit, hub: fly}} = socket) do defp load_data(%{assigns: %{operation: :edit, hub: hub}} = socket) do
data = %{ {:ok, apps} = FlyClient.fetch_apps(hub.access_token)
"access_token" => fly.access_token, params = Map.from_struct(hub)
"application_id" => fly.application_id,
"hub_name" => fly.hub_name,
"hub_color" => fly.hub_color
}
{:ok, apps} = FlyClient.fetch_apps(fly.access_token) assign(socket,
opts = select_options(apps, fly.application_id) changeset: Fly.change_hub(hub, params),
selected_app: hub,
assign(socket, data: data, selected_app: fly, select_options: opts, apps: apps) select_options: select_options(apps),
apps: apps,
valid?: true
)
end end
@impl true @impl true
def handle_event("fetch_data", %{"fly" => %{"access_token" => token}}, socket) do def handle_event("fetch_data", %{"fly" => %{"access_token" => token}}, socket) do
case FlyClient.fetch_apps(token) do case FlyClient.fetch_apps(token) do
{:ok, apps} -> {:ok, apps} ->
data = %{"access_token" => token, "hub_color" => User.random_hex_color()}
opts = select_options(apps) opts = select_options(apps)
{:noreply, assign(socket, data: data, select_options: opts, apps: apps)} changeset =
socket.assigns.changeset
|> Fly.changeset(%{access_token: token, hub_color: HexColor.random()})
|> clean_errors()
{:noreply,
assign(socket,
changeset: changeset,
valid?: changeset.valid?,
select_options: opts,
apps: apps
)}
{:error, _} -> {:error, _} ->
send(self(), {:flash_error, "Invalid Access Token"}) changeset =
{:noreply, assign(socket, data: %{}, select_options: [], apps: [])} socket.assigns.changeset
|> Fly.changeset()
|> clean_errors()
|> put_action()
|> add_error(:access_token, "is invalid")
send(self(), {:flash, :error, "Failed to fetch Applications"})
{:noreply,
assign(socket,
changeset: changeset,
valid?: changeset.valid?,
select_options: [],
apps: []
)}
end end
end end
def handle_event("randomize_color", _, socket) do def handle_event("randomize_color", _, socket) do
data = Map.replace!(socket.assigns.data, "hub_color", User.random_hex_color()) changeset =
{:noreply, assign(socket, data: data)} socket.assigns.changeset
|> clean_errors()
|> Fly.change_hub(%{hub_color: HexColor.random()})
|> put_action()
{:noreply, assign(socket, changeset: changeset, valid?: changeset.valid?)}
end end
def handle_event("save_hub", %{"fly" => params}, socket) do def handle_event("save", %{"fly" => params}, socket) do
params = {:noreply, save_fly(socket, socket.assigns.operation, params)}
if socket.assigns.data do end
Map.merge(socket.assigns.data, params)
def handle_event("validate", %{"fly" => attrs}, socket) do
params = Map.merge(socket.assigns.changeset.params, attrs)
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 else
params Fly.changeset(socket.assigns.changeset, params)
end end
case socket.assigns.operation do {:noreply,
:new -> create_fly(socket, params) assign(socket,
:edit -> save_fly(socket, params) changeset: changeset,
end valid?: changeset.valid?,
end selected_app: selected_app,
select_options: opts
def handle_event("select_app", %{"fly" => %{"application_id" => app_id}}, socket) do )}
selected_app = Enum.find(socket.assigns.apps, &(&1.application_id == app_id))
opts = select_options(socket.assigns.apps, app_id)
{:noreply, assign(socket, selected_app: selected_app, select_options: opts)}
end
def handle_event("update_data", %{"fly" => data}, socket) do
data =
if socket.assigns.data do
Map.merge(socket.assigns.data, data)
else
data
end
opts = select_options(socket.assigns.apps, data["application_id"])
{:noreply, assign(socket, data: data, select_options: opts)}
end end
defp select_options(hubs, app_id \\ nil) do defp select_options(hubs, app_id \\ nil) do
@ -192,22 +221,50 @@ defmodule LivebookWeb.HubLive.FlyComponent do
] ]
end end
Enum.reverse(options ++ [disabled_option]) [disabled_option] ++ options
end end
defp create_fly(socket, params) do defp save_fly(socket, :new, params) do
if Hubs.hub_exists?(socket.assigns.selected_app.id) do case Fly.create_hub(socket.assigns.selected_app, params) do
send(self(), {:flash_error, "Application already exists"}) {:ok, fly} ->
{:noreply, assign(socket, data: params)} changeset =
else fly
save_fly(socket, params) |> Fly.change_hub(params)
|> put_action()
socket
|> assign(changeset: changeset, valid?: changeset.valid?)
|> put_flash(:success, "Hub created successfully")
|> push_redirect(to: Routes.hub_path(socket, :edit, fly.id))
{:error, changeset} ->
assign(socket, changeset: put_action(changeset), valid?: changeset.valid?)
end end
end end
defp save_fly(socket, params) do defp save_fly(socket, :edit, params) do
Fly.save_fly(socket.assigns.selected_app, params) id = socket.assigns.selected_app.id
opts = select_options(socket.assigns.apps, params["application_id"])
{:noreply, assign(socket, data: params, select_options: opts)} case Fly.update_hub(socket.assigns.selected_app, params) do
{:ok, fly} ->
changeset =
fly
|> Fly.change_hub(params)
|> put_action()
socket
|> assign(changeset: changeset, selected_app: fly, valid?: changeset.valid?)
|> put_flash(:success, "Hub updated successfully")
|> push_redirect(to: Routes.hub_path(socket, :edit, id))
{:error, changeset} ->
assign(socket, changeset: changeset, valid?: changeset.valid?)
end end
end end
defp clean_errors(changeset), do: %{changeset | errors: []}
defp put_action(changeset, action \\ :validate), do: %{changeset | action: action}
defp hub_color(changeset), do: get_field(changeset, :hub_color)
defp access_token(changeset), do: get_field(changeset, :access_token)
end

View file

@ -3,18 +3,16 @@ defmodule LivebookWeb.UserComponent do
import LivebookWeb.UserHelpers import LivebookWeb.UserHelpers
alias Livebook.Users.User alias Livebook.EctoTypes.HexColor
alias Livebook.Users
@impl true @impl true
def update(assigns, socket) do def update(assigns, socket) do
socket = assign(socket, assigns) socket = assign(socket, assigns)
user = socket.assigns.user user = socket.assigns.user
changeset = Users.change_user(user)
{:ok, assign(socket, data: user_to_data(user), valid: true, preview_user: user)} {:ok, assign(socket, changeset: changeset, valid?: changeset.valid?, user: user)}
end
defp user_to_data(user) do
%{"name" => user.name || "", "hex_color" => user.hex_color}
end end
@impl true @impl true
@ -25,11 +23,11 @@ defmodule LivebookWeb.UserComponent do
User profile User profile
</h3> </h3>
<div class="flex justify-center"> <div class="flex justify-center">
<.user_avatar user={@preview_user} class="h-20 w-20" text_class="text-3xl" /> <.user_avatar user={@user} class="h-20 w-20" text_class="text-3xl" />
</div> </div>
<.form <.form
let={f} let={f}
for={:data} for={@changeset}
phx-submit={@on_save |> JS.push("save")} phx-submit={@on_save |> JS.push("save")}
phx-change="validate" phx-change="validate"
phx-target={@myself} phx-target={@myself}
@ -39,21 +37,21 @@ defmodule LivebookWeb.UserComponent do
<div class="flex flex-col space-y-5"> <div class="flex flex-col space-y-5">
<div> <div>
<div class="input-label">Display name</div> <div class="input-label">Display name</div>
<%= text_input(f, :name, value: @data["name"], class: "input", spellcheck: "false") %> <%= text_input(f, :name, class: "input", spellcheck: "false") %>
<%= error_tag(f, :name) %>
</div> </div>
<div> <div>
<div class="input-label">Cursor color</div> <div class="input-label">Cursor color</div>
<div class="flex space-x-4 items-center"> <div class="flex space-x-4 items-center">
<div <div
class="border-[3px] rounded-lg p-1 flex justify-center items-center" class="border-[3px] rounded-lg p-1 flex justify-center items-center"
style={"border-color: #{@preview_user.hex_color}"} style={"border-color: #{hex_color(@changeset)}"}
> >
<div class="rounded h-5 w-5" style={"background-color: #{@preview_user.hex_color}"}> <div class="rounded h-5 w-5" style={"background-color: #{hex_color(@changeset)}"}>
</div> </div>
</div> </div>
<div class="relative grow"> <div class="relative grow">
<%= text_input(f, :hex_color, <%= text_input(f, :hex_color,
value: @data["hex_color"],
class: "input", class: "input",
spellcheck: "false", spellcheck: "false",
maxlength: 7 maxlength: 7
@ -66,13 +64,14 @@ defmodule LivebookWeb.UserComponent do
> >
<.remix_icon icon="refresh-line" class="text-xl" /> <.remix_icon icon="refresh-line" class="text-xl" />
</button> </button>
<%= error_tag(f, :hex_color) %>
</div> </div>
</div> </div>
</div> </div>
<button <button
class="button-base button-blue flex space-x-1 justify-center items-center" class="button-base button-blue flex space-x-1 justify-center items-center"
type="submit" type="submit"
disabled={not @valid} disabled={not @valid?}
> >
<.remix_icon icon="save-line" /> <.remix_icon icon="save-line" />
<span>Save</span> <span>Save</span>
@ -85,27 +84,34 @@ defmodule LivebookWeb.UserComponent do
@impl true @impl true
def handle_event("randomize_color", %{}, socket) do def handle_event("randomize_color", %{}, socket) do
data = %{ hex_color = HexColor.random(except: [socket.assigns.user.hex_color])
socket.assigns.data handle_event("validate", %{"user" => %{"hex_color" => hex_color}}, socket)
| "hex_color" => User.random_hex_color(except: [socket.assigns.preview_user.hex_color])
}
handle_event("validate", %{"data" => data}, socket)
end end
def handle_event("validate", %{"data" => data}, socket) do def handle_event("validate", %{"user" => params}, socket) do
{valid, user} = changeset = Users.change_user(socket.assigns.user, params)
case User.change(socket.assigns.user, data) do
{:ok, user} -> {true, user} user =
{:error, _errors, user} -> {false, user} if changeset.valid? do
Ecto.Changeset.apply_action!(changeset, :update)
else
socket.assigns.user
end end
{:noreply, assign(socket, data: data, valid: valid, preview_user: user)} {:noreply, assign(socket, changeset: changeset, valid?: changeset.valid?, user: user)}
end end
def handle_event("save", %{"data" => data}, socket) do def handle_event("save", %{"user" => params}, socket) do
{:ok, user} = User.change(socket.assigns.user, data) changeset = Users.change_user(socket.assigns.user, params)
Livebook.Users.broadcast_change(user)
{:noreply, socket} case Users.update_user(changeset) do
{:ok, user} ->
{:noreply, assign(socket, changeset: changeset, valid?: changeset.valid?, user: user)}
{:error, changeset} ->
{:noreply, assign(socket, changeset: changeset, valid?: changeset.valid?)}
end end
end end
defp hex_color(changeset), do: Ecto.Changeset.get_field(changeset, :hex_color)
end

View file

@ -12,6 +12,18 @@
</div> </div>
<% end %> <% end %>
<%= if live_flash(@flash, :success) do %>
<div
class="shadow-custom-1 max-w-2xl flex items-center space-x-3 rounded-lg px-4 py-2 border-l-4 rounded-l-none border-blue-500 bg-white text-gray-600 hover:bg-gray-50 hover:text-gray-500 cursor-pointer"
role="alert"
phx-click="lv:clear-flash"
phx-value-key="success"
>
<.remix_icon icon="checkbox-circle-fill" class="text-2xl text-blue-500" />
<span class="whitespace-pre-wrap"><%= live_flash(@flash, :success) %></span>
</div>
<% end %>
<%= if live_flash(@flash, :warning) do %> <%= if live_flash(@flash, :warning) do %>
<div <div
class="shadow-custom-1 max-w-2xl flex items-center space-x-3 rounded-lg px-4 py-2 border-l-4 rounded-l-none border-yellow-300 bg-white text-gray-600 hover:bg-gray-50 hover:text-gray-500 cursor-pointer" class="shadow-custom-1 max-w-2xl flex items-center space-x-3 rounded-lg px-4 py-2 border-l-4 rounded-l-none border-yellow-300 bg-white text-gray-600 hover:bg-gray-50 hover:text-gray-500 cursor-pointer"

View file

@ -0,0 +1,29 @@
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

@ -98,6 +98,8 @@ defmodule Livebook.MixProject do
{:earmark_parser, "~> 1.4"}, {:earmark_parser, "~> 1.4"},
{:castore, "~> 0.1.0"}, {:castore, "~> 0.1.0"},
{:aws_signature, "~> 0.3.0"}, {:aws_signature, "~> 0.3.0"},
{:ecto, "~> 3.8.4"},
{:phoenix_ecto, "~> 4.4.0"},
{:phoenix_live_reload, "~> 1.2", only: :dev}, {:phoenix_live_reload, "~> 1.2", only: :dev},
{:floki, ">= 0.27.0", only: :test}, {:floki, ">= 0.27.0", only: :test},
{:bypass, "~> 2.1", only: :test} {:bypass, "~> 2.1", only: :test}

View file

@ -5,7 +5,10 @@
"cowboy": {:hex, :cowboy, "2.9.0", "865dd8b6607e14cf03282e10e934023a1bd8be6f6bacf921a7e2a96d800cd452", [:make, :rebar3], [{:cowlib, "2.11.0", [hex: :cowlib, repo: "hexpm", optional: false]}, {:ranch, "1.8.0", [hex: :ranch, repo: "hexpm", optional: false]}], "hexpm", "2c729f934b4e1aa149aff882f57c6372c15399a20d54f65c8d67bef583021bde"}, "cowboy": {:hex, :cowboy, "2.9.0", "865dd8b6607e14cf03282e10e934023a1bd8be6f6bacf921a7e2a96d800cd452", [:make, :rebar3], [{:cowlib, "2.11.0", [hex: :cowlib, repo: "hexpm", optional: false]}, {:ranch, "1.8.0", [hex: :ranch, repo: "hexpm", optional: false]}], "hexpm", "2c729f934b4e1aa149aff882f57c6372c15399a20d54f65c8d67bef583021bde"},
"cowboy_telemetry": {:hex, :cowboy_telemetry, "0.4.0", "f239f68b588efa7707abce16a84d0d2acf3a0f50571f8bb7f56a15865aae820c", [:rebar3], [{:cowboy, "~> 2.7", [hex: :cowboy, repo: "hexpm", optional: false]}, {:telemetry, "~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "7d98bac1ee4565d31b62d59f8823dfd8356a169e7fcbb83831b8a5397404c9de"}, "cowboy_telemetry": {:hex, :cowboy_telemetry, "0.4.0", "f239f68b588efa7707abce16a84d0d2acf3a0f50571f8bb7f56a15865aae820c", [:rebar3], [{:cowboy, "~> 2.7", [hex: :cowboy, repo: "hexpm", optional: false]}, {:telemetry, "~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "7d98bac1ee4565d31b62d59f8823dfd8356a169e7fcbb83831b8a5397404c9de"},
"cowlib": {:hex, :cowlib, "2.11.0", "0b9ff9c346629256c42ebe1eeb769a83c6cb771a6ee5960bd110ab0b9b872063", [:make, :rebar3], [], "hexpm", "2b3e9da0b21c4565751a6d4901c20d1b4cc25cbb7fd50d91d2ab6dd287bc86a9"}, "cowlib": {:hex, :cowlib, "2.11.0", "0b9ff9c346629256c42ebe1eeb769a83c6cb771a6ee5960bd110ab0b9b872063", [:make, :rebar3], [], "hexpm", "2b3e9da0b21c4565751a6d4901c20d1b4cc25cbb7fd50d91d2ab6dd287bc86a9"},
"decimal": {:hex, :decimal, "2.0.0", "a78296e617b0f5dd4c6caf57c714431347912ffb1d0842e998e9792b5642d697", [:mix], [], "hexpm", "34666e9c55dea81013e77d9d87370fe6cb6291d1ef32f46a1600230b1d44f577"},
"earmark_parser": {:hex, :earmark_parser, "1.4.26", "f4291134583f373c7d8755566122908eb9662df4c4b63caa66a0eabe06569b0a", [:mix], [], "hexpm", "48d460899f8a0c52c5470676611c01f64f3337bad0b26ddab43648428d94aabc"}, "earmark_parser": {:hex, :earmark_parser, "1.4.26", "f4291134583f373c7d8755566122908eb9662df4c4b63caa66a0eabe06569b0a", [:mix], [], "hexpm", "48d460899f8a0c52c5470676611c01f64f3337bad0b26ddab43648428d94aabc"},
"ecto": {:hex, :ecto, "3.8.4", "e06b8b87e62b27fea17fd2ff6041572ddd10339fd16cdf58446e402c6c90a74b", [:mix], [{:decimal, "~> 1.6 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "f9244288b8d42db40515463a008cf3f4e0e564bb9c249fe87bf28a6d79fe82d4"},
"ex_machina": {:hex, :ex_machina, "2.7.0", "b792cc3127fd0680fecdb6299235b4727a4944a09ff0fa904cc639272cd92dc7", [:mix], [{:ecto, "~> 2.2 or ~> 3.0", [hex: :ecto, repo: "hexpm", optional: true]}, {:ecto_sql, "~> 3.0", [hex: :ecto_sql, repo: "hexpm", optional: true]}], "hexpm", "419aa7a39bde11894c87a615c4ecaa52d8f107bbdd81d810465186f783245bf8"},
"file_system": {:hex, :file_system, "0.2.10", "fb082005a9cd1711c05b5248710f8826b02d7d1784e7c3451f9c1231d4fc162d", [:mix], [], "hexpm", "41195edbfb562a593726eda3b3e8b103a309b733ad25f3d642ba49696bf715dc"}, "file_system": {:hex, :file_system, "0.2.10", "fb082005a9cd1711c05b5248710f8826b02d7d1784e7c3451f9c1231d4fc162d", [:mix], [], "hexpm", "41195edbfb562a593726eda3b3e8b103a309b733ad25f3d642ba49696bf715dc"},
"floki": {:hex, :floki, "0.33.1", "f20f1eb471e726342b45ccb68edb9486729e7df94da403936ea94a794f072781", [:mix], [{:html_entities, "~> 0.5.0", [hex: :html_entities, repo: "hexpm", optional: false]}], "hexpm", "461035fd125f13fdf30f243c85a0b1e50afbec876cbf1ceefe6fddd2e6d712c6"}, "floki": {:hex, :floki, "0.33.1", "f20f1eb471e726342b45ccb68edb9486729e7df94da403936ea94a794f072781", [:mix], [{:html_entities, "~> 0.5.0", [hex: :html_entities, repo: "hexpm", optional: false]}], "hexpm", "461035fd125f13fdf30f243c85a0b1e50afbec876cbf1ceefe6fddd2e6d712c6"},
"html_entities": {:hex, :html_entities, "0.5.2", "9e47e70598da7de2a9ff6af8758399251db6dbb7eebe2b013f2bbd2515895c3c", [:mix], [], "hexpm", "c53ba390403485615623b9531e97696f076ed415e8d8058b1dbaa28181f4fdcc"}, "html_entities": {:hex, :html_entities, "0.5.2", "9e47e70598da7de2a9ff6af8758399251db6dbb7eebe2b013f2bbd2515895c3c", [:mix], [], "hexpm", "c53ba390403485615623b9531e97696f076ed415e8d8058b1dbaa28181f4fdcc"},
@ -13,6 +16,7 @@
"libpe": {:hex, :libpe, "1.1.2", "16337b414c690e0ee9c49fe917b059622f001c399303102b98900c05c229cd9a", [:mix], [], "hexpm", "31df0639fafb603b20078c8db9596c8984f35a151c64ec2e483d9136ff9f428c"}, "libpe": {:hex, :libpe, "1.1.2", "16337b414c690e0ee9c49fe917b059622f001c399303102b98900c05c229cd9a", [:mix], [], "hexpm", "31df0639fafb603b20078c8db9596c8984f35a151c64ec2e483d9136ff9f428c"},
"mime": {:hex, :mime, "1.6.0", "dabde576a497cef4bbdd60aceee8160e02a6c89250d6c0b29e56c0dfb00db3d2", [:mix], [], "hexpm", "31a1a8613f8321143dde1dafc36006a17d28d02bdfecb9e95a880fa7aabd19a7"}, "mime": {:hex, :mime, "1.6.0", "dabde576a497cef4bbdd60aceee8160e02a6c89250d6c0b29e56c0dfb00db3d2", [:mix], [], "hexpm", "31a1a8613f8321143dde1dafc36006a17d28d02bdfecb9e95a880fa7aabd19a7"},
"phoenix": {:hex, :phoenix, "1.6.11", "29f3c0fd12fa1fc4d4b05e341578e55bc78d96ea83a022587a7e276884d397e4", [:mix], [{:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:phoenix_pubsub, "~> 2.0", [hex: :phoenix_pubsub, repo: "hexpm", optional: false]}, {:phoenix_view, "~> 1.0", [hex: :phoenix_view, repo: "hexpm", optional: false]}, {:plug, "~> 1.10", [hex: :plug, repo: "hexpm", optional: false]}, {:plug_cowboy, "~> 2.2", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:plug_crypto, "~> 1.2", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "1664e34f80c25ea4918fbadd957f491225ef601c0e00b4e644b1a772864bfbc2"}, "phoenix": {:hex, :phoenix, "1.6.11", "29f3c0fd12fa1fc4d4b05e341578e55bc78d96ea83a022587a7e276884d397e4", [:mix], [{:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:phoenix_pubsub, "~> 2.0", [hex: :phoenix_pubsub, repo: "hexpm", optional: false]}, {:phoenix_view, "~> 1.0", [hex: :phoenix_view, repo: "hexpm", optional: false]}, {:plug, "~> 1.10", [hex: :plug, repo: "hexpm", optional: false]}, {:plug_cowboy, "~> 2.2", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:plug_crypto, "~> 1.2", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "1664e34f80c25ea4918fbadd957f491225ef601c0e00b4e644b1a772864bfbc2"},
"phoenix_ecto": {:hex, :phoenix_ecto, "4.4.0", "0672ed4e4808b3fbed494dded89958e22fb882de47a97634c0b13e7b0b5f7720", [:mix], [{:ecto, "~> 3.3", [hex: :ecto, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 2.14.2 or ~> 3.0", [hex: :phoenix_html, repo: "hexpm", optional: true]}, {:plug, "~> 1.9", [hex: :plug, repo: "hexpm", optional: false]}], "hexpm", "09864e558ed31ee00bd48fcc1d4fc58ae9678c9e81649075431e69dbabb43cc1"},
"phoenix_html": {:hex, :phoenix_html, "3.2.0", "1c1219d4b6cb22ac72f12f73dc5fad6c7563104d083f711c3fcd8551a1f4ae11", [:mix], [{:plug, "~> 1.5", [hex: :plug, repo: "hexpm", optional: true]}], "hexpm", "36ec97ba56d25c0136ef1992c37957e4246b649d620958a1f9fa86165f8bc54f"}, "phoenix_html": {:hex, :phoenix_html, "3.2.0", "1c1219d4b6cb22ac72f12f73dc5fad6c7563104d083f711c3fcd8551a1f4ae11", [:mix], [{:plug, "~> 1.5", [hex: :plug, repo: "hexpm", optional: true]}], "hexpm", "36ec97ba56d25c0136ef1992c37957e4246b649d620958a1f9fa86165f8bc54f"},
"phoenix_live_dashboard": {:hex, :phoenix_live_dashboard, "0.6.5", "1495bb014be12c9a9252eca04b9af54246f6b5c1e4cd1f30210cd00ec540cf8e", [:mix], [{:ecto, "~> 3.6.2 or ~> 3.7", [hex: :ecto, repo: "hexpm", optional: true]}, {:ecto_mysql_extras, "~> 0.3", [hex: :ecto_mysql_extras, repo: "hexpm", optional: true]}, {:ecto_psql_extras, "~> 0.7", [hex: :ecto_psql_extras, repo: "hexpm", optional: true]}, {:mime, "~> 1.6 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:phoenix_live_view, "~> 0.17.7", [hex: :phoenix_live_view, repo: "hexpm", optional: false]}, {:telemetry_metrics, "~> 0.6.0", [hex: :telemetry_metrics, repo: "hexpm", optional: false]}], "hexpm", "ef4fa50dd78364409039c99cf6f98ab5209b4c5f8796c17f4db118324f0db852"}, "phoenix_live_dashboard": {:hex, :phoenix_live_dashboard, "0.6.5", "1495bb014be12c9a9252eca04b9af54246f6b5c1e4cd1f30210cd00ec540cf8e", [:mix], [{:ecto, "~> 3.6.2 or ~> 3.7", [hex: :ecto, repo: "hexpm", optional: true]}, {:ecto_mysql_extras, "~> 0.3", [hex: :ecto_mysql_extras, repo: "hexpm", optional: true]}, {:ecto_psql_extras, "~> 0.7", [hex: :ecto_psql_extras, repo: "hexpm", optional: true]}, {:mime, "~> 1.6 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:phoenix_live_view, "~> 0.17.7", [hex: :phoenix_live_view, repo: "hexpm", optional: false]}, {:telemetry_metrics, "~> 0.6.0", [hex: :telemetry_metrics, repo: "hexpm", optional: false]}], "hexpm", "ef4fa50dd78364409039c99cf6f98ab5209b4c5f8796c17f4db118324f0db852"},
"phoenix_live_reload": {:hex, :phoenix_live_reload, "1.3.3", "3a53772a6118d5679bf50fc1670505a290e32a1d195df9e069d8c53ab040c054", [:mix], [{:file_system, "~> 0.2.1 or ~> 0.3", [hex: :file_system, repo: "hexpm", optional: false]}, {:phoenix, "~> 1.4", [hex: :phoenix, repo: "hexpm", optional: false]}], "hexpm", "766796676e5f558dbae5d1bdb066849673e956005e3730dfd5affd7a6da4abac"}, "phoenix_live_reload": {:hex, :phoenix_live_reload, "1.3.3", "3a53772a6118d5679bf50fc1670505a290e32a1d195df9e069d8c53ab040c054", [:mix], [{:file_system, "~> 0.2.1 or ~> 0.3", [hex: :file_system, repo: "hexpm", optional: false]}, {:phoenix, "~> 1.4", [hex: :phoenix, repo: "hexpm", optional: false]}], "hexpm", "766796676e5f558dbae5d1bdb066849673e956005e3730dfd5affd7a6da4abac"},

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

View file

@ -0,0 +1,4 @@
defmodule Livebook.EctoTypes.HexColorTest do
use ExUnit.Case, async: true
doctest Livebook.EctoTypes.HexColor
end

View file

@ -1,13 +1,11 @@
defmodule Livebook.Hubs.ProviderTest do defmodule Livebook.Hubs.ProviderTest do
use ExUnit.Case use Livebook.DataCase
import Livebook.Fixtures
alias Livebook.Hubs.{Fly, Metadata, Provider} alias Livebook.Hubs.{Fly, Metadata, Provider}
describe "Fly" do describe "Fly" do
test "normalize/1" do test "normalize/1" do
fly = fly_fixture() fly = build(:fly)
assert Provider.normalize(fly) == %Metadata{ assert Provider.normalize(fly) == %Metadata{
id: fly.id, id: fly.id,
@ -18,7 +16,7 @@ defmodule Livebook.Hubs.ProviderTest do
end end
test "load/2" do test "load/2" do
fly = fly_fixture() fly = build(:fly)
fields = Map.from_struct(fly) fields = Map.from_struct(fly)
assert Provider.load(%Fly{}, fields) == fly assert Provider.load(%Fly{}, fields) == fly

View file

@ -1,7 +1,5 @@
defmodule Livebook.HubsTest do defmodule Livebook.HubsTest do
use ExUnit.Case use Livebook.DataCase
import Livebook.Fixtures
alias Livebook.Hubs alias Livebook.Hubs
@ -12,7 +10,7 @@ defmodule Livebook.HubsTest do
end end
test "fetch_hubs/0 returns a list of persisted hubs" do test "fetch_hubs/0 returns a list of persisted hubs" do
fly = create_fly("fly-baz") fly = insert_hub(:fly, id: "fly-baz")
assert Hubs.fetch_hubs() == [fly] assert Hubs.fetch_hubs() == [fly]
Hubs.delete_hub("fly-baz") Hubs.delete_hub("fly-baz")
@ -20,7 +18,7 @@ defmodule Livebook.HubsTest do
end end
test "fetch_metadata/0 returns a list of persisted hubs normalized" do test "fetch_metadata/0 returns a list of persisted hubs normalized" do
fly = create_fly("fly-livebook") fly = insert_hub(:fly, id: "fly-livebook")
assert Hubs.fetch_metadatas() == [ assert Hubs.fetch_metadatas() == [
%Hubs.Metadata{ %Hubs.Metadata{
@ -42,26 +40,26 @@ defmodule Livebook.HubsTest do
Hubs.fetch_hub!("fly-foo") Hubs.fetch_hub!("fly-foo")
end end
fly = create_fly("fly-foo") fly = insert_hub(:fly, id: "fly-foo")
assert Hubs.fetch_hub!("fly-foo") == fly assert Hubs.fetch_hub!("fly-foo") == fly
end end
test "hub_exists?/1" do test "hub_exists?/1" do
refute Hubs.hub_exists?("fly-bar") refute Hubs.hub_exists?("fly-bar")
create_fly("fly-bar") insert_hub(:fly, id: "fly-bar")
assert Hubs.hub_exists?("fly-bar") assert Hubs.hub_exists?("fly-bar")
end end
test "save_hub/1 persists hub" do test "save_hub/1 persists hub" do
fly = fly_fixture(id: "fly-foo") fly = build(:fly, id: "fly-foo")
Hubs.save_hub(fly) Hubs.save_hub(fly)
assert Hubs.fetch_hub!("fly-foo") == fly assert Hubs.fetch_hub!("fly-foo") == fly
end end
test "save_hub/1 updates hub" do test "save_hub/1 updates hub" do
fly = create_fly("fly-foo2") fly = insert_hub(:fly, id: "fly-foo2")
Hubs.save_hub(%{fly | hub_color: "#FFFFFF"}) Hubs.save_hub(%{fly | hub_color: "#FFFFFF"})
refute Hubs.fetch_hub!("fly-foo2") == fly refute Hubs.fetch_hub!("fly-foo2") == fly

View file

@ -1,34 +1,35 @@
defmodule Livebook.Users.UserTest do defmodule Livebook.Users.UserTest do
use ExUnit.Case, async: true use Livebook.DataCase, async: true
alias Livebook.Users.User alias Livebook.Users.User
describe "change/2" do describe "change/2" do
test "given valid attributes returns and updated user" do test "given valid attributes returns and updated user" do
user = User.new() user = build(:user)
attrs = %{"name" => "Jake Peralta", "hex_color" => "#000000"} attrs = %{"name" => "Jake Peralta", "hex_color" => "#000000"}
assert {:ok, %User{name: "Jake Peralta", hex_color: "#000000"}} = User.change(user, attrs) changeset = User.changeset(user, attrs)
assert changeset.valid?
assert get_field(changeset, :name) == "Jake Peralta"
assert get_field(changeset, :hex_color) == "#000000"
end end
test "given empty name sets name to nil" do test "given empty name returns an error" do
user = User.new() user = build(:user)
attrs = %{"name" => ""} attrs = %{"name" => ""}
assert {:ok, %User{name: nil}} = User.change(user, attrs) changeset = User.changeset(user, attrs)
refute changeset.valid?
assert "can't be blank" in errors_on(changeset).name
end end
test "given invalid color returns an error" do test "given invalid color returns an error" do
user = User.new() user = build(:user)
attrs = %{"hex_color" => "#invalid"} attrs = %{"hex_color" => "#invalid"}
assert {:error, [{:hex_color, "not a valid color"}], _user} = User.change(user, attrs) changeset = User.changeset(user, attrs)
end
test "given invalid attribute partially updates the user" do refute changeset.valid?
user = User.new() assert "not a valid color" in errors_on(changeset).hex_color
current_hex_color = user.hex_color
attrs = %{"hex_color" => "#invalid", "name" => "Jake Peralta"}
assert {:error, _errors, %User{name: "Jake Peralta", hex_color: ^current_hex_color}} =
User.change(user, attrs)
end end
end end
end end

View file

@ -1,7 +1,4 @@
defmodule Livebook.UtilsTest do defmodule Livebook.UtilsTest do
use ExUnit.Case, async: true use ExUnit.Case, async: true
doctest Livebook.Utils
alias Livebook.Utils
doctest Utils
end end

View file

@ -1,7 +1,6 @@
defmodule LivebookWeb.HomeLiveTest do defmodule LivebookWeb.HomeLiveTest do
use LivebookWeb.ConnCase, async: true use LivebookWeb.ConnCase, async: true
import Livebook.Fixtures
import Phoenix.LiveViewTest import Phoenix.LiveViewTest
alias Livebook.{Sessions, Session} alias Livebook.{Sessions, Session}
@ -248,7 +247,7 @@ defmodule LivebookWeb.HomeLiveTest do
end end
test "render persisted hubs", %{conn: conn} do test "render persisted hubs", %{conn: conn} do
fly = create_fly("fly-foo-bar-id") fly = insert_hub(:fly, id: "fly-foo-bar-id")
{:ok, _view, html} = live(conn, "/") {:ok, _view, html} = live(conn, "/")
assert html =~ "HUBS" assert html =~ "HUBS"
@ -417,11 +416,17 @@ defmodule LivebookWeb.HomeLiveTest do
test "handles user profile update", %{conn: conn} do test "handles user profile update", %{conn: conn} do
{:ok, view, _} = live(conn, "/") {:ok, view, _} = live(conn, "/")
data = %{user: %{name: "Jake Peralta", hex_color: "#123456"}}
view view
|> element("#user_form") |> element("#user_form")
|> render_submit(%{data: %{hex_color: "#123456"}}) |> render_change(data)
view
|> element("#user_form")
|> render_submit(data)
assert render(view) =~ "Jake Peralta"
assert render(view) =~ "#123456" assert render(view) =~ "#123456"
end end

View file

@ -1,7 +1,6 @@
defmodule LivebookWeb.HubLiveTest do defmodule LivebookWeb.HubLiveTest do
use LivebookWeb.ConnCase, async: true use LivebookWeb.ConnCase, async: true
import Livebook.Fixtures
import Phoenix.LiveViewTest import Phoenix.LiveViewTest
alias Livebook.Hubs alias Livebook.Hubs
@ -25,38 +24,38 @@ defmodule LivebookWeb.HubLiveTest do
{:ok, view, _html} = live(conn, "/hub") {:ok, view, _html} = live(conn, "/hub")
# renders the second step
assert view assert view
|> element("#fly") |> element("#fly")
|> render_click() =~ "2. Configure your Hub" |> render_click() =~ "2. Configure your Hub"
# triggers the access_access_token field change
# and shows the fly's third step
assert view assert view
|> element(~s/input[name="fly[access_token]"]/) |> element(~s/input[name="fly[access_token]"]/)
|> render_change(%{"fly" => %{"access_token" => "dummy access token"}}) =~ |> render_change(%{"fly" => %{"access_token" => "dummy access token"}}) =~
~s(<option value="123456789">Foo Bar - 123456789</option>) ~s(<option value="123456789">Foo Bar - 123456789</option>)
# triggers the application_id field change attrs = %{
# and assigns `selected_app` to socket
view
|> element(~s/select[name="fly[application_id]"]/)
|> render_change(%{"fly" => %{"application_id" => "123456789"}})
# sends the save_hub event to backend
# and checks the new hub on sidebar
refute view
|> element("#fly-form")
|> render_submit(%{
"fly" => %{
"access_token" => "dummy access token", "access_token" => "dummy access token",
"application_id" => "123456789", "application_id" => "123456789",
"hub_name" => "My Foo Hub", "hub_name" => "My Foo Hub",
"hub_color" => "#FF00FF" "hub_color" => "#FF00FF"
} }
}) =~ "Application already exists"
# and checks the new hub on sidebar view
|> element("#fly-form")
|> render_change(%{"fly" => attrs})
refute view
|> element("#fly-form .invalid-feedback")
|> has_element?()
assert {:ok, view, _html} =
view
|> element("#fly-form")
|> render_submit(%{"fly" => attrs})
|> follow_redirect(conn)
assert render(view) =~ "Hub created successfully"
assert view assert view
|> element("#hubs") |> element("#hubs")
|> render() =~ ~s/style="color: #FF00FF"/ |> render() =~ ~s/style="color: #FF00FF"/
@ -72,30 +71,38 @@ defmodule LivebookWeb.HubLiveTest do
test "updates fly", %{conn: conn} do test "updates fly", %{conn: conn} do
fly_app_bypass("987654321") fly_app_bypass("987654321")
fly = create_fly("fly-987654321", %{application_id: "987654321"}) fly = insert_hub(:fly, id: "fly-987654321", application_id: "987654321")
{:ok, view, _html} = live(conn, "/hub/fly-987654321") {:ok, view, _html} = live(conn, "/hub/fly-987654321")
# renders the second step
assert render(view) =~ "2. Configure your Hub" assert render(view) =~ "2. Configure your Hub"
assert render(view) =~ assert render(view) =~
~s(<option selected="selected" value="987654321">Foo Bar - 987654321</option>) ~s(<option selected="selected" value="987654321">Foo Bar - 987654321</option>)
# sends the save_hub event to backend attrs = %{
# and checks the new hub on sidebar
view
|> element("#fly-form")
|> render_submit(%{
"fly" => %{
"access_token" => "dummy access token", "access_token" => "dummy access token",
"application_id" => "987654321", "application_id" => "987654321",
"hub_name" => "Personal Hub", "hub_name" => "Personal Hub",
"hub_color" => "#FF00FF" "hub_color" => "#FF00FF"
} }
})
# and checks the new hub on sidebar view
|> element("#fly-form")
|> render_change(%{"fly" => attrs})
refute view
|> element("#fly-form .invalid-feedback")
|> has_element?()
assert {:ok, view, _html} =
view
|> element("#fly-form")
|> render_submit(%{"fly" => attrs})
|> follow_redirect(conn)
assert render(view) =~ "Hub updated successfully"
assert view assert view
|> element("#hubs") |> element("#hubs")
|> render() =~ ~s/style="color: #FF00FF"/ |> render() =~ ~s/style="color: #FF00FF"/
@ -112,45 +119,39 @@ defmodule LivebookWeb.HubLiveTest do
end end
test "fails to create existing hub", %{conn: conn} do test "fails to create existing hub", %{conn: conn} do
fly = create_fly("fly-foo", %{application_id: "foo"}) fly = insert_hub(:fly, id: "fly-foo", application_id: "foo")
fly_app_bypass("foo") fly_app_bypass("foo")
{:ok, view, _html} = live(conn, "/hub") {:ok, view, _html} = live(conn, "/hub")
# renders the second step
assert view assert view
|> element("#fly") |> element("#fly")
|> render_click() =~ "2. Configure your Hub" |> render_click() =~ "2. Configure your Hub"
# triggers the access_token field change
# and shows the fly's third step
assert view assert view
|> element(~s/input[name="fly[access_token]"]/) |> element(~s/input[name="fly[access_token]"]/)
|> render_change(%{"fly" => %{"access_token" => "dummy access token"}}) =~ |> render_change(%{"fly" => %{"access_token" => "dummy access token"}}) =~
~s(<option value="foo">Foo Bar - foo</option>) ~s(<option value="foo">Foo Bar - foo</option>)
# triggers the application_id field change attrs = %{
# and assigns `selected_app` to socket
view
|> element(~s/select[name="fly[application_id]"]/)
|> render_change(%{"fly" => %{"application_id" => "foo"}})
# sends the save_hub event to backend
# and checks the new hub on sidebar
view
|> element("#fly-form")
|> render_submit(%{
"fly" => %{
"access_token" => "dummy access token", "access_token" => "dummy access token",
"application_id" => "foo", "application_id" => "foo",
"hub_name" => "My Foo Hub", "hub_name" => "My Foo Hub",
"hub_color" => "#FF00FF" "hub_color" => "#FF00FF"
} }
})
assert render(view) =~ "Application already exists" view
|> element("#fly-form")
|> render_change(%{"fly" => attrs})
refute view
|> element("#fly-form .invalid-feedback")
|> has_element?()
assert view
|> element("#fly-form")
|> render_submit(%{"fly" => attrs}) =~ "already exists"
# and checks the hub didn't change on sidebar
assert view assert view
|> element("#hubs") |> element("#hubs")
|> render() =~ ~s/style="color: #{fly.hub_color}"/ |> render() =~ ~s/style="color: #{fly.hub_color}"/

View file

@ -5,7 +5,6 @@ defmodule LivebookWeb.SessionLiveTest do
alias Livebook.{Sessions, Session, Runtime, Users, FileSystem} alias Livebook.{Sessions, Session, Runtime, Users, FileSystem}
alias Livebook.Notebook.Cell alias Livebook.Notebook.Cell
alias Livebook.Users.User
setup do setup do
{:ok, session} = Sessions.create_session(notebook: Livebook.Notebook.new()) {:ok, session} = Sessions.create_session(notebook: Livebook.Notebook.new())
@ -577,7 +576,7 @@ defmodule LivebookWeb.SessionLiveTest do
describe "connected users" do describe "connected users" do
test "lists connected users", %{conn: conn, session: session} do test "lists connected users", %{conn: conn, session: session} do
user1 = create_user_with_name("Jake Peralta") user1 = build(:user, name: "Jake Peralta")
client_pid = client_pid =
spawn_link(fn -> spawn_link(fn ->
@ -601,7 +600,7 @@ defmodule LivebookWeb.SessionLiveTest do
Session.subscribe(session.id) Session.subscribe(session.id)
user1 = create_user_with_name("Jake Peralta") user1 = build(:user, name: "Jake Peralta")
client_pid = client_pid =
spawn_link(fn -> spawn_link(fn ->
@ -622,7 +621,7 @@ defmodule LivebookWeb.SessionLiveTest do
test "updates users list whenever a user changes his data", test "updates users list whenever a user changes his data",
%{conn: conn, session: session} do %{conn: conn, session: session} do
user1 = create_user_with_name("Jake Peralta") user1 = build(:user, name: "Jake Peralta")
client_pid = client_pid =
spawn_link(fn -> spawn_link(fn ->
@ -959,11 +958,6 @@ defmodule LivebookWeb.SessionLiveTest do
cell_id cell_id
end end
defp create_user_with_name(name) do
{:ok, user} = User.new() |> User.change(%{"name" => name})
user
end
defp url(port), do: "http://localhost:#{port}" defp url(port), do: "http://localhost:#{port}"
defp close_session_by_id(session_id) do defp close_session_by_id(session_id) do

View file

@ -6,6 +6,7 @@ defmodule LivebookWeb.ConnCase do
# Import conveniences for testing with connections # Import conveniences for testing with connections
import Plug.Conn import Plug.Conn
import Phoenix.ConnTest import Phoenix.ConnTest
import Livebook.Factory
import LivebookWeb.ConnCase import LivebookWeb.ConnCase
alias LivebookWeb.Router.Helpers, as: Routes alias LivebookWeb.Router.Helpers, as: Routes

44
test/support/data_case.ex Normal file
View file

@ -0,0 +1,44 @@
defmodule Livebook.DataCase do
@moduledoc """
This module defines the setup for tests requiring
access to the application's data layer.
You may define functions here to be used as helpers in
your tests.
Finally, if the test case interacts with the database,
we enable the SQL sandbox, so changes done to the database
are reverted at the end of every test. If you are using
PostgreSQL, you can even run database tests asynchronously
by setting `use Livebook.DataCase, async: true`, although
this option is not recommended for other databases.
"""
use ExUnit.CaseTemplate
using do
quote do
import Ecto
import Ecto.Changeset
import Ecto.Query
import Livebook.DataCase
import Livebook.Factory
end
end
@doc """
A helper that transforms changeset errors into a map of messages.
assert {:error, changeset} = Accounts.create_user(%{password: "short"})
assert "password is too short" in errors_on(changeset).password
assert %{password: ["password is too short"]} = errors_on(changeset)
"""
def errors_on(changeset) do
Ecto.Changeset.traverse_errors(changeset, fn {message, opts} ->
Regex.replace(~r"%{(\w+)}", message, fn _, key ->
opts |> Keyword.get(String.to_existing_atom(key), key) |> to_string()
end)
end)
end
end

43
test/support/factory.ex Normal file
View file

@ -0,0 +1,43 @@
defmodule Livebook.Factory do
@moduledoc false
def build(:user) do
%Livebook.Users.User{
id: Livebook.Utils.random_id(),
name: "Jose Valim",
hex_color: Livebook.EctoTypes.HexColor.random()
}
end
def build(:fly_metadata) do
%Livebook.Hubs.Metadata{
id: "fly-foo-bar-baz",
name: "My Personal Hub",
color: "#FF00FF",
provider: build(:fly)
}
end
def build(:fly) do
%Livebook.Hubs.Fly{
id: "fly-foo-bar-baz",
hub_name: "My Personal Hub",
hub_color: "#FF00FF",
access_token: Livebook.Utils.random_cookie(),
organization_id: Livebook.Utils.random_id(),
organization_type: "PERSONAL",
organization_name: "Foo",
application_id: "foo-bar-baz"
}
end
def build(factory_name, attrs \\ %{}) do
factory_name |> build() |> struct!(attrs)
end
def insert_hub(factory_name, attrs \\ %{}) do
factory_name
|> build(attrs)
|> Livebook.Hubs.save_hub()
end
end

View file

@ -1,27 +0,0 @@
defmodule Livebook.Fixtures do
@moduledoc false
def create_fly(id, attrs \\ %{}) do
attrs
|> fly_fixture()
|> Map.replace!(:id, id)
|> Livebook.Hubs.save_hub()
end
def fly_fixture(attrs \\ %{}) do
fly = %Livebook.Hubs.Fly{
id: "fly-foo-bar-baz",
hub_name: "My Personal Hub",
hub_color: "#FF00FF",
access_token: Livebook.Utils.random_cookie(),
organization_id: Livebook.Utils.random_id(),
organization_type: "PERSONAL",
organization_name: "Foo",
application_id: "foo-bar-baz"
}
for {key, value} <- attrs, reduce: fly do
acc -> Map.replace!(acc, key, value)
end
end
end