Implement new Add Hub page (#1909)

This commit is contained in:
Alexandre de Souza 2023-05-18 13:45:32 -03:00 committed by GitHub
parent 08199336b3
commit 3a63e30e89
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
13 changed files with 514 additions and 523 deletions

View file

@ -2,7 +2,7 @@ defmodule Livebook.Hubs do
@moduledoc false
alias Livebook.Storage
alias Livebook.Hubs.{Broadcasts, Enterprise, Fly, Metadata, Personal, Provider}
alias Livebook.Hubs.{Broadcasts, Enterprise, Fly, Metadata, Personal, Provider, Team}
alias Livebook.Secrets.Secret
@namespace :hubs
@ -169,6 +169,10 @@ defmodule Livebook.Hubs do
Provider.load(%Personal{}, fields)
end
defp to_struct(%{id: "team-" <> _} = fields) do
Provider.load(%Team{}, fields)
end
@doc """
Connects to the all available and connectable hubs.

View file

@ -8,25 +8,31 @@ defmodule Livebook.Hubs.Enterprise do
@type t :: %__MODULE__{
id: String.t() | nil,
url: String.t() | nil,
token: String.t() | nil,
external_id: String.t() | nil,
org_id: pos_integer() | nil,
user_id: pos_integer() | nil,
org_key_id: pos_integer() | nil,
teams_key: String.t() | nil,
session_token: String.t() | nil,
hub_name: String.t() | nil,
hub_emoji: String.t() | nil
}
embedded_schema do
field :url, :string
field :token, :string
field :external_id, :string
field :org_id, :integer
field :user_id, :integer
field :org_key_id, :integer
field :teams_key, :string
field :session_token, :string
field :hub_name, :string
field :hub_emoji, :string
end
@fields ~w(
url
token
external_id
org_id
user_id
org_key_id
teams_key
session_token
hub_name
hub_emoji
)a
@ -63,7 +69,7 @@ defmodule Livebook.Hubs.Enterprise do
if Hubs.hub_exists?(id) do
{:error,
changeset
|> add_error(:external_id, "already exists")
|> add_error(:hub_name, "already exists")
|> Map.replace!(:action, :validate)}
else
with {:ok, struct} <- apply_action(changeset, :insert) do
@ -92,7 +98,7 @@ defmodule Livebook.Hubs.Enterprise do
else
{:error,
changeset
|> add_error(:external_id, "does not exists")
|> add_error(:hub_name, "does not exists")
|> Map.replace!(:action, :validate)}
end
end
@ -105,9 +111,9 @@ defmodule Livebook.Hubs.Enterprise do
end
defp add_id(changeset) do
case get_field(changeset, :external_id) do
case get_field(changeset, :hub_name) do
nil -> changeset
external_id -> put_change(changeset, :id, "enterprise-#{external_id}")
hub_name -> put_change(changeset, :id, "enterprise-#{hub_name}")
end
end
end
@ -119,9 +125,11 @@ defimpl Livebook.Hubs.Provider, for: Livebook.Hubs.Enterprise do
%{
enterprise
| id: fields.id,
url: fields.url,
token: fields.token,
external_id: fields.external_id,
session_token: fields.session_token,
teams_key: fields.teams_key,
org_id: fields.org_id,
user_id: fields.user_id,
org_key_id: fields.org_key_id,
hub_name: fields.hub_name,
hub_emoji: fields.hub_emoji
}

View file

@ -82,9 +82,9 @@ defmodule Livebook.Hubs.EnterpriseClient do
## GenServer callbacks
@impl true
def init(%Enterprise{url: url, token: token} = enterprise) do
headers = [{"X-Auth-Token", token}]
{:ok, pid} = ClientConnection.start_link(self(), url, headers)
def init(%Enterprise{} = enterprise) do
# TODO: Make it work with new struct and `Livebook.Teams`
{:ok, pid} = ClientConnection.start_link(self(), Livebook.Config.teams_url())
{:ok, %__MODULE__{hub: enterprise, server: pid}}
end

110
lib/livebook/hubs/team.ex Normal file
View file

@ -0,0 +1,110 @@
defmodule Livebook.Hubs.Team do
@moduledoc false
use Ecto.Schema
import Ecto.Changeset
@type t :: %__MODULE__{
id: String.t() | nil,
org_id: pos_integer() | nil,
user_id: pos_integer() | nil,
org_key_id: pos_integer() | nil,
teams_key: String.t() | nil,
session_token: String.t() | nil,
hub_name: String.t() | nil,
hub_emoji: String.t() | nil
}
embedded_schema do
field :org_id, :integer
field :user_id, :integer
field :org_key_id, :integer
field :teams_key, :string
field :session_token, :string
field :hub_name, :string
field :hub_emoji, :string
end
@fields ~w(
org_id
user_id
org_key_id
teams_key
session_token
hub_name
hub_emoji
)a
@doc """
Returns an `%Ecto.Changeset{}` for tracking hub changes.
"""
@spec change_hub(t(), map()) :: Ecto.Changeset.t()
def change_hub(%__MODULE__{} = team, attrs \\ %{}) do
changeset(team, attrs)
end
defp changeset(team, attrs) do
team
|> cast(attrs, @fields)
|> validate_required(@fields)
|> add_id()
end
defp add_id(changeset) do
if name = get_field(changeset, :hub_name) do
change(changeset, %{id: "team-#{name}"})
else
changeset
end
end
end
defimpl Livebook.Hubs.Provider, for: Livebook.Hubs.Team do
def load(team, fields) do
%{
team
| id: fields.id,
session_token: fields.session_token,
teams_key: fields.teams_key,
org_id: fields.org_id,
user_id: fields.user_id,
org_key_id: fields.org_key_id,
hub_name: fields.hub_name,
hub_emoji: fields.hub_emoji
}
end
def to_metadata(team) do
%Livebook.Hubs.Metadata{
id: team.id,
name: team.hub_name,
provider: team,
emoji: team.hub_emoji,
connected?: false
}
end
def type(_team), do: "team"
def connection_spec(_team), do: nil
def disconnect(_team), do: raise("not implemented")
def capabilities(_team), do: []
def get_secrets(_team), do: []
def create_secret(_team, _secret), do: :ok
def update_secret(_team, _secret), do: :ok
def delete_secret(_team, _secret), do: :ok
def connection_error(_team), do: raise("not implemented")
def notebook_stamp(_hub, _notebook_source, _metadata) do
:skip
end
def verify_notebook_stamp(_hub, _notebook_source, _stamp), do: raise("not implemented")
end

View file

@ -1,8 +1,12 @@
defmodule Livebook.Teams do
@moduledoc false
alias Livebook.Hubs
alias Livebook.Hubs.Team
alias Livebook.Teams.{Client, Org}
import Ecto.Changeset, only: [add_error: 3, apply_action: 2, apply_action!: 2, get_field: 2]
@doc """
Creates an Org.
@ -16,7 +20,7 @@ defmodule Livebook.Teams do
def create_org(%Org{} = org, attrs) do
changeset = Org.changeset(org, attrs)
with {:ok, %Org{} = org} <- Ecto.Changeset.apply_action(changeset, :insert),
with {:ok, %Org{} = org} <- apply_action(changeset, :insert),
{:ok, response} <- Client.create_org(org) do
{:ok, response}
else
@ -60,12 +64,45 @@ defmodule Livebook.Teams do
end
end
@doc """
Creates a Hub.
It notifies interested processes about hub metadatas data change.
"""
@spec create_hub!(map()) :: Team.t()
def create_hub!(attrs) do
changeset = Team.change_hub(%Team{}, attrs)
team = apply_action!(changeset, :insert)
Hubs.save_hub(team)
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(Team.t(), map()) :: {:ok, Team.t()} | {:error, Ecto.Changeset.t()}
def update_hub(%Team{} = team, attrs) do
changeset = Team.change_hub(team, attrs)
id = get_field(changeset, :id)
if Hubs.hub_exists?(id) do
with {:ok, struct} <- apply_action(changeset, :update) do
{:ok, Hubs.save_hub(struct)}
end
else
{:error, add_error(changeset, :hub_name, "does not exists")}
end
end
defp add_org_errors(%Ecto.Changeset{} = changeset, errors_map) do
for {key, errors} <- errors_map,
field <- String.to_atom(key),
field in Org.__schema__(:fields),
error <- errors,
reduce: changeset,
do: (acc -> Ecto.Changeset.add_error(acc, field, error))
do: (acc -> add_error(acc, field, error))
end
end

View file

@ -6,18 +6,22 @@ defmodule Livebook.Teams.Org do
@type t :: %__MODULE__{
id: pos_integer() | nil,
emoji: String.t() | nil,
name: String.t() | nil,
teams_key: String.t() | nil,
user_code: String.t() | nil
}
@primary_key {:id, :id, autogenerate: false}
embedded_schema do
field :emoji, :string
field :name, :string
field :teams_key, :string
field :user_code, :string
end
@fields ~w(id name teams_key user_code)a
@fields ~w(id emoji name teams_key user_code)a
@required_fields @fields -- ~w(id user_code)a
@doc """
Generates a new teams key.
@ -30,7 +34,7 @@ defmodule Livebook.Teams.Org do
org
|> cast(attrs, @fields)
|> generate_teams_key()
|> validate_required(@fields -- [:id])
|> validate_required(@required_fields)
end
defp generate_teams_key(changeset) do

View file

@ -0,0 +1,88 @@
defmodule LivebookWeb.Hub.Edit.TeamComponent do
use LivebookWeb, :live_component
alias Livebook.Hubs.Team
alias Livebook.Teams
alias LivebookWeb.LayoutHelpers
@impl true
def update(assigns, socket) do
changeset = Team.change_hub(assigns.hub)
{:ok,
socket
|> assign(assigns)
|> assign_form(changeset)}
end
@impl true
def render(assigns) do
~H"""
<div id={"#{@id}-component"} class="space-y-8">
<div class="flex relative">
<LayoutHelpers.title text={"#{@hub.hub_emoji} #{@hub.hub_name}"} />
<button
phx-click={JS.push("delete_hub", value: %{id: @hub.id})}
class="absolute right-0 button-base button-red"
>
Delete hub
</button>
</div>
<div class="flex flex-col space-y-10">
<div class="flex flex-col space-y-2">
<h2 class="text-xl text-gray-800 font-medium pb-2 border-b border-gray-200">
General
</h2>
<.form
:let={f}
id={@id}
class="flex flex-col mt-4 space-y-4"
for={@form}
phx-submit="save"
phx-change="validate"
phx-target={@myself}
>
<div class="grid grid-cols-1 md:grid-cols-1 gap-3">
<.emoji_field field={f[:hub_emoji]} label="Emoji" />
</div>
<button class="button-base button-blue" type="submit" phx-disable-with="Updating...">
Update Hub
</button>
</.form>
</div>
</div>
</div>
"""
end
@impl true
def handle_event("save", %{"team" => params}, socket) do
case Teams.update_hub(socket.assigns.hub, params) do
{:ok, hub} ->
{:noreply,
socket
|> put_flash(:success, "Hub updated successfully")
|> push_navigate(to: ~p"/hub/#{hub.id}")}
{:error, changeset} ->
{:noreply, assign_form(socket, changeset)}
end
end
def handle_event("validate", %{"team" => attrs}, socket) do
changeset =
socket.assigns.hub
|> Team.change_hub(attrs)
|> Map.replace!(:action, :validate)
{:noreply, assign_form(socket, changeset)}
end
defp assign_form(socket, %Ecto.Changeset{} = changeset) do
assign(socket, form: to_form(changeset))
end
end

View file

@ -70,6 +70,8 @@ defmodule LivebookWeb.Hub.EditLive do
hub={@hub}
id="enterprise-form"
/>
<% "team" -> %>
<.live_component module={LivebookWeb.Hub.Edit.TeamComponent} hub={@hub} id="team-form" />
<% end %>
</div>
</LayoutHelpers.layout>

View file

@ -1,164 +0,0 @@
defmodule LivebookWeb.Hub.New.EnterpriseComponent do
use LivebookWeb, :live_component
import Ecto.Changeset, only: [get_field: 2]
alias Livebook.Hubs.{Enterprise, EnterpriseClient}
@impl true
def update(assigns, socket) do
if connected?(socket) do
Livebook.Hubs.subscribe(:connection)
end
{:ok,
socket
|> assign(assigns)
|> assign(
base: %Enterprise{},
changeset: Enterprise.change_hub(%Enterprise{}),
pid: nil
)}
end
@impl true
def render(assigns) do
~H"""
<div>
<.form
:let={f}
id={@id}
class="flex flex-col space-y-4"
for={@changeset}
phx-submit="save"
phx-change="validate"
phx-target={@myself}
>
<div class="grid grid-cols-1 md:grid-cols-2 gap-3">
<.text_field
field={f[:url]}
label="URL"
autofocus
spellcheck="false"
autocomplete="off"
phx-debounce="blur"
/>
<.password_field
type="password"
field={f[:token]}
label="Token"
spellcheck="false"
autocomplete="off"
phx-debounce="blur"
/>
</div>
<div>
<button
id="connect"
type="button"
phx-click="connect"
phx-target={@myself}
class="button-base button-blue"
>
Connect
</button>
</div>
<%= if @pid do %>
<div class="grid grid-cols-1 md:grid-cols-1">
<.password_field type="password" field={f[:external_id]} label="ID" disabled />
</div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-3">
<.text_field field={f[:hub_name]} label="Name" readonly />
<.emoji_field field={f[:hub_emoji]} label="Emoji" />
</div>
<div>
<button
class="button-base button-blue"
type="submit"
phx-disable-with="Add..."
disabled={not @changeset.valid?}
>
Add Hub
</button>
</div>
<% end %>
</.form>
</div>
"""
end
@impl true
def handle_event("connect", _params, socket) do
url = get_field(socket.assigns.changeset, :url)
token = get_field(socket.assigns.changeset, :token)
base = %Enterprise{
id: "enterprise-placeholder",
token: token,
external_id: "placeholder",
url: url,
hub_name: "Enterprise",
hub_emoji: "🏭"
}
{:ok, pid} = EnterpriseClient.start_link(base)
receive do
{:hub_connection_failed, reason} ->
EnterpriseClient.stop(base.id)
{:noreply,
socket
|> put_flash(:error, "Failed to connect with Enterprise: " <> reason)
|> push_patch(to: ~p"/hub")}
:hub_connected ->
data = LivebookProto.build_handshake_request(app_version: Livebook.Config.app_version())
case EnterpriseClient.send_request(pid, data) do
{:handshake, handshake_response} ->
base = %{base | external_id: handshake_response.id}
changeset = Enterprise.validate_hub(base)
{:noreply, assign(socket, pid: pid, changeset: changeset, base: base)}
{:transport_error, reason} ->
EnterpriseClient.stop(base.id)
{:noreply,
socket
|> put_flash(:error, "Failed to connect with Enterprise: " <> reason)
|> push_patch(to: ~p"/hub")}
end
end
end
def handle_event("save", %{"enterprise" => params}, socket) do
if socket.assigns.changeset.valid? do
case Enterprise.create_hub(socket.assigns.base, params) do
{:ok, hub} ->
if pid = socket.assigns.pid do
GenServer.stop(pid)
end
{:noreply,
socket
|> put_flash(:success, "Hub added successfully")
|> push_navigate(to: ~p"/hub/#{hub.id}")}
{:error, changeset} ->
{:noreply, assign(socket, changeset: changeset)}
end
else
{:noreply, socket}
end
end
def handle_event("validate", %{"enterprise" => attrs}, socket) do
{:noreply, assign(socket, changeset: Enterprise.validate_hub(socket.assigns.base, attrs))}
end
end

View file

@ -1,131 +0,0 @@
defmodule LivebookWeb.Hub.New.FlyComponent do
use LivebookWeb, :live_component
import Ecto.Changeset, only: [add_error: 3]
alias Livebook.Hubs.{Fly, FlyClient}
@impl true
def update(assigns, socket) do
{:ok,
socket
|> assign(assigns)
|> assign(
base: %Fly{},
changeset: Fly.change_hub(%Fly{}),
selected_app: nil,
select_options: [],
apps: []
)}
end
@impl true
def render(assigns) do
~H"""
<div>
<.form
:let={f}
id={@id}
class="flex flex-col space-y-4"
for={@changeset}
phx-submit="save"
phx-change="validate"
phx-target={@myself}
>
<.password_field
type="password"
field={f[:access_token]}
label="Access Token"
phx-change="fetch_data"
phx-debounce="blur"
phx-target={@myself}
autofocus
spellcheck="false"
autocomplete="off"
/>
<%= if length(@apps) > 0 do %>
<.select_field
field={f[:application_id]}
label="Application"
options={@select_options}
prompt="Select one application"
/>
<div class="grid grid-cols-1 md:grid-cols-2 gap-3">
<.text_field field={f[:hub_name]} label="Name" />
<.emoji_field field={f[:hub_emoji]} label="Emoji" />
</div>
<div>
<button
class="button-base button-blue"
phx-disable-with="Add..."
disabled={not @changeset.valid?}
>
Add Hub
</button>
</div>
<% end %>
</.form>
</div>
"""
end
@impl true
def handle_event("fetch_data", %{"fly" => %{"access_token" => token}}, socket) do
case FlyClient.fetch_apps(token) do
{:ok, apps} ->
opts = select_options(apps)
base = %Fly{access_token: token, hub_emoji: "🚀"}
changeset = Fly.validate_hub(base)
{:noreply,
assign(socket, changeset: changeset, base: base, select_options: opts, apps: apps)}
{:error, _} ->
changeset =
%Fly{}
|> Fly.validate_hub(%{access_token: token})
|> add_error(:access_token, "is invalid")
{:noreply,
assign(socket, changeset: changeset, base: %Fly{}, select_options: [], apps: [])}
end
end
def handle_event("save", %{"fly" => params}, socket) do
if socket.assigns.changeset.valid? do
case Fly.create_hub(socket.assigns.selected_app, params) do
{:ok, hub} ->
{:noreply,
socket
|> put_flash(:success, "Hub added successfully")
|> push_navigate(to: ~p"/hub/#{hub.id}")}
{:error, changeset} ->
{:noreply, assign(socket, changeset: changeset)}
end
else
{:noreply, socket}
end
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)
changeset = Fly.validate_hub(selected_app || socket.assigns.base, params)
{:noreply,
assign(socket, changeset: changeset, selected_app: selected_app, select_options: opts)}
end
defp select_options(hubs) do
for fly <- hubs do
[key: "#{fly.organization_name} - #{fly.application_id}", value: fly.application_id]
end
end
end

View file

@ -1,19 +1,30 @@
defmodule LivebookWeb.Hub.NewLive do
use LivebookWeb, :live_view
alias Livebook.Teams
alias Livebook.Teams.Org
alias LivebookWeb.LayoutHelpers
alias Phoenix.LiveView.JS
on_mount LivebookWeb.SidebarHook
@check_completion_data_internal 3000
@impl true
def mount(_params, _session, socket) do
enabled? = Livebook.Config.feature_flag_enabled?(:create_hub)
{:ok, assign(socket, selected_type: nil, page_title: "Hub - Livebook", enabled?: enabled?)}
end
@impl true
def handle_params(_params, _url, socket), do: {:noreply, socket}
{:ok,
assign(socket,
selected_option: nil,
page_title: "Hub - Livebook",
enabled?: enabled?,
requested_code: false,
org: nil,
verification_uri: nil,
org_form: nil
)}
end
@impl true
def render(%{enabled?: false} = assigns) do
@ -74,71 +85,94 @@ defmodule LivebookWeb.Hub.NewLive do
</p>
</div>
<div class="flex flex-col space-y-4">
<h2 class="text-xl text-gray-800 font-medium pb-2 border-b border-gray-200">
1. Select your Hub service
</h2>
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
<.card_item id="fly" selected={@selected_type} title="Fly">
<:logo>
<%= Phoenix.HTML.raw(File.read!("static/images/fly.svg")) %>
</:logo>
<:headline>
Deploy notebooks to your Fly account.
</:headline>
</.card_item>
<.card_item id="enterprise" selected={@selected_type} title="Livebook Teams">
<:logo>
<img src="/images/teams.png" class="max-h-full max-w-[75%]" alt="Livebook Teams logo" />
</:logo>
<:headline>
Control access, manage secrets, and deploy notebooks within your team.
</:headline>
</.card_item>
</div>
</div>
<div :if={@selected_type} class="flex flex-col space-y-4">
<h2 class="text-xl text-gray-800 font-medium pb-2 border-b border-gray-200">
2. Configure your Hub
</h2>
<.live_component
:if={@selected_type == "fly"}
module={LivebookWeb.Hub.New.FlyComponent}
id="fly-form"
/>
<.live_component
:if={@selected_type == "enterprise"}
module={LivebookWeb.Hub.New.EnterpriseComponent}
id="enterprise-form"
/>
</div>
<.org_form
form={@org_form}
org={@org}
requested_code={@requested_code}
selected={@selected_option}
verification_uri={@verification_uri}
/>
</div>
</LayoutHelpers.layout>
"""
end
defp org_form(assigns) do
~H"""
<div class="flex flex-col space-y-4">
<h2 class="text-xl text-gray-800 font-medium pb-2 border-b border-gray-200">
1. Select your option
</h2>
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
<.card_item id="new-org" selected={@selected} title="Create a new organization">
<:logo><.remix_icon icon="add-circle-fill" class="text-black text-3xl" /></:logo>
<:headline>Create a new organization and invite your team members.</:headline>
</.card_item>
<.card_item id="join-org" selected={@selected} disabled title="Join an organization">
<:logo><.remix_icon icon="user-add-fill" class="text-black text-3xl" /></:logo>
<:headline>Coming soon...</:headline>
</.card_item>
</div>
</div>
<div :if={@selected == "new-org"} class="flex flex-col space-y-4">
<h2 class="text-xl text-gray-800 font-medium pb-2 border-b border-gray-200">
2. Create your Organization
</h2>
<.form
:let={f}
id="new-org-form"
class="flex flex-col space-y-4"
for={@form}
phx-submit="save"
phx-change="validate"
>
<div class="grid grid-cols-1 md:grid-cols-2 gap-3">
<.text_field field={f[:name]} label="Name" />
<.emoji_field field={f[:emoji]} label="Emoji" />
</div>
<.password_field readonly field={f[:teams_key]} label="Livebook Teams Key" />
<div :if={@requested_code} class="grid grid-cols-1 gap-3">
<span>
Access the following URL and input the User Code below to confirm the Organization creation.
</span>
<.link navigate={@verification_uri} target="_blank" class="font-bold text-blue-500">
<%= @verification_uri %>
</.link>
<span><%= @org.user_code %></span>
</div>
<button :if={!@requested_code} class="button-base button-blue" phx-disable-with="Creating...">
Create Org
</button>
</.form>
</div>
"""
end
defp card_item(assigns) do
assigns = assign_new(assigns, :disabled, fn -> false end)
~H"""
<div
id={@id}
class="flex flex-col cursor-pointer"
phx-click={JS.push("select_type", value: %{value: @id})}
class={["flex flex-col cursor-pointer", disabled_class(@disabled)]}
phx-click={JS.push("select_option", value: %{value: @id})}
>
<div class={[
"flex items-center justify-center p-6 border-2 rounded-t-2xl h-[150px]",
if(@id == @selected, do: "border-gray-200", else: "border-gray-100")
card_item_border_class(@id, @selected)
]}>
<%= render_slot(@logo) %>
</div>
<div class={[
"px-6 py-4 rounded-b-2xl grow",
if(@id == @selected, do: "bg-gray-200", else: "bg-gray-100")
]}>
<div class={["px-6 py-4 rounded-b-2xl grow", card_item_class(@id, @selected)]}>
<p class="text-gray-800 font-semibold cursor-pointer">
<%= @title %>
</p>
@ -151,8 +185,108 @@ defmodule LivebookWeb.Hub.NewLive do
"""
end
defp disabled_class(true), do: "opacity-30 pointer-events-none"
defp disabled_class(false), do: ""
defp card_item_border_class(id, id), do: "border-gray-200"
defp card_item_border_class(_, _), do: "border-gray-100"
defp card_item_class(id, id), do: "bg-gray-200"
defp card_item_class(_, _), do: "bg-gray-100"
@impl true
def handle_event("select_type", %{"value" => service}, socket) do
{:noreply, assign(socket, selected_type: service)}
def handle_event("select_option", %{"value" => option}, socket) do
{:noreply,
socket
|> assign(selected_option: option)
|> assign_form(option)}
end
def handle_event("validate", %{"new_org" => attrs}, socket) do
changeset =
socket.assigns.org
|> Teams.change_org(attrs)
|> Map.replace!(:action, :validate)
{:noreply, assign_form(socket, changeset)}
end
def handle_event("save", %{"new_org" => attrs}, socket) do
case Teams.create_org(socket.assigns.org, attrs) do
{:ok, response} ->
attrs = Map.merge(attrs, response)
changeset = Teams.change_org(socket.assigns.org, attrs)
org = Ecto.Changeset.apply_action!(changeset, :insert)
Process.send_after(self(), :check_completion_data, @check_completion_data_internal)
{:noreply,
socket
|> assign(requested_code: true, org: org, verification_uri: response["verification_uri"])
|> assign_form(changeset)}
{:error, changeset} ->
{:noreply, assign_form(socket, changeset)}
{:transport_error, message} ->
{:noreply, put_flash(socket, :error, message)}
end
end
@impl true
def handle_info(:check_completion_data, %{assigns: %{org: org}} = socket) do
case Teams.get_org_request_completion_data(org) do
{:ok, :awaiting_confirmation} ->
Process.send_after(self(), :check_completion_data, @check_completion_data_internal)
{:noreply, socket}
{:ok, %{"id" => _id, "session_token" => _session_token} = response} ->
hub =
Teams.create_hub!(%{
org_id: response["id"],
user_id: response["user_id"],
org_key_id: response["org_key_id"],
session_token: response["session_token"],
teams_key: org.teams_key,
hub_name: org.name,
hub_emoji: org.emoji
})
{:noreply,
socket
|> put_flash(:success, "Hub added successfully")
|> push_navigate(to: ~p"/hub/#{hub.id}")}
{:error, :expired} ->
changeset = Teams.change_org(org, %{user_code: nil})
{:noreply,
socket
|> assign(requested_code: false, org: org, verification_uri: nil)
|> put_flash(
:error,
"Oh no! Your org creation request expired, could you please try again?"
)
|> assign_form(changeset)}
{:transport_error, message} ->
{:noreply, put_flash(socket, :error, message)}
end
end
def handle_info(_any, socket), do: {:noreply, socket}
defp assign_form(socket, "new-org") do
org = %Org{emoji: "💡"}
changeset = Teams.change_org(org)
socket
|> assign(org: org)
|> assign_form(changeset)
end
defp assign_form(socket, %Ecto.Changeset{} = changeset) do
assign(socket, org_form: to_form(changeset, as: :new_org))
end
end

View file

@ -1,166 +1,62 @@
defmodule LivebookWeb.Hub.NewLiveTest do
use LivebookWeb.ConnCase
use Livebook.TeamsIntegrationCase, async: true
import Phoenix.LiveViewTest
alias Livebook.Hubs
test "render hub selection cards", %{conn: conn} do
{:ok, _view, html} = live(conn, ~p"/hub")
{:ok, view, _html} = live(conn, ~p"/hub")
assert html =~ "Fly"
assert html =~ "Livebook Teams"
# shows the new options
assert has_element?(view, "#new-org")
assert has_element?(view, "#join-org")
end
describe "fly" do
test "persists new hub", %{conn: conn} do
fly_bypass("123456789")
describe "new-org" do
test "persist a new hub", %{conn: conn, node: node, user: user} do
name = "New Org Test #{System.unique_integer([:positive])}"
teams_key = Livebook.Teams.Org.teams_key()
{:ok, view, _html} = live(conn, ~p"/hub")
path = ~p"/hub/team-#{name}"
# select the new org option
assert view
|> element("#fly")
|> render_click() =~ "2. Configure your Hub"
|> element("#new-org")
|> render_click() =~ "2. Create your Organization"
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>)
# builds the form data
org_attrs = %{"new_org" => %{"name" => name, "teams_key" => teams_key, "emoji" => "🐈"}}
attrs = %{
"access_token" => "dummy access token",
"application_id" => "123456789",
"hub_name" => "My Foo Hub",
"hub_emoji" => "🐈"
}
# finds the form and change data
new_org_form = element(view, "#new-org-form")
render_change(new_org_form, org_attrs)
view
|> element("#fly-form")
|> render_change(%{"fly" => attrs})
# submits the form
render_submit(new_org_form, org_attrs)
refute view
|> element("#fly-form .invalid-feedback")
|> has_element?()
# check if the form has the url to confirm
link_element = element(view, "#new-org-form a")
html = render(link_element)
parsed_html = Floki.parse_document!(html)
assert [url] = Floki.attribute(parsed_html, "href")
assert [_port, [org_request_id]] = Regex.scan(~r/(?<=\D|^)\d{1,4}(?=\D|$)/, url)
id = String.to_integer(org_request_id)
assert {:ok, view, _html} =
view
|> element("#fly-form")
|> render_submit(%{"fly" => attrs})
|> follow_redirect(conn)
# force org request confirmation
org_request = :erpc.call(node, Hub.Integration, :get_org_request!, [id])
:erpc.call(node, Hub.Integration, :confirm_org_request, [org_request, user])
assert render(view) =~ "Hub added successfully"
# wait for the c:handle_info/2 cycle
# check if the page redirected to edit hub page
# and check the flash message
%{"success" => "Hub added successfully"} = assert_redirect(view, path, 1200)
# checks if the hub is in the sidebar
{:ok, view, _html} = live(conn, path)
hubs_html = view |> element("#hubs") |> render()
assert hubs_html =~ "🐈"
assert hubs_html =~ "/hub/fly-123456789"
assert hubs_html =~ "My Foo Hub"
assert hubs_html =~ path
assert hubs_html =~ name
end
test "fails to create existing hub", %{conn: conn} do
hub = insert_hub(:fly, id: "fly-foo", application_id: "foo")
fly_bypass(hub.application_id)
{:ok, view, _html} = live(conn, ~p"/hub")
assert view
|> element("#fly")
|> render_click() =~ "2. Configure your Hub"
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>)
attrs = %{
"access_token" => "dummy access token",
"application_id" => "foo",
"hub_name" => "My Foo Hub",
"hub_emoji" => "🐈"
}
view
|> element("#fly-form")
|> render_change(%{"fly" => attrs})
refute view
|> element("#fly-form .invalid-feedback")
|> has_element?()
assert view
|> element("#fly-form")
|> render_submit(%{"fly" => attrs}) =~ "already exists"
assert_hub(view, hub)
assert Hubs.fetch_hub!(hub.id) == hub
end
end
defp fly_bypass(app_id) do
bypass = Bypass.open()
Application.put_env(:livebook, :fly_graphql_endpoint, "http://localhost:#{bypass.port}")
Bypass.expect(bypass, "POST", "/", fn conn ->
{:ok, body, conn} = Plug.Conn.read_body(conn)
body = Jason.decode!(body)
response =
cond do
body["query"] =~ "apps" -> fetch_apps_response(app_id)
body["query"] =~ "app" -> fetch_app_response(app_id)
end
conn
|> Plug.Conn.put_resp_content_type("application/json")
|> Plug.Conn.resp(200, Jason.encode!(response))
end)
end
defp fetch_apps_response(app_id) do
app = %{
"id" => app_id,
"organization" => %{
"id" => "l3soyvjmvtmwtl6l2drnbfuvltipprge",
"name" => "Foo Bar",
"type" => "PERSONAL"
}
}
%{"data" => %{"apps" => %{"nodes" => [app]}}}
end
defp fetch_app_response(app_id) do
app = %{
"id" => app_id,
"name" => app_id,
"hostname" => app_id <> ".fly.dev",
"platformVersion" => "nomad",
"deployed" => true,
"status" => "running",
"secrets" => [
%{
"createdAt" => to_string(DateTime.utc_now()),
"digest" => to_string(Livebook.Utils.random_cookie()),
"id" => Livebook.Utils.random_short_id(),
"name" => "LIVEBOOK_PASSWORD"
},
%{
"createdAt" => to_string(DateTime.utc_now()),
"digest" => to_string(Livebook.Utils.random_cookie()),
"id" => Livebook.Utils.random_short_id(),
"name" => "LIVEBOOK_SECRET_KEY_BASE"
}
]
}
%{"data" => %{"app" => app}}
end
defp assert_hub(view, hub) do
hubs_html = view |> element("#hubs") |> render()
assert hubs_html =~ hub.hub_emoji
assert hubs_html =~ ~p"/hub/#{hub.id}"
assert hubs_html =~ hub.hub_name
end
end

View file

@ -31,15 +31,17 @@ defmodule Livebook.Factory do
end
def build(:enterprise) do
id = Livebook.Utils.random_id()
name = "Enteprise #{Livebook.Utils.random_short_id()}"
%Livebook.Hubs.Enterprise{
id: "enterprise-#{id}",
hub_name: "Enterprise",
id: "enterprise-#{name}",
hub_name: name,
hub_emoji: "🏭",
external_id: id,
token: Livebook.Utils.random_cookie(),
url: "http://localhost"
org_id: 1,
user_id: 1,
org_key_id: 1,
teams_key: Livebook.Utils.random_id(),
session_token: Livebook.Utils.random_cookie()
}
end
@ -74,6 +76,7 @@ defmodule Livebook.Factory do
def build(:org) do
%Livebook.Teams.Org{
id: nil,
emoji: "🏭",
name: "Org Name #{System.unique_integer([:positive])}",
teams_key: Livebook.Teams.Org.teams_key(),
user_code: "request"