diff --git a/.formatter.exs b/.formatter.exs
index e945e12b9..05a9ef928 100644
--- a/.formatter.exs
+++ b/.formatter.exs
@@ -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}"]
]
diff --git a/lib/livebook/ecto_types/hex_color.ex b/lib/livebook/ecto_types/hex_color.ex
new file mode 100644
index 000000000..398ac4aed
--- /dev/null
+++ b/lib/livebook/ecto_types/hex_color.ex
@@ -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
diff --git a/lib/livebook/hubs/fly.ex b/lib/livebook/hubs/fly.ex
index 49414b19d..01d8b0f4a 100644
--- a/lib/livebook/hubs/fly.ex
+++ b/lib/livebook/hubs/fly.ex
@@ -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
diff --git a/lib/livebook/users.ex b/lib/livebook/users.ex
index 604f3e6d2..a96eee642 100644
--- a/lib/livebook/users.ex
+++ b/lib/livebook/users.ex
@@ -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
diff --git a/lib/livebook/users/user.ex b/lib/livebook/users/user.ex
index 9b1017646..4a317b620 100644
--- a/lib/livebook/users/user.ex
+++ b/lib/livebook/users/user.ex
@@ -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
diff --git a/lib/livebook/utils.ex b/lib/livebook/utils.ex
index 23b51f7dd..f50cd9634 100644
--- a/lib/livebook/utils.ex
+++ b/lib/livebook/utils.ex
@@ -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.
diff --git a/lib/livebook_web.ex b/lib/livebook_web.ex
index 752b69fd8..0dc2f8a43 100644
--- a/lib/livebook_web.ex
+++ b/lib/livebook_web.ex
@@ -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
diff --git a/lib/livebook_web/live/hooks/user_hook.ex b/lib/livebook_web/live/hooks/user_hook.ex
index 797333ed3..900d35b7d 100644
--- a/lib/livebook_web/live/hooks/user_hook.ex
+++ b/lib/livebook_web/live/hooks/user_hook.ex
@@ -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
diff --git a/lib/livebook_web/live/hub_live.ex b/lib/livebook_web/live/hub_live.ex
index a0551b0c2..846f70119 100644
--- a/lib/livebook_web/live/hub_live.ex
+++ b/lib/livebook_web/live/hub_live.ex
@@ -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"""
- <.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" />
<.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
Display name
- <%= text_input(f, :name, value: @data["name"], class: "input", spellcheck: "false") %>
+ <%= text_input(f, :name, class: "input", spellcheck: "false") %>
+ <%= error_tag(f, :name) %>
Cursor color
-
<%= 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" />
+ <%= error_tag(f, :hex_color) %>
<% end %>
+ <%= if live_flash(@flash, :success) do %>
+
+ <.remix_icon icon="checkbox-circle-fill" class="text-2xl text-blue-500" />
+ <%= live_flash(@flash, :success) %>
+
+ <% end %>
+
<%= if live_flash(@flash, :warning) do %>
+ String.replace(acc, "%{#{key}}", fn _ -> to_string(value) end)
+ end)
+ end
+end
diff --git a/mix.exs b/mix.exs
index a061cc8be..560eacb82 100644
--- a/mix.exs
+++ b/mix.exs
@@ -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}
diff --git a/mix.lock b/mix.lock
index fb572489d..07a92815e 100644
--- a/mix.lock
+++ b/mix.lock
@@ -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"},
diff --git a/static/images/enterprise.png b/static/images/enterprise.png
new file mode 100644
index 000000000..ae2f0f657
Binary files /dev/null and b/static/images/enterprise.png differ
diff --git a/test/livebook/ecto_types/hex_color_test.exs b/test/livebook/ecto_types/hex_color_test.exs
new file mode 100644
index 000000000..1cce201b6
--- /dev/null
+++ b/test/livebook/ecto_types/hex_color_test.exs
@@ -0,0 +1,4 @@
+defmodule Livebook.EctoTypes.HexColorTest do
+ use ExUnit.Case, async: true
+ doctest Livebook.EctoTypes.HexColor
+end
diff --git a/test/livebook/hubs/provider_test.exs b/test/livebook/hubs/provider_test.exs
index 2a3c1f587..321afd4c5 100644
--- a/test/livebook/hubs/provider_test.exs
+++ b/test/livebook/hubs/provider_test.exs
@@ -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
diff --git a/test/livebook/hubs_test.exs b/test/livebook/hubs_test.exs
index 5790beb89..310c64b75 100644
--- a/test/livebook/hubs_test.exs
+++ b/test/livebook/hubs_test.exs
@@ -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
diff --git a/test/livebook/users/user_test.exs b/test/livebook/users/user_test.exs
index 2eb3358b7..8ed652ec8 100644
--- a/test/livebook/users/user_test.exs
+++ b/test/livebook/users/user_test.exs
@@ -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
diff --git a/test/livebook/utils_test.exs b/test/livebook/utils_test.exs
index 3268e56fd..14e65e987 100644
--- a/test/livebook/utils_test.exs
+++ b/test/livebook/utils_test.exs
@@ -1,7 +1,4 @@
defmodule Livebook.UtilsTest do
use ExUnit.Case, async: true
-
- alias Livebook.Utils
-
- doctest Utils
+ doctest Livebook.Utils
end
diff --git a/test/livebook_web/live/home_live_test.exs b/test/livebook_web/live/home_live_test.exs
index 4dad262e5..4c4c90beb 100644
--- a/test/livebook_web/live/home_live_test.exs
+++ b/test/livebook_web/live/home_live_test.exs
@@ -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
diff --git a/test/livebook_web/live/hub_live_test.exs b/test/livebook_web/live/hub_live_test.exs
index a0c55323c..111400b77 100644
--- a/test/livebook_web/live/hub_live_test.exs
+++ b/test/livebook_web/live/hub_live_test.exs
@@ -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()
- # 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()
- # 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()
- # 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}"/
diff --git a/test/livebook_web/live/session_live_test.exs b/test/livebook_web/live/session_live_test.exs
index fcee33173..6c5da251d 100644
--- a/test/livebook_web/live/session_live_test.exs
+++ b/test/livebook_web/live/session_live_test.exs
@@ -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
diff --git a/test/support/conn_case.ex b/test/support/conn_case.ex
index 6791440d3..cd7c610fd 100644
--- a/test/support/conn_case.ex
+++ b/test/support/conn_case.ex
@@ -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
diff --git a/test/support/data_case.ex b/test/support/data_case.ex
new file mode 100644
index 000000000..5e12c3eee
--- /dev/null
+++ b/test/support/data_case.ex
@@ -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
diff --git a/test/support/factory.ex b/test/support/factory.ex
new file mode 100644
index 000000000..6157fdcf3
--- /dev/null
+++ b/test/support/factory.ex
@@ -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
diff --git a/test/support/fixtures.ex b/test/support/fixtures.ex
deleted file mode 100644
index 0b8aaf433..000000000
--- a/test/support/fixtures.ex
+++ /dev/null
@@ -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