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],
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
@moduledoc false
defstruct [
:id,
:access_token,
:hub_name,
:hub_color,
:organization_id,
:organization_type,
:organization_name,
:application_id
]
use Ecto.Schema
import Ecto.Changeset
alias Livebook.Hubs
@type t :: %__MODULE__{
id: Livebook.Utils.id(),
access_token: String.t(),
hub_name: String.t(),
hub_color: Livebook.Users.User.hex_color(),
hub_color: String.t(),
organization_id: String.t(),
organization_type: String.t(),
organization_name: String.t(),
application_id: String.t()
}
def save_fly(fly, params) do
fly = %{fly | hub_name: params["hub_name"], hub_color: params["hub_color"]}
embedded_schema do
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

View file

@ -3,13 +3,37 @@ defmodule Livebook.Users do
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 """
Notifies interested processes about user data change.
Broadcasts `{:user_change, user}` message under the `"user:{id}"` topic.
"""
@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})
:ok
end

View file

@ -9,7 +9,8 @@ defmodule Livebook.Users.User do
# can provide data like name and cursor color
# to improve visibility during collaboration.
defstruct [:id, :name, :hex_color]
use Ecto.Schema
import Ecto.Changeset
alias Livebook.Utils
@ -22,6 +23,11 @@ defmodule Livebook.Users.User do
@type id :: Utils.id()
@type hex_color :: String.t()
embedded_schema do
field :name, :string
field :hex_color, Livebook.EctoTypes.HexColor
end
@doc """
Generates a new user.
"""
@ -30,79 +36,13 @@ defmodule Livebook.Users.User do
%__MODULE__{
id: Utils.random_id(),
name: nil,
hex_color: random_hex_color()
hex_color: Livebook.EctoTypes.HexColor.random()
}
end
@doc """
Validates `attrs` and returns an updated user.
In case of validation errors `{:error, errors, user}` tuple
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)
def changeset(user, attrs \\ %{}) do
user
|> cast(attrs, [:id, :name, :hex_color])
|> validate_required([:id, :name, :hex_color])
end
end

View file

@ -176,26 +176,6 @@ defmodule Livebook.Utils do
uri.scheme != nil and uri.host not in [nil, ""]
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"""
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 Phoenix.View
import LivebookWeb.ErrorHelpers
alias Phoenix.LiveView.JS
alias LivebookWeb.Router.Helpers, as: Routes

View file

@ -33,13 +33,16 @@ defmodule LivebookWeb.UserHook do
# `user_data` from session.
defp build_current_user(session, socket) do
%{"current_user_id" => current_user_id} = session
user = %{User.new() | id: current_user_id}
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
{:error, _errors, user} -> user
{:error, _changeset} -> user
end
end
end

View file

@ -12,13 +12,19 @@ defmodule LivebookWeb.HubLive do
@impl true
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
@impl true
def render(assigns) do
~H"""
<div class="flex grow h-full">
<.live_region role="alert" />
<SidebarHelpers.sidebar
socket={@socket}
current_user={@current_user}
@ -52,7 +58,11 @@ defmodule LivebookWeb.HubLive do
<.card_item id="enterprise" selected={@selected_provider} title="Livebook Enterprise">
<: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>
<:headline>
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
{:noreply, assign(socket, selected_provider: service)}
end
@impl true
def handle_info({:flash_error, message}, socket) do
{:noreply, put_flash(socket, :error, message)}
end
end

View file

@ -1,9 +1,10 @@
defmodule LivebookWeb.HubLive.FlyComponent do
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.Users.User
@impl true
def update(assigns, socket) do
@ -21,9 +22,9 @@ defmodule LivebookWeb.HubLive.FlyComponent do
id={@id}
class="flex flex-col space-y-4"
let={f}
for={:fly}
phx-submit="save_hub"
phx-change="update_data"
for={@changeset}
phx-submit="save"
phx-change="validate"
phx-target={@myself}
phx-debounce="blur"
>
@ -35,14 +36,14 @@ defmodule LivebookWeb.HubLive.FlyComponent do
phx_change: "fetch_data",
phx_debounce: "blur",
phx_target: @myself,
value: access_token(@changeset),
disabled: @operation == :edit,
value: @data["access_token"],
class: "input w-full",
autofocus: true,
spellcheck: "false",
required: true,
autocomplete: "off"
) %>
<%= error_tag(f, :access_token) %>
</div>
<%= if length(@apps) > 0 do %>
@ -52,11 +53,9 @@ defmodule LivebookWeb.HubLive.FlyComponent do
</h3>
<%= select(f, :application_id, @select_options,
class: "input",
required: true,
phx_target: @myself,
phx_change: "select_app",
disabled: @operation == :edit
) %>
<%= error_tag(f, :application_id) %>
</div>
<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">
Name
</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 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="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>
<div class="rounded h-5 w-5" style={"background-color: #{hub_color(@changeset)}"} />
</div>
<div class="relative grow">
<%= text_input(f, :hub_color,
value: @data["hub_color"],
class: "input",
spellcheck: "false",
required: true,
maxlength: 7
) %>
<button
@ -96,12 +93,17 @@ defmodule LivebookWeb.HubLive.FlyComponent do
>
<.remix_icon icon="refresh-line" class="text-xl" />
</button>
<%= error_tag(f, :hub_color) %>
</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 %>
</.form>
</div>
@ -109,75 +111,102 @@ defmodule LivebookWeb.HubLive.FlyComponent do
end
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
defp load_data(%{assigns: %{operation: :edit, hub: fly}} = socket) do
data = %{
"access_token" => fly.access_token,
"application_id" => fly.application_id,
"hub_name" => fly.hub_name,
"hub_color" => fly.hub_color
}
defp load_data(%{assigns: %{operation: :edit, hub: hub}} = socket) do
{:ok, apps} = FlyClient.fetch_apps(hub.access_token)
params = Map.from_struct(hub)
{:ok, apps} = FlyClient.fetch_apps(fly.access_token)
opts = select_options(apps, fly.application_id)
assign(socket, data: data, selected_app: fly, select_options: opts, apps: apps)
assign(socket,
changeset: Fly.change_hub(hub, params),
selected_app: hub,
select_options: select_options(apps),
apps: apps,
valid?: true
)
end
@impl true
def handle_event("fetch_data", %{"fly" => %{"access_token" => token}}, socket) do
case FlyClient.fetch_apps(token) do
{:ok, apps} ->
data = %{"access_token" => token, "hub_color" => User.random_hex_color()}
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, _} ->
send(self(), {:flash_error, "Invalid Access Token"})
{:noreply, assign(socket, data: %{}, select_options: [], apps: [])}
changeset =
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
def handle_event("randomize_color", _, socket) do
data = Map.replace!(socket.assigns.data, "hub_color", User.random_hex_color())
{:noreply, assign(socket, data: data)}
changeset =
socket.assigns.changeset
|> clean_errors()
|> Fly.change_hub(%{hub_color: HexColor.random()})
|> put_action()
{:noreply, assign(socket, changeset: changeset, valid?: changeset.valid?)}
end
def handle_event("save_hub", %{"fly" => params}, socket) do
params =
if socket.assigns.data do
Map.merge(socket.assigns.data, params)
def handle_event("save", %{"fly" => params}, socket) do
{:noreply, save_fly(socket, socket.assigns.operation, params)}
end
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
params
Fly.changeset(socket.assigns.changeset, params)
end
case socket.assigns.operation do
:new -> create_fly(socket, params)
:edit -> save_fly(socket, params)
end
end
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)}
{:noreply,
assign(socket,
changeset: changeset,
valid?: changeset.valid?,
selected_app: selected_app,
select_options: opts
)}
end
defp select_options(hubs, app_id \\ nil) do
@ -192,22 +221,50 @@ defmodule LivebookWeb.HubLive.FlyComponent do
]
end
Enum.reverse(options ++ [disabled_option])
[disabled_option] ++ options
end
defp create_fly(socket, params) do
if Hubs.hub_exists?(socket.assigns.selected_app.id) do
send(self(), {:flash_error, "Application already exists"})
{:noreply, assign(socket, data: params)}
else
save_fly(socket, params)
defp save_fly(socket, :new, params) do
case Fly.create_hub(socket.assigns.selected_app, params) do
{:ok, fly} ->
changeset =
fly
|> 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
defp save_fly(socket, params) do
Fly.save_fly(socket.assigns.selected_app, params)
opts = select_options(socket.assigns.apps, params["application_id"])
defp save_fly(socket, :edit, params) do
id = socket.assigns.selected_app.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
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
alias Livebook.Users.User
alias Livebook.EctoTypes.HexColor
alias Livebook.Users
@impl true
def update(assigns, socket) do
socket = assign(socket, assigns)
user = socket.assigns.user
changeset = Users.change_user(user)
{:ok, assign(socket, data: user_to_data(user), valid: true, preview_user: user)}
end
defp user_to_data(user) do
%{"name" => user.name || "", "hex_color" => user.hex_color}
{:ok, assign(socket, changeset: changeset, valid?: changeset.valid?, user: user)}
end
@impl true
@ -25,11 +23,11 @@ defmodule LivebookWeb.UserComponent do
User profile
</h3>
<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>
<.form
let={f}
for={:data}
for={@changeset}
phx-submit={@on_save |> JS.push("save")}
phx-change="validate"
phx-target={@myself}
@ -39,21 +37,21 @@ defmodule LivebookWeb.UserComponent do
<div class="flex flex-col space-y-5">
<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 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: #{@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 class="relative grow">
<%= text_input(f, :hex_color,
value: @data["hex_color"],
class: "input",
spellcheck: "false",
maxlength: 7
@ -66,13 +64,14 @@ defmodule LivebookWeb.UserComponent do
>
<.remix_icon icon="refresh-line" class="text-xl" />
</button>
<%= error_tag(f, :hex_color) %>
</div>
</div>
</div>
<button
class="button-base button-blue flex space-x-1 justify-center items-center"
type="submit"
disabled={not @valid}
disabled={not @valid?}
>
<.remix_icon icon="save-line" />
<span>Save</span>
@ -85,27 +84,34 @@ defmodule LivebookWeb.UserComponent do
@impl true
def handle_event("randomize_color", %{}, socket) do
data = %{
socket.assigns.data
| "hex_color" => User.random_hex_color(except: [socket.assigns.preview_user.hex_color])
}
handle_event("validate", %{"data" => data}, socket)
hex_color = HexColor.random(except: [socket.assigns.user.hex_color])
handle_event("validate", %{"user" => %{"hex_color" => hex_color}}, socket)
end
def handle_event("validate", %{"data" => data}, socket) do
{valid, user} =
case User.change(socket.assigns.user, data) do
{:ok, user} -> {true, user}
{:error, _errors, user} -> {false, user}
def handle_event("validate", %{"user" => params}, socket) do
changeset = Users.change_user(socket.assigns.user, params)
user =
if changeset.valid? do
Ecto.Changeset.apply_action!(changeset, :update)
else
socket.assigns.user
end
{:noreply, assign(socket, data: data, valid: valid, preview_user: user)}
{:noreply, assign(socket, changeset: changeset, valid?: changeset.valid?, user: user)}
end
def handle_event("save", %{"data" => data}, socket) do
{:ok, user} = User.change(socket.assigns.user, data)
Livebook.Users.broadcast_change(user)
{:noreply, socket}
def handle_event("save", %{"user" => params}, socket) do
changeset = Users.change_user(socket.assigns.user, params)
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
defp hex_color(changeset), do: Ecto.Changeset.get_field(changeset, :hex_color)
end

View file

@ -12,6 +12,18 @@
</div>
<% 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 %>
<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"

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"},
{:castore, "~> 0.1.0"},
{:aws_signature, "~> 0.3.0"},
{:ecto, "~> 3.8.4"},
{:phoenix_ecto, "~> 4.4.0"},
{:phoenix_live_reload, "~> 1.2", only: :dev},
{:floki, ">= 0.27.0", 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_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"},
"decimal": {:hex, :decimal, "2.0.0", "a78296e617b0f5dd4c6caf57c714431347912ffb1d0842e998e9792b5642d697", [:mix], [], "hexpm", "34666e9c55dea81013e77d9d87370fe6cb6291d1ef32f46a1600230b1d44f577"},
"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"},
"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"},
@ -13,6 +16,7 @@
"libpe": {:hex, :libpe, "1.1.2", "16337b414c690e0ee9c49fe917b059622f001c399303102b98900c05c229cd9a", [:mix], [], "hexpm", "31df0639fafb603b20078c8db9596c8984f35a151c64ec2e483d9136ff9f428c"},
"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_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_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"},

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

View file

@ -1,7 +1,5 @@
defmodule Livebook.HubsTest do
use ExUnit.Case
import Livebook.Fixtures
use Livebook.DataCase
alias Livebook.Hubs
@ -12,7 +10,7 @@ defmodule Livebook.HubsTest do
end
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]
Hubs.delete_hub("fly-baz")
@ -20,7 +18,7 @@ defmodule Livebook.HubsTest do
end
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() == [
%Hubs.Metadata{
@ -42,26 +40,26 @@ defmodule Livebook.HubsTest do
Hubs.fetch_hub!("fly-foo")
end
fly = create_fly("fly-foo")
fly = insert_hub(:fly, id: "fly-foo")
assert Hubs.fetch_hub!("fly-foo") == fly
end
test "hub_exists?/1" do
refute Hubs.hub_exists?("fly-bar")
create_fly("fly-bar")
insert_hub(:fly, id: "fly-bar")
assert Hubs.hub_exists?("fly-bar")
end
test "save_hub/1 persists hub" do
fly = fly_fixture(id: "fly-foo")
fly = build(:fly, id: "fly-foo")
Hubs.save_hub(fly)
assert Hubs.fetch_hub!("fly-foo") == fly
end
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"})
refute Hubs.fetch_hub!("fly-foo2") == fly

View file

@ -1,34 +1,35 @@
defmodule Livebook.Users.UserTest do
use ExUnit.Case, async: true
use Livebook.DataCase, async: true
alias Livebook.Users.User
describe "change/2" do
test "given valid attributes returns and updated user" do
user = User.new()
user = build(:user)
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
test "given empty name sets name to nil" do
user = User.new()
test "given empty name returns an error" do
user = build(:user)
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
test "given invalid color returns an error" do
user = User.new()
user = build(:user)
attrs = %{"hex_color" => "#invalid"}
assert {:error, [{:hex_color, "not a valid color"}], _user} = User.change(user, attrs)
end
changeset = User.changeset(user, attrs)
test "given invalid attribute partially updates the user" do
user = User.new()
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)
refute changeset.valid?
assert "not a valid color" in errors_on(changeset).hex_color
end
end
end

View file

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

View file

@ -1,7 +1,6 @@
defmodule LivebookWeb.HomeLiveTest do
use LivebookWeb.ConnCase, async: true
import Livebook.Fixtures
import Phoenix.LiveViewTest
alias Livebook.{Sessions, Session}
@ -248,7 +247,7 @@ defmodule LivebookWeb.HomeLiveTest do
end
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, "/")
assert html =~ "HUBS"
@ -417,11 +416,17 @@ defmodule LivebookWeb.HomeLiveTest do
test "handles user profile update", %{conn: conn} do
{:ok, view, _} = live(conn, "/")
data = %{user: %{name: "Jake Peralta", hex_color: "#123456"}}
view
|> 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"
end

View file

@ -1,7 +1,6 @@
defmodule LivebookWeb.HubLiveTest do
use LivebookWeb.ConnCase, async: true
import Livebook.Fixtures
import Phoenix.LiveViewTest
alias Livebook.Hubs
@ -25,38 +24,38 @@ defmodule LivebookWeb.HubLiveTest do
{:ok, view, _html} = live(conn, "/hub")
# renders the second step
assert view
|> element("#fly")
|> render_click() =~ "2. Configure your Hub"
# triggers the access_access_token field change
# and shows the fly's third step
assert view
|> element(~s/input[name="fly[access_token]"]/)
|> render_change(%{"fly" => %{"access_token" => "dummy access token"}}) =~
~s(<option value="123456789">Foo Bar - 123456789</option>)
# triggers the application_id field change
# and assigns `selected_app` to socket
attrs = %{
"access_token" => "dummy access token",
"application_id" => "123456789",
"hub_name" => "My Foo Hub",
"hub_color" => "#FF00FF"
}
view
|> element(~s/select[name="fly[application_id]"]/)
|> render_change(%{"fly" => %{"application_id" => "123456789"}})
|> element("#fly-form")
|> render_change(%{"fly" => attrs})
# 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",
"application_id" => "123456789",
"hub_name" => "My Foo Hub",
"hub_color" => "#FF00FF"
}
}) =~ "Application already exists"
|> 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"
# and checks the new hub on sidebar
assert view
|> element("#hubs")
|> render() =~ ~s/style="color: #FF00FF"/
@ -72,30 +71,38 @@ defmodule LivebookWeb.HubLiveTest do
test "updates fly", %{conn: conn} do
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")
# renders the second step
assert render(view) =~ "2. Configure your Hub"
assert render(view) =~
~s(<option selected="selected" value="987654321">Foo Bar - 987654321</option>)
# sends the save_hub event to backend
# and checks the new hub on sidebar
attrs = %{
"access_token" => "dummy access token",
"application_id" => "987654321",
"hub_name" => "Personal Hub",
"hub_color" => "#FF00FF"
}
view
|> element("#fly-form")
|> render_submit(%{
"fly" => %{
"access_token" => "dummy access token",
"application_id" => "987654321",
"hub_name" => "Personal Hub",
"hub_color" => "#FF00FF"
}
})
|> 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"
# and checks the new hub on sidebar
assert view
|> element("#hubs")
|> render() =~ ~s/style="color: #FF00FF"/
@ -112,45 +119,39 @@ defmodule LivebookWeb.HubLiveTest do
end
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")
{:ok, view, _html} = live(conn, "/hub")
# renders the second step
assert view
|> element("#fly")
|> render_click() =~ "2. Configure your Hub"
# triggers the access_token field change
# and shows the fly's third step
assert view
|> element(~s/input[name="fly[access_token]"]/)
|> render_change(%{"fly" => %{"access_token" => "dummy access token"}}) =~
~s(<option value="foo">Foo Bar - foo</option>)
# triggers the application_id field change
# and assigns `selected_app` to socket
view
|> element(~s/select[name="fly[application_id]"]/)
|> render_change(%{"fly" => %{"application_id" => "foo"}})
attrs = %{
"access_token" => "dummy access token",
"application_id" => "foo",
"hub_name" => "My Foo Hub",
"hub_color" => "#FF00FF"
}
# 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",
"application_id" => "foo",
"hub_name" => "My Foo Hub",
"hub_color" => "#FF00FF"
}
})
|> render_change(%{"fly" => attrs})
assert render(view) =~ "Application already exists"
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
|> element("#hubs")
|> 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.Notebook.Cell
alias Livebook.Users.User
setup do
{:ok, session} = Sessions.create_session(notebook: Livebook.Notebook.new())
@ -577,7 +576,7 @@ defmodule LivebookWeb.SessionLiveTest do
describe "connected users" 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 =
spawn_link(fn ->
@ -601,7 +600,7 @@ defmodule LivebookWeb.SessionLiveTest do
Session.subscribe(session.id)
user1 = create_user_with_name("Jake Peralta")
user1 = build(:user, name: "Jake Peralta")
client_pid =
spawn_link(fn ->
@ -622,7 +621,7 @@ defmodule LivebookWeb.SessionLiveTest do
test "updates users list whenever a user changes his data",
%{conn: conn, session: session} do
user1 = create_user_with_name("Jake Peralta")
user1 = build(:user, name: "Jake Peralta")
client_pid =
spawn_link(fn ->
@ -959,11 +958,6 @@ defmodule LivebookWeb.SessionLiveTest do
cell_id
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 close_session_by_id(session_id) do

View file

@ -6,6 +6,7 @@ defmodule LivebookWeb.ConnCase do
# Import conveniences for testing with connections
import Plug.Conn
import Phoenix.ConnTest
import Livebook.Factory
import LivebookWeb.ConnCase
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