From 77c8804d62a39801f4aa42f780adfac4814914d0 Mon Sep 17 00:00:00 2001 From: Alexandre de Souza Date: Mon, 22 Aug 2022 18:12:54 -0300 Subject: [PATCH] User and Hub forms with Changeset validations and input errors (#1347) --- .formatter.exs | 2 +- lib/livebook/ecto_types/hex_color.ex | 70 ++++++ lib/livebook/hubs/fly.ex | 101 +++++++-- lib/livebook/users.ex | 26 ++- lib/livebook/users/user.ex | 84 +------ lib/livebook/utils.ex | 20 -- lib/livebook_web.ex | 1 + lib/livebook_web/live/hooks/user_hook.ex | 9 +- lib/livebook_web/live/hub_live.ex | 19 +- .../live/hub_live/fly_component.ex | 207 +++++++++++------- lib/livebook_web/live/user_component.ex | 64 +++--- .../templates/layout/live.html.heex | 12 + lib/livebook_web/views/error_helpers.ex | 29 +++ mix.exs | 2 + mix.lock | 4 + static/images/enterprise.png | Bin 0 -> 24569 bytes test/livebook/ecto_types/hex_color_test.exs | 4 + test/livebook/hubs/provider_test.exs | 8 +- test/livebook/hubs_test.exs | 16 +- test/livebook/users/user_test.exs | 33 +-- test/livebook/utils_test.exs | 5 +- test/livebook_web/live/home_live_test.exs | 11 +- test/livebook_web/live/hub_live_test.exs | 109 ++++----- test/livebook_web/live/session_live_test.exs | 12 +- test/support/conn_case.ex | 1 + test/support/data_case.ex | 44 ++++ test/support/factory.ex | 43 ++++ test/support/fixtures.ex | 27 --- 28 files changed, 614 insertions(+), 349 deletions(-) create mode 100644 lib/livebook/ecto_types/hex_color.ex create mode 100644 lib/livebook_web/views/error_helpers.ex create mode 100644 static/images/enterprise.png create mode 100644 test/livebook/ecto_types/hex_color_test.exs create mode 100644 test/support/data_case.ex create mode 100644 test/support/factory.ex delete mode 100644 test/support/fixtures.ex 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"""
+ <.live_region role="alert" /> <:logo> - Fly logo + Livebook Enterprise 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 diff --git a/lib/livebook_web/live/hub_live/fly_component.ex b/lib/livebook_web/live/hub_live/fly_component.ex index 50f3e56b6..3508ce3e5 100644 --- a/lib/livebook_web/live/hub_live/fly_component.ex +++ b/lib/livebook_web/live/hub_live/fly_component.ex @@ -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) %>
<%= if length(@apps) > 0 do %> @@ -52,11 +53,9 @@ defmodule LivebookWeb.HubLive.FlyComponent do <%= 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) %>
@@ -64,7 +63,8 @@ defmodule LivebookWeb.HubLive.FlyComponent do

Name

- <%= 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) %>
@@ -75,17 +75,14 @@ defmodule LivebookWeb.HubLive.FlyComponent do
-
-
+
<%= text_input(f, :hub_color, - value: @data["hub_color"], class: "input", spellcheck: "false", - required: true, maxlength: 7 ) %> + <%= error_tag(f, :hub_color) %>
- <%= 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 %> @@ -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 diff --git a/lib/livebook_web/live/user_component.ex b/lib/livebook_web/live/user_component.ex index 195793fcc..2b7b7da4e 100644 --- a/lib/livebook_web/live/user_component.ex +++ b/lib/livebook_web/live/user_component.ex @@ -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
- <.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 %> + + <% 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 0000000000000000000000000000000000000000..ae2f0f6577da0deaa423d968819e5a31efff8968 GIT binary patch literal 24569 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

b#!|%9DUIBh()NFP92(Ug*!`U56+7l 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

XGNj7g6>P@DJD4Phi-BXj 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 z "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