mirror of
https://github.com/livebook-dev/livebook.git
synced 2024-09-20 10:05:57 +08:00
User and Hub forms with Changeset validations and input errors (#1347)
This commit is contained in:
parent
4ce7df62e4
commit
77c8804d62
|
@ -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}"]
|
||||
]
|
||||
|
|
70
lib/livebook/ecto_types/hex_color.ex
Normal file
70
lib/livebook/ecto_types/hex_color.ex
Normal 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
|
|
@ -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
|
||||
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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.
|
||||
|
||||
|
|
|
@ -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
|
||||
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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"
|
||||
|
|
29
lib/livebook_web/views/error_helpers.ex
Normal file
29
lib/livebook_web/views/error_helpers.ex
Normal 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
|
2
mix.exs
2
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}
|
||||
|
|
4
mix.lock
4
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"},
|
||||
|
|
BIN
static/images/enterprise.png
Normal file
BIN
static/images/enterprise.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 24 KiB |
4
test/livebook/ecto_types/hex_color_test.exs
Normal file
4
test/livebook/ecto_types/hex_color_test.exs
Normal file
|
@ -0,0 +1,4 @@
|
|||
defmodule Livebook.EctoTypes.HexColorTest do
|
||||
use ExUnit.Case, async: true
|
||||
doctest Livebook.EctoTypes.HexColor
|
||||
end
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -1,7 +1,4 @@
|
|||
defmodule Livebook.UtilsTest do
|
||||
use ExUnit.Case, async: true
|
||||
|
||||
alias Livebook.Utils
|
||||
|
||||
doctest Utils
|
||||
doctest Livebook.Utils
|
||||
end
|
||||
|
|
|
@ -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
|
||||
|
||||
|
|
|
@ -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}"/
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
44
test/support/data_case.ex
Normal 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
43
test/support/factory.ex
Normal 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
|
|
@ -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
|
Loading…
Reference in a new issue