From 67c5d35c59c292aa072127e70e9270b5cc8a58ba Mon Sep 17 00:00:00 2001 From: Alexandre de Souza Date: Thu, 23 Oct 2025 14:28:01 -0300 Subject: [PATCH 1/7] Fix protobuf message with wrong type --- proto/lib/livebook_proto/authorization_group.pb.ex | 2 +- proto/messages.proto | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/proto/lib/livebook_proto/authorization_group.pb.ex b/proto/lib/livebook_proto/authorization_group.pb.ex index 3d689c4cab6..e84f69ed2d1 100644 --- a/proto/lib/livebook_proto/authorization_group.pb.ex +++ b/proto/lib/livebook_proto/authorization_group.pb.ex @@ -3,5 +3,5 @@ defmodule LivebookProto.AuthorizationGroup do field :provider_id, 1, type: :string, json_name: "providerId" field :group_name, 2, type: :string, json_name: "groupName" - field :app_folder_ids, 3, repeated: true, type: :string, json_name: "appFolderIds" + field :app_folder_id, 3, type: :string, json_name: "appFolderId" end diff --git a/proto/messages.proto b/proto/messages.proto index 8d41e28f5fe..e2888c820d8 100644 --- a/proto/messages.proto +++ b/proto/messages.proto @@ -218,7 +218,7 @@ message EnvironmentVariable { message AuthorizationGroup { string provider_id = 1; string group_name = 2; - repeated string app_folder_ids = 3; + string app_folder_id = 3; } message DeploymentUser { From 1beb28a73a2da016b2defc5ae6a142da498ede66 Mon Sep 17 00:00:00 2001 From: Alexandre de Souza Date: Mon, 20 Oct 2025 18:47:25 -0300 Subject: [PATCH 2/7] Add app folder struct --- lib/livebook/teams/app_folder.ex | 15 +++++++++++++++ test/support/factory.ex | 7 +++++++ 2 files changed, 22 insertions(+) create mode 100644 lib/livebook/teams/app_folder.ex diff --git a/lib/livebook/teams/app_folder.ex b/lib/livebook/teams/app_folder.ex new file mode 100644 index 00000000000..fdd29681ff9 --- /dev/null +++ b/lib/livebook/teams/app_folder.ex @@ -0,0 +1,15 @@ +defmodule Livebook.Teams.AppFolder do + use Ecto.Schema + + @type t :: %__MODULE__{ + id: String.t() | nil, + name: String.t() | nil, + hub_id: String.t() | nil + } + + @primary_key {:id, :string, autogenerate: false} + embedded_schema do + field :name, :string + field :hub_id, :string + end +end diff --git a/test/support/factory.ex b/test/support/factory.ex index 5a4d0b81f4f..004349449b9 100644 --- a/test/support/factory.ex +++ b/test/support/factory.ex @@ -162,6 +162,13 @@ defmodule Livebook.Factory do } end + def build(:app_folder) do + %Livebook.Teams.AppFolder{ + id: "#{unique_integer()}", + name: unique_value("app_folder") + } + end + def build(factory_name, attrs) do factory_name |> build() |> struct!(attrs) end From 2197b4d040e2a1da05927cd4197344b28d1f62b7 Mon Sep 17 00:00:00 2001 From: Alexandre de Souza Date: Mon, 20 Oct 2025 18:47:04 -0300 Subject: [PATCH 3/7] Implement new fields for app folder id --- lib/livebook/teams/app_deployment.ex | 2 ++ lib/livebook/teams/authorization_group.ex | 4 +++- 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/lib/livebook/teams/app_deployment.ex b/lib/livebook/teams/app_deployment.ex index 4314cf771b3..36e107213c7 100644 --- a/lib/livebook/teams/app_deployment.ex +++ b/lib/livebook/teams/app_deployment.ex @@ -14,6 +14,7 @@ defmodule Livebook.Teams.AppDeployment do access_type: Livebook.Notebook.AppSettings.access_type(), hub_id: String.t() | nil, deployment_group_id: String.t() | nil, + app_folder_id: String.t() | nil, file: binary() | nil, deployed_by: String.t() | nil, deployed_at: DateTime.t() | nil, @@ -32,6 +33,7 @@ defmodule Livebook.Teams.AppDeployment do field :access_type, Ecto.Enum, values: @access_types field :hub_id, :string field :deployment_group_id, :string + field :app_folder_id, :string field :file, :string field :deployed_by, :string field :deployed_at, :utc_datetime diff --git a/lib/livebook/teams/authorization_group.ex b/lib/livebook/teams/authorization_group.ex index 4b22c4d226e..ee452e8224d 100644 --- a/lib/livebook/teams/authorization_group.ex +++ b/lib/livebook/teams/authorization_group.ex @@ -3,12 +3,14 @@ defmodule Livebook.Teams.AuthorizationGroup do @type t :: %__MODULE__{ provider_id: String.t() | nil, - group_name: String.t() | nil + group_name: String.t() | nil, + app_folder_id: String.t() | nil } @primary_key false embedded_schema do field :provider_id, :string field :group_name, :string + field :app_folder_id, :string end end From 8f864756127fa325f8a415a5d85037bfa088ef63 Mon Sep 17 00:00:00 2001 From: Alexandre de Souza Date: Mon, 20 Oct 2025 18:46:53 -0300 Subject: [PATCH 4/7] Handle app folder events from WebSocket Shows a list of app folders --- lib/livebook/hubs/team_client.ex | 91 ++++++++++++++++++- lib/livebook/session.ex | 8 ++ lib/livebook/session/data.ex | 46 ++++++++-- lib/livebook/teams.ex | 10 ++ lib/livebook/teams/broadcasts.ex | 31 +++++++ lib/livebook_web/live/session_live.ex | 4 +- .../session_live/app_settings_component.ex | 19 +++- lib/livebook_web/live/session_live/render.ex | 2 + test/livebook_teams/hubs/team_client_test.exs | 84 ++++++++++++++++- test/livebook_teams/web/session_live_test.exs | 39 ++++++++ test/support/integration/teams_rpc.ex | 8 ++ 11 files changed, 331 insertions(+), 11 deletions(-) diff --git a/lib/livebook/hubs/team_client.ex b/lib/livebook/hubs/team_client.ex index eac71bfca04..9e67335b8a3 100644 --- a/lib/livebook/hubs/team_client.ex +++ b/lib/livebook/hubs/team_client.ex @@ -24,6 +24,7 @@ defmodule Livebook.Hubs.TeamClient do deployment_groups: [], app_deployments: [], agents: [], + app_folders: [], app_deployment_statuses: nil ] @@ -172,6 +173,14 @@ defmodule Livebook.Hubs.TeamClient do GenServer.call(registry_name(id), {:user_can_deploy?, user_id, deployment_group_id}) end + @doc """ + Returns a list of cached app folders. + """ + @spec get_app_folders(String.t()) :: list(Teams.AppFolder.t()) + def get_app_folders(id) do + GenServer.call(registry_name(id), :get_app_folders) + end + @doc """ Returns if the Team client is connected. """ @@ -370,6 +379,10 @@ defmodule Livebook.Hubs.TeamClient do end end + def handle_call(:get_app_folders, _caller, state) do + {:reply, state.app_folders, state} + end + @impl true def handle_info(:connected, state) do Hubs.Broadcasts.hub_connected(state.hub.id) @@ -611,7 +624,8 @@ defmodule Livebook.Hubs.TeamClient do file: nil, deployed_by: app_deployment.deployed_by, deployed_at: DateTime.from_gregorian_seconds(app_deployment.deployed_at), - authorization_groups: authorization_groups + authorization_groups: authorization_groups, + app_folder_id: nullify(app_deployment.app_folder_id) } end @@ -630,7 +644,8 @@ defmodule Livebook.Hubs.TeamClient do for authorization_group <- authorization_groups do %Teams.AuthorizationGroup{ provider_id: authorization_group.provider_id, - group_name: authorization_group.group_name + group_name: authorization_group.group_name, + app_folder_id: nullify(authorization_group.app_folder_id) } end end @@ -664,6 +679,24 @@ defmodule Livebook.Hubs.TeamClient do } end + defp put_app_folder(state, app_folder) do + state = remove_app_folder(state, app_folder) + + %{state | app_folders: [app_folder | state.app_folders]} + end + + defp remove_app_folder(state, app_folder) do + %{state | app_folders: Enum.reject(state.app_folders, &(&1.id == app_folder.id))} + end + + defp build_app_folder(state, %LivebookProto.AppFolder{} = app_folder) do + %Teams.AppFolder{ + id: app_folder.id, + name: app_folder.name, + hub_id: state.hub.id + } + end + defp handle_event(:secret_created, %Secrets.Secret{} = secret, state) do Hubs.Broadcasts.secret_created(secret) @@ -787,6 +820,7 @@ defmodule Livebook.Hubs.TeamClient do |> dispatch_deployment_groups(user_connected) |> dispatch_app_deployments(user_connected) |> dispatch_agents(user_connected) + |> dispatch_app_folders(user_connected) |> dispatch_connection() end @@ -798,6 +832,7 @@ defmodule Livebook.Hubs.TeamClient do |> dispatch_deployment_groups(agent_connected) |> dispatch_app_deployments(agent_connected) |> dispatch_agents(agent_connected) + |> dispatch_app_folders(agent_connected) |> dispatch_connection() end @@ -873,6 +908,43 @@ defmodule Livebook.Hubs.TeamClient do update_hub(state, org_updated) end + defp handle_event(:app_folder_created, %Teams.AppFolder{} = app_folder, state) do + Teams.Broadcasts.app_folder_created(app_folder) + put_app_folder(state, app_folder) + end + + defp handle_event(:app_folder_created, app_folder_created, state) do + handle_event( + :app_folder_created, + build_app_folder(state, app_folder_created.app_folder), + state + ) + end + + defp handle_event(:app_folder_updated, %Teams.AppFolder{} = app_folder, state) do + Teams.Broadcasts.app_folder_updated(app_folder) + put_app_folder(state, app_folder) + end + + defp handle_event(:app_folder_updated, app_folder_updated, state) do + handle_event( + :app_folder_updated, + build_app_folder(state, app_folder_updated.app_folder), + state + ) + end + + defp handle_event(:app_folder_deleted, %Teams.AppFolder{} = app_folder, state) do + Teams.Broadcasts.app_folder_deleted(app_folder) + remove_app_folder(state, app_folder) + end + + defp handle_event(:app_folder_deleted, %{id: id}, state) do + with {:ok, app_folder} <- fetch_app_folder(id, state) do + handle_event(:app_folder_deleted, app_folder, state) + end + end + defp dispatch_secrets(state, %{secrets: secrets}) do decrypted_secrets = Enum.map(secrets, &build_secret(state, &1)) @@ -936,6 +1008,19 @@ defmodule Livebook.Hubs.TeamClient do dispatch_events(state, agent_joined: joined, agent_left: left) end + defp dispatch_app_folders(state, %{app_folders: app_folders}) do + app_folders = Enum.map(app_folders, &build_app_folder(state, &1)) + + {created, deleted, updated} = + diff(state.app_folders, app_folders, &(&1.id == &2.id)) + + dispatch_events(state, + app_folder_deleted: deleted, + app_folder_created: created, + app_folder_updated: updated + ) + end + defp dispatch_connection(%{hub: %{id: id}} = state) do Teams.Broadcasts.client_connected(id) state @@ -1064,6 +1149,8 @@ defmodule Livebook.Hubs.TeamClient do defp fetch_app_deployment_from_slug(slug, state), do: fetch_entry(state.app_deployments, &(&1.slug == slug), state) + defp fetch_app_folder(id, state), do: fetch_entry(state.app_folders, &(&1.id == id), state) + defp fetch_entry(entries, fun, state) do if entry = Enum.find(entries, fun) do {:ok, entry} diff --git a/lib/livebook/session.ex b/lib/livebook/session.ex index 40ba9235f75..9318ea4d8d1 100644 --- a/lib/livebook/session.ex +++ b/lib/livebook/session.ex @@ -904,6 +904,7 @@ defmodule Livebook.Session do def init({caller_pid, opts}) do Livebook.Settings.subscribe() Livebook.Hubs.Broadcasts.subscribe([:crud, :secrets, :file_systems]) + Livebook.Teams.Broadcasts.subscribe(:app_folders) id = Keyword.fetch!(opts, :id) @@ -2028,6 +2029,13 @@ defmodule Livebook.Session do {:noreply, handle_operation(state, operation)} end + def handle_info({event, app_folder}, state) + when event in [:app_folder_created, :app_folder_updated, :app_folder_deleted] and + app_folder.hub_id == state.data.notebook.hub_id do + operation = {:sync_hub_app_folders, @client_id} + {:noreply, handle_operation(state, operation)} + end + def handle_info({:hub_deleted, id}, %{data: %{notebook: %{hub_id: id}}} = state) do # Since the hub got deleted, we close all sessions using that hub. # This way we clean up all secrets and other in-memory state that diff --git a/lib/livebook/session/data.ex b/lib/livebook/session/data.ex index af07583ed3a..358f67b4af7 100644 --- a/lib/livebook/session/data.ex +++ b/lib/livebook/session/data.ex @@ -37,6 +37,7 @@ defmodule Livebook.Session.Data do :secrets, :hub_secrets, :hub_file_systems, + :hub_app_folders, :mode, :deployed_app_slug, :app_data @@ -247,6 +248,7 @@ defmodule Livebook.Session.Data do | {:set_notebook_hub, client_id(), String.t()} | {:sync_hub_secrets, client_id()} | {:sync_hub_file_systems, client_id()} + | {:sync_hub_app_folders, client_id()} | {:add_file_entries, client_id(), list(Notebook.file_entry())} | {:rename_file_entry, client_id(), name :: String.t(), new_name :: String.t()} | {:delete_file_entry, client_id(), String.t()} @@ -306,6 +308,13 @@ defmodule Livebook.Session.Data do hub_secrets = Livebook.Hubs.get_secrets(hub) hub_file_systems = Livebook.Hubs.get_file_systems(hub) + hub_app_folders = + if is_struct(hub, Livebook.Hubs.Team) do + Livebook.Teams.get_app_folders(hub) + else + [] + end + startup_secrets = for secret <- Livebook.Secrets.get_startup_secrets(), do: {secret.name, secret}, @@ -338,6 +347,7 @@ defmodule Livebook.Session.Data do secrets: secrets, hub_secrets: hub_secrets, hub_file_systems: hub_file_systems, + hub_app_folders: hub_app_folders, mode: opts[:mode], deployed_app_slug: nil, app_data: app_data @@ -1074,6 +1084,14 @@ defmodule Livebook.Session.Data do |> wrap_ok() end + def apply_operation(data, {:sync_hub_app_folders, _client_id}) do + data + |> with_actions() + |> sync_hub_app_folders() + |> set_dirty() + |> wrap_ok() + end + def apply_operation(data, {:add_file_entries, _client_id, file_entries}) do data |> with_actions() @@ -1957,15 +1975,21 @@ defmodule Livebook.Session.Data do end defp set_notebook_hub({data, _} = data_actions, hub) do + teams_enabled = is_struct(hub, Livebook.Hubs.Team) + + app_folders = + if teams_enabled do + Livebook.Teams.get_app_folders(hub) + else + [] + end + data_actions |> set!( - notebook: %{ - data.notebook - | hub_id: hub.id, - teams_enabled: is_struct(hub, Livebook.Hubs.Team) - }, + notebook: %{data.notebook | hub_id: hub.id, teams_enabled: teams_enabled}, hub_secrets: Livebook.Hubs.get_secrets(hub), - hub_file_systems: Livebook.Hubs.get_file_systems(hub) + hub_file_systems: Livebook.Hubs.get_file_systems(hub), + hub_app_folders: app_folders ) end @@ -1985,6 +2009,16 @@ defmodule Livebook.Session.Data do set!(data_actions, hub_file_systems: file_systems) end + defp sync_hub_app_folders({data, _} = data_actions) do + if data.notebook.teams_enabled do + hub = Livebook.Hubs.fetch_hub!(data.notebook.hub_id) + app_folders = Livebook.Teams.get_app_folders(hub) + set!(data_actions, hub_app_folders: app_folders) + else + data_actions + end + end + defp update_notebook_hub_secret_names({data, _} = data_actions) do hub_secret_names = for {_name, secret} <- data.secrets, secret.hub_id == data.notebook.hub_id, do: secret.name diff --git a/lib/livebook/teams.ex b/lib/livebook/teams.ex index 717903dd3c9..be48634a955 100644 --- a/lib/livebook/teams.ex +++ b/lib/livebook/teams.ex @@ -305,4 +305,14 @@ defmodule Livebook.Teams do def user_can_deploy?(%Team{} = team, %Teams.DeploymentGroup{} = deployment_group) do TeamClient.user_can_deploy?(team.id, team.user_id, deployment_group.id) end + + @doc """ + Gets a list of app folders for a given Hub. + """ + @spec get_app_folders(Team.t()) :: list(Teams.AppFolder.t()) + def get_app_folders(team) do + team.id + |> TeamClient.get_app_folders() + |> Enum.sort_by(& &1.name) + end end diff --git a/lib/livebook/teams/broadcasts.ex b/lib/livebook/teams/broadcasts.ex index d94d17eb1fe..f7db7a01047 100644 --- a/lib/livebook/teams/broadcasts.ex +++ b/lib/livebook/teams/broadcasts.ex @@ -7,6 +7,7 @@ defmodule Livebook.Teams.Broadcasts do @app_deployments_topic "teams:app_deployments" @clients_topic "teams:clients" @deployment_groups_topic "teams:deployment_groups" + @app_folders_topic "teams:app_folders" @app_server_topic "teams:app_server" @doc """ @@ -40,6 +41,12 @@ defmodule Livebook.Teams.Broadcasts do * `{:server_authorization_updated, DeploymentGroup.t()}` + Topic `#{@app_folders_topic}`: + + * `{:app_folder_created, AppFolder.t()}` + * `{:app_folder_updated, AppFolder.t()}` + * `{:app_folder_deleted, AppFolder.t()}` + """ @spec subscribe(atom() | list(atom())) :: :ok | {:error, term()} def subscribe(topics) when is_list(topics) do @@ -154,6 +161,30 @@ defmodule Livebook.Teams.Broadcasts do broadcast(@app_server_topic, {:server_authorization_updated, deployment_group}) end + @doc """ + Broadcasts under `#{@app_folders_topic}` topic when hub received a new app folder. + """ + @spec app_folder_created(Teams.AppFolder.t()) :: broadcast() + def app_folder_created(%Teams.AppFolder{} = app_folder) do + broadcast(@app_folders_topic, {:app_folder_created, app_folder}) + end + + @doc """ + Broadcasts under `#{@app_folders_topic}` topic when hub received an updated app folder. + """ + @spec app_folder_updated(Teams.AppFolder.t()) :: broadcast() + def app_folder_updated(%Teams.AppFolder{} = app_folder) do + broadcast(@app_folders_topic, {:app_folder_updated, app_folder}) + end + + @doc """ + Broadcasts under `#{@app_folders_topic}` topic when hub received a deleted app folder. + """ + @spec app_folder_deleted(Teams.AppFolder.t()) :: broadcast() + def app_folder_deleted(%Teams.AppFolder{} = app_folder) do + broadcast(@app_folders_topic, {:app_folder_deleted, app_folder}) + end + defp broadcast(topic, message) do Phoenix.PubSub.broadcast(Livebook.PubSub, topic, message) end diff --git a/lib/livebook_web/live/session_live.ex b/lib/livebook_web/live/session_live.ex index dbe6899bbe6..bbebde58687 100644 --- a/lib/livebook_web/live/session_live.ex +++ b/lib/livebook_web/live/session_live.ex @@ -1865,13 +1865,15 @@ defmodule LivebookWeb.SessionLive do hub: Livebook.Hubs.fetch_hub!(data.notebook.hub_id), hub_secrets: data.hub_secrets, hub_file_systems: data.hub_file_systems, + hub_app_folders: data.hub_app_folders, any_session_secrets?: Session.Data.session_secrets(data.secrets, data.notebook.hub_id) != [], file_entries: Enum.sort_by(data.notebook.file_entries, & &1.name), quarantine_file_entry_names: data.notebook.quarantine_file_entry_names, app_settings: data.notebook.app_settings, deployed_app_slug: data.deployed_app_slug, - deployment_group_id: data.notebook.deployment_group_id + deployment_group_id: data.notebook.deployment_group_id, + teams_enabled: data.notebook.teams_enabled } end diff --git a/lib/livebook_web/live/session_live/app_settings_component.ex b/lib/livebook_web/live/session_live/app_settings_component.ex index 1ce0b820afc..20278c494c8 100644 --- a/lib/livebook_web/live/session_live/app_settings_component.ex +++ b/lib/livebook_web/live/session_live/app_settings_component.ex @@ -12,10 +12,15 @@ defmodule LivebookWeb.SessionLive.AppSettingsComponent do _ -> AppSettings.change(assigns.settings) end + app_folder_options = + for app_folder <- assigns.app_folders do + {app_folder.name, app_folder.id} + end + {:ok, socket |> assign(assigns) - |> assign(changeset: changeset)} + |> assign(app_folder_options: app_folder_options, changeset: changeset)} end @impl true @@ -42,6 +47,18 @@ defmodule LivebookWeb.SessionLive.AppSettingsComponent do >
<.text_field field={f[:slug]} label="Slug" spellcheck="false" phx-debounce /> + <.select_field + :if={@teams_enabled} + field={f[:group]} + label="Folder" + prompt="Select a folder..." + options={@app_folder_options} + help={ + ~S''' + Use folders to organize how how apps are displayed. + ''' + } + />
<.checkbox_field field={f[:access_type]} diff --git a/lib/livebook_web/live/session_live/render.ex b/lib/livebook_web/live/session_live/render.ex index 62e7ec9b363..df300f4ded6 100644 --- a/lib/livebook_web/live/session_live/render.ex +++ b/lib/livebook_web/live/session_live/render.ex @@ -103,6 +103,8 @@ defmodule LivebookWeb.SessionLive.Render do settings={@data_view.app_settings} context={@action_assigns.context} deployed_app_slug={@data_view.deployed_app_slug} + app_folders={@data_view.hub_app_folders} + teams_enabled={@data_view.teams_enabled} /> diff --git a/test/livebook_teams/hubs/team_client_test.exs b/test/livebook_teams/hubs/team_client_test.exs index 197ebf1642f..4d0c823f195 100644 --- a/test/livebook_teams/hubs/team_client_test.exs +++ b/test/livebook_teams/hubs/team_client_test.exs @@ -6,7 +6,13 @@ defmodule Livebook.Hubs.TeamClientTest do setup :teams @moduletag subscribe_to_hubs_topics: [:crud, :connection, :file_systems, :secrets] - @moduletag subscribe_to_teams_topics: [:clients, :deployment_groups, :app_deployments, :agents] + @moduletag subscribe_to_teams_topics: [ + :clients, + :deployment_groups, + :app_deployments, + :agents, + :app_folders + ] describe "connect" do @describetag teams_for: :user @@ -303,6 +309,44 @@ defmodule Livebook.Hubs.TeamClientTest do assert_receive {:agent_left, ^agent} refute agent in TeamClient.get_agents(team.id) end + + test "dispatches the app folders list", + %{team: team, pid: pid, user_connected: user_connected} do + app_folder = build(:app_folder, hub_id: team.id) + + livebook_proto_app_folder = + %LivebookProto.AppFolder{ + id: app_folder.id, + name: app_folder.name + } + + # creates the app folder + user_connected = %{user_connected | app_folders: [livebook_proto_app_folder]} + refute_received {:app_folder_created, ^app_folder} + send(pid, {:event, :user_connected, user_connected}) + assert_receive {:app_folder_created, ^app_folder} + assert app_folder in TeamClient.get_app_folders(team.id) + + # updates the app folder + updated_app_folder = %{app_folder | name: "ChonkiestCat"} + + updated_livebook_proto_app_folder = %{ + livebook_proto_app_folder + | name: updated_app_folder.name + } + + user_connected = %{user_connected | app_folders: [updated_livebook_proto_app_folder]} + send(pid, {:event, :user_connected, user_connected}) + assert_receive {:app_folder_updated, ^updated_app_folder} + refute app_folder in TeamClient.get_app_folders(team.id) + assert updated_app_folder in TeamClient.get_app_folders(team.id) + + # deletes the app folder + user_connected = %{user_connected | app_folders: []} + send(pid, {:event, :user_connected, user_connected}) + assert_receive {:app_folder_deleted, ^updated_app_folder} + refute updated_app_folder in TeamClient.get_app_folders(team.id) + end end describe "handle agent_connected event" do @@ -809,5 +853,43 @@ defmodule Livebook.Hubs.TeamClientTest do assert_receive {:agent_left, ^agent} refute agent in TeamClient.get_agents(team.id) end + + test "dispatches the app folders list", + %{team: team, pid: pid, agent_connected: agent_connected} do + app_folder = build(:app_folder, hub_id: team.id) + + livebook_proto_app_folder = + %LivebookProto.AppFolder{ + id: app_folder.id, + name: app_folder.name + } + + # creates the app folder + agent_connected = %{agent_connected | app_folders: [livebook_proto_app_folder]} + refute_received {:app_folder_created, ^app_folder} + send(pid, {:event, :agent_connected, agent_connected}) + assert_receive {:app_folder_created, ^app_folder} + assert app_folder in TeamClient.get_app_folders(team.id) + + # updates the app folder + updated_app_folder = %{app_folder | name: "ChonkiestCat"} + + updated_livebook_proto_app_folder = %{ + livebook_proto_app_folder + | name: updated_app_folder.name + } + + agent_connected = %{agent_connected | app_folders: [updated_livebook_proto_app_folder]} + send(pid, {:event, :agent_connected, agent_connected}) + assert_receive {:app_folder_updated, ^updated_app_folder} + refute app_folder in TeamClient.get_app_folders(team.id) + assert updated_app_folder in TeamClient.get_app_folders(team.id) + + # deletes the app folder + agent_connected = %{agent_connected | app_folders: []} + send(pid, {:event, :agent_connected, agent_connected}) + assert_receive {:app_folder_deleted, ^updated_app_folder} + refute updated_app_folder in TeamClient.get_app_folders(team.id) + end end end diff --git a/test/livebook_teams/web/session_live_test.exs b/test/livebook_teams/web/session_live_test.exs index 3951e31a699..ff13a3fd8dc 100644 --- a/test/livebook_teams/web/session_live_test.exs +++ b/test/livebook_teams/web/session_live_test.exs @@ -740,4 +740,43 @@ defmodule LivebookWeb.Integration.SessionLiveTest do refute has_element?(view, ~s{button[id*="file-system-#{file_system.id}"]}) end end + + describe "app settings" do + @describetag subscribe_to_teams_topics: [:clients, :app_folders] + + test "updates the list of app folders", + %{team: team, conn: conn, node: node, session: session, org: org} do + Session.set_notebook_hub(session.pid, team.id) + {:ok, view, _} = live(conn, ~p"/sessions/#{session.id}") + + assert view + |> element(~s/[data-el-app-info] a/, "Configure") + |> render_click() =~ ~s(name="app_settings[app_folder_id]") + + assert render(view) =~ ~s() + + app_folder = TeamsRPC.create_app_folder(node, name: "Tidewave", org: org) + id = to_string(app_folder.id) + + assert_receive {:app_folder_created, %{id: ^id, name: "Tidewave"}} + assert_receive {:operation, {:sync_hub_app_folders, "__server__"}} + + assert render(view) =~ + ~s() + + {:ok, %{name: "Wavetide"}} = TeamsRPC.update_app_folder(node, app_folder, name: "Wavetide") + + assert_receive {:app_folder_updated, %{id: ^id, name: "Wavetide"}} + assert_receive {:operation, {:sync_hub_app_folders, "__server__"}} + refute render(view) =~ ~s() + assert render(view) =~ ~s() + + TeamsRPC.delete_app_folder(node, app_folder) + + assert_receive {:app_folder_deleted, %{id: ^id, name: "Wavetide"}} + assert_receive {:operation, {:sync_hub_app_folders, "__server__"}} + refute render(view) =~ ~s() + refute render(view) =~ ~s() + end + end end diff --git a/test/support/integration/teams_rpc.ex b/test/support/integration/teams_rpc.ex index 6c02941ffd0..103dbccd973 100644 --- a/test/support/integration/teams_rpc.ex +++ b/test/support/integration/teams_rpc.ex @@ -191,6 +191,10 @@ defmodule Livebook.TeamsRPC do :erpc.call(node, TeamsRPC, :update_file_system, [file_system.external_id, org_key, attrs]) end + def update_app_folder(node, app_folder, attrs \\ []) do + :erpc.call(node, TeamsRPC, :update_app_folder, [app_folder, attrs]) + end + # Delete resource def delete_user_org(node, user_id, org_id) do @@ -206,6 +210,10 @@ defmodule Livebook.TeamsRPC do :erpc.call(node, TeamsRPC, :delete_file_system, [id, org_key, livebook_version]) end + def delete_app_folder(node, app_folder) do + :erpc.call(node, TeamsRPC, :delete_app_folder, [app_folder]) + end + # Actions def upload_app_deployment( From f2ea902fd7634338b26ed99a3dbca3d29787533a Mon Sep 17 00:00:00 2001 From: Alexandre de Souza Date: Wed, 29 Oct 2025 17:19:55 -0300 Subject: [PATCH 5/7] Send app folder id to Livebook Teams --- lib/livebook/live_markdown/export.ex | 3 +- lib/livebook/live_markdown/import.ex | 3 + lib/livebook/notebook/app_settings.ex | 10 ++- lib/livebook/teams/app_deployment.ex | 1 + lib/livebook/teams/requests.ex | 2 + .../session_live/app_settings_component.ex | 2 +- .../live/session_live/app_teams_live.ex | 20 ++++- test/livebook/live_markdown/export_test.exs | 5 +- test/livebook/live_markdown/import_test.exs | 5 +- test/livebook_teams/cli/deploy_test.exs | 52 +++++++++++++ test/livebook_teams/web/session_live_test.exs | 76 +++++++++++++++++-- test/support/integration/teams_rpc.ex | 4 + 12 files changed, 166 insertions(+), 17 deletions(-) diff --git a/lib/livebook/live_markdown/export.ex b/lib/livebook/live_markdown/export.ex index 0ffa19f44b6..1baca768b70 100644 --- a/lib/livebook/live_markdown/export.ex +++ b/lib/livebook/live_markdown/export.ex @@ -113,7 +113,8 @@ defmodule Livebook.LiveMarkdown.Export do :auto_shutdown_ms, :access_type, :show_source, - :output_type + :output_type, + :app_folder_id ] put_unless_default( diff --git a/lib/livebook/live_markdown/import.ex b/lib/livebook/live_markdown/import.ex index c0ab4782cea..cb4a132527e 100644 --- a/lib/livebook/live_markdown/import.ex +++ b/lib/livebook/live_markdown/import.ex @@ -495,6 +495,9 @@ defmodule Livebook.LiveMarkdown.Import do {"show_source", show_source}, attrs -> Map.put(attrs, :show_source, show_source) + {"app_folder_id", app_folder_id}, attrs -> + Map.put(attrs, :app_folder_id, app_folder_id) + {"output_type", output_type}, attrs when output_type in ["all", "rich"] -> Map.put(attrs, :output_type, String.to_atom(output_type)) diff --git a/lib/livebook/notebook/app_settings.ex b/lib/livebook/notebook/app_settings.ex index a68ea7a43b0..ec922bbd18d 100644 --- a/lib/livebook/notebook/app_settings.ex +++ b/lib/livebook/notebook/app_settings.ex @@ -14,7 +14,8 @@ defmodule Livebook.Notebook.AppSettings do access_type: access_type(), password: String.t() | nil, show_source: boolean(), - output_type: output_type() + output_type: output_type(), + app_folder_id: String.t() | nil } @type access_type :: :public | :protected @@ -33,6 +34,7 @@ defmodule Livebook.Notebook.AppSettings do field :password, :string field :show_source, :boolean field :output_type, Ecto.Enum, values: [:all, :rich] + field :app_folder_id, :string end @doc """ @@ -49,7 +51,8 @@ defmodule Livebook.Notebook.AppSettings do access_type: :protected, password: generate_password(), show_source: false, - output_type: :all + output_type: :all, + app_folder_id: nil } end @@ -82,7 +85,8 @@ defmodule Livebook.Notebook.AppSettings do :auto_shutdown_ms, :access_type, :show_source, - :output_type + :output_type, + :app_folder_id ]) |> validate_required([ :slug, diff --git a/lib/livebook/teams/app_deployment.ex b/lib/livebook/teams/app_deployment.ex index 36e107213c7..92d724db269 100644 --- a/lib/livebook/teams/app_deployment.ex +++ b/lib/livebook/teams/app_deployment.ex @@ -77,6 +77,7 @@ defmodule Livebook.Teams.AppDeployment do title: notebook.name, multi_session: notebook.app_settings.multi_session, access_type: notebook.app_settings.access_type, + app_folder_id: notebook.app_settings.app_folder_id, hub_id: notebook.hub_id, deployment_group_id: notebook.deployment_group_id, file: zip_content diff --git a/lib/livebook/teams/requests.ex b/lib/livebook/teams/requests.ex index b2ceb9031e2..0ffe623f0ed 100644 --- a/lib/livebook/teams/requests.ex +++ b/lib/livebook/teams/requests.ex @@ -188,6 +188,7 @@ defmodule Livebook.Teams.Requests do slug: app_deployment.slug, multi_session: app_deployment.multi_session, access_type: app_deployment.access_type, + app_folder_id: app_deployment.app_folder_id, deployment_group_id: app_deployment.deployment_group_id, sha: app_deployment.sha } @@ -249,6 +250,7 @@ defmodule Livebook.Teams.Requests do slug: app_deployment.slug, multi_session: app_deployment.multi_session, access_type: app_deployment.access_type, + app_folder_id: app_deployment.app_folder_id, deployment_group_id: deployment_group_id, sha: app_deployment.sha } diff --git a/lib/livebook_web/live/session_live/app_settings_component.ex b/lib/livebook_web/live/session_live/app_settings_component.ex index 20278c494c8..b2fedcbf730 100644 --- a/lib/livebook_web/live/session_live/app_settings_component.ex +++ b/lib/livebook_web/live/session_live/app_settings_component.ex @@ -49,7 +49,7 @@ defmodule LivebookWeb.SessionLive.AppSettingsComponent do <.text_field field={f[:slug]} label="Slug" spellcheck="false" phx-debounce /> <.select_field :if={@teams_enabled} - field={f[:group]} + field={f[:app_folder_id]} label="Folder" prompt="Select a folder..." options={@app_folder_options} diff --git a/lib/livebook_web/live/session_live/app_teams_live.ex b/lib/livebook_web/live/session_live/app_teams_live.ex index 9e4933d9e85..676130bb903 100644 --- a/lib/livebook_web/live/session_live/app_teams_live.ex +++ b/lib/livebook_web/live/session_live/app_teams_live.ex @@ -212,7 +212,11 @@ defmodule LivebookWeb.SessionLive.AppTeamsLive do

Current version:

- <.app_deployment_card app_deployment={@app_deployment} deployment_group={@deployment_group} /> + <.app_deployment_card + app_deployment={@app_deployment} + deployment_group={@deployment_group} + hub={@hub} + />
<.message_box :if={@num_agents[@deployment_group.id] == nil} kind="warning"> @@ -293,6 +297,7 @@ defmodule LivebookWeb.SessionLive.AppTeamsLive do :if={@app_deployment} app_deployment={@app_deployment} deployment_group={@deployment_group} + hub={@hub} />
<.button color="gray" outlined phx-click="go_deployment_groups"> @@ -392,6 +397,9 @@ defmodule LivebookWeb.SessionLive.AppTeamsLive do <.labeled_text label="Title"> {@app_deployment.title} + <.labeled_text label="Folder"> + {app_folder_name(@hub, @app_deployment.app_folder_id)} + <.labeled_text label="Deployed by"> {@app_deployment.deployed_by} @@ -593,4 +601,14 @@ defmodule LivebookWeb.SessionLive.AppTeamsLive do String.replace(acc, "%{#{key}}", to_string(value)) end) end + + defp app_folder_name(%{id: "team-" <> _}, id) when id in [nil, ""], do: "Ungrouped apps" + + defp app_folder_name(%{id: "team-" <> _} = hub, id) do + hub + |> Teams.get_app_folders() + |> Enum.find_value(&(&1.id == id && &1.name)) + end + + defp app_folder_name(_hub, _id), do: "" end diff --git a/test/livebook/live_markdown/export_test.exs b/test/livebook/live_markdown/export_test.exs index b81a9c0f303..3b3b2a0594d 100644 --- a/test/livebook/live_markdown/export_test.exs +++ b/test/livebook/live_markdown/export_test.exs @@ -1152,12 +1152,13 @@ defmodule Livebook.LiveMarkdown.ExportTest do auto_shutdown_ms: 5_000, access_type: :public, show_source: true, - output_type: :rich + output_type: :rich, + app_folder_id: "123" } } expected_document = """ - + # My Notebook """ diff --git a/test/livebook/live_markdown/import_test.exs b/test/livebook/live_markdown/import_test.exs index ec04e40c320..780d078ae25 100644 --- a/test/livebook/live_markdown/import_test.exs +++ b/test/livebook/live_markdown/import_test.exs @@ -785,7 +785,7 @@ defmodule Livebook.LiveMarkdown.ImportTest do describe "app settings" do test "imports settings" do markdown = """ - + # My Notebook """ @@ -802,7 +802,8 @@ defmodule Livebook.LiveMarkdown.ImportTest do auto_shutdown_ms: 5_000, access_type: :public, show_source: true, - output_type: :rich + output_type: :rich, + app_folder_id: "123" } } = notebook end diff --git a/test/livebook_teams/cli/deploy_test.exs b/test/livebook_teams/cli/deploy_test.exs index b645d4abe49..5b3da0642a4 100644 --- a/test/livebook_teams/cli/deploy_test.exs +++ b/test/livebook_teams/cli/deploy_test.exs @@ -56,6 +56,7 @@ defmodule LivebookCLI.Integration.DeployTest do title: ^title, slug: ^slug, deployment_group_id: ^deployment_group_id, + app_folder_id: nil, hub_id: ^hub_id, deployed_by: "CLI" }} @@ -106,6 +107,7 @@ defmodule LivebookCLI.Integration.DeployTest do title: ^title, slug: ^slug, deployment_group_id: ^deployment_group_id, + app_folder_id: nil, hub_id: ^hub_id, deployed_by: "CLI" }} @@ -168,6 +170,56 @@ defmodule LivebookCLI.Integration.DeployTest do title: ^title, slug: ^slug, deployment_group_id: ^deployment_group_id, + app_folder_id: nil, + hub_id: ^hub_id, + deployed_by: "CLI" + }} + end + + test "successfully deploys a notebook with app folder via CLI", + %{team: team, node: node, org: org, tmp_dir: tmp_dir} do + title = "App with folder" + slug = Utils.random_short_id() + app_path = Path.join(tmp_dir, "#{slug}.livemd") + {key, _} = TeamsRPC.create_org_token(node, org: org) + deployment_group = TeamsRPC.create_deployment_group(node, org: org, url: @url) + app_folder = TeamsRPC.create_app_folder(node, org: org) + + hub_id = team.id + deployment_group_id = to_string(deployment_group.id) + app_folder_id = to_string(app_folder.id) + + stamp_notebook(app_path, """ + + + # #{title} + + ## Test Section + + ```elixir + IO.puts("Hello from CLI deployed app!") + ``` + """) + + output = + ExUnit.CaptureIO.capture_io(fn -> + assert deploy( + key, + team.teams_key, + deployment_group.id, + app_path + ) == :ok + end) + + assert output =~ "* Preparing to deploy notebook #{slug}.livemd" + assert output =~ " * #{title} deployed successfully. (#{@url}/apps/#{slug})" + + assert_receive {:app_deployment_started, + %{ + title: ^title, + slug: ^slug, + deployment_group_id: ^deployment_group_id, + app_folder_id: ^app_folder_id, hub_id: ^hub_id, deployed_by: "CLI" }} diff --git a/test/livebook_teams/web/session_live_test.exs b/test/livebook_teams/web/session_live_test.exs index ff13a3fd8dc..a87972b7d6f 100644 --- a/test/livebook_teams/web/session_live_test.exs +++ b/test/livebook_teams/web/session_live_test.exs @@ -440,7 +440,8 @@ defmodule LivebookWeb.Integration.SessionLiveTest do :agents, :app_deployments, :deployment_groups, - :app_server + :app_server, + :app_folders ] test "shows a message when non-teams hub is selected", %{conn: conn, session: session} do @@ -491,7 +492,6 @@ defmodule LivebookWeb.Integration.SessionLiveTest do assert render(view) =~ "Step: add app server" assert render(view) =~ "You must set up an app server for the app to run on." - assert render(view) =~ "Awaiting an app server to be set up." [deployment_group] = Livebook.Hubs.TeamClient.get_deployment_groups(team.id) @@ -505,9 +505,8 @@ defmodule LivebookWeb.Integration.SessionLiveTest do |> element("button", "Deploy") |> render_click() - assert render(view) =~ - "App deployment created successfully" - + assert render(view) =~ "App deployment created successfully" + assert render(view) =~ "Ungrouped apps" assert render(view) =~ "#{Livebook.Config.teams_url()}/orgs/#{team.org_id}" end @@ -567,8 +566,71 @@ defmodule LivebookWeb.Integration.SessionLiveTest do |> element("button", "Deploy") |> render_click() - assert render(view) =~ - "App deployment created successfully" + assert render(view) =~ "App deployment created successfully" + assert render(view) =~ "Ungrouped apps" + end + + test "deployment flow with existing app folders in the hub", + %{team: team, conn: conn, node: node, session: session, org: org} do + Session.set_notebook_hub(session.pid, team.id) + + id = insert_deployment_group(mode: :online, hub_id: team.id).id + assert_receive {:deployment_group_created, %{id: ^id} = deployment_group} + + app_folder_id = to_string(TeamsRPC.create_app_folder(node, org: org).id) + assert_receive {:app_folder_created, %{id: ^app_folder_id} = app_folder} + + {:ok, view, _} = live(conn, ~p"/sessions/#{session.id}") + + view + |> element("a", "Deploy with Livebook Teams") + |> render_click() + + # Step: configuring valid app settings + + assert render(view) =~ "You must configure your app before deploying it." + + slug = Livebook.Utils.random_short_id() + + view + |> element(~s/#app-settings-modal form/) + |> render_submit(%{"app_settings" => %{"slug" => slug, "app_folder_id" => app_folder_id}}) + + # From this point forward we are in a child LV + view = find_live_child(view, "app-teams") + assert render(view) =~ "App deployment with Livebook Teams" + + # Step: selecting deployment group + + view + |> element(~s/[phx-click="select_deployment_group"][phx-value-id="#{deployment_group.id}"]/) + |> render_click() + + assert_receive {:operation, {:set_notebook_deployment_group, _, ^id}} + assert render(view) =~ "The selected deployment group has no app servers." + + view + |> element(~s/button/, "Add app server") + |> render_click() + + # Step: agent instance setup + + assert render(view) =~ "Step: add app server" + assert render(view) =~ "Awaiting an app server to be set up." + + [deployment_group] = Livebook.Hubs.TeamClient.get_deployment_groups(team.id) + simulate_agent_join(team, deployment_group) + + assert render(view) =~ "An app server is running" + + # Step: deploy + + view + |> element("button", "Deploy") + |> render_click() + + assert render(view) =~ "App deployment created successfully" + assert render(view) =~ app_folder.name end test "shows tooltip message if user is unauthorized to deploy apps", diff --git a/test/support/integration/teams_rpc.ex b/test/support/integration/teams_rpc.ex index 103dbccd973..4e292e7136c 100644 --- a/test/support/integration/teams_rpc.ex +++ b/test/support/integration/teams_rpc.ex @@ -163,6 +163,10 @@ defmodule Livebook.TeamsRPC do {key, :erpc.call(node, TeamsRPC, :create_org_token, [key, attrs])} end + def create_app_folder(node, attrs \\ []) do + :erpc.call(node, TeamsRPC, :create_app_folder, [attrs]) + end + # Update resource def update_authorization_group(node, authorization_group, attrs) do From 281b91fad30b207c8593b317b33b52a603c04dbb Mon Sep 17 00:00:00 2001 From: Alexandre de Souza Date: Mon, 3 Nov 2025 16:11:44 -0300 Subject: [PATCH 6/7] Apply suggestions from code review --- lib/livebook/hubs/personal.ex | 2 + lib/livebook/hubs/provider.ex | 6 ++ lib/livebook/hubs/team.ex | 6 ++ lib/livebook/live_markdown/import.ex | 17 +++++- lib/livebook/session/data.ex | 35 ++++-------- lib/livebook/teams.ex | 4 +- lib/livebook_web/live/session_live.ex | 3 +- .../session_live/app_settings_component.ex | 3 +- .../live/session_live/app_teams_live.ex | 6 +- lib/livebook_web/live/session_live/render.ex | 1 - test/livebook_teams/cli/deploy_test.exs | 56 ++----------------- .../live_markdown/import_test.exs | 48 ++++++++++++++++ test/livebook_teams/web/session_live_test.exs | 6 +- 13 files changed, 101 insertions(+), 92 deletions(-) create mode 100644 test/livebook_teams/live_markdown/import_test.exs diff --git a/lib/livebook/hubs/personal.ex b/lib/livebook/hubs/personal.ex index a708def02d1..205bc10a495 100644 --- a/lib/livebook/hubs/personal.ex +++ b/lib/livebook/hubs/personal.ex @@ -281,4 +281,6 @@ defimpl Livebook.Hubs.Provider, for: Livebook.Hubs.Personal do def deployment_groups(_personal), do: nil def get_app_specs(_personal), do: [] + + def get_app_folders(_personal), do: [] end diff --git a/lib/livebook/hubs/provider.ex b/lib/livebook/hubs/provider.ex index a1ac56259e8..4c067ddb0d8 100644 --- a/lib/livebook/hubs/provider.ex +++ b/lib/livebook/hubs/provider.ex @@ -155,4 +155,10 @@ defprotocol Livebook.Hubs.Provider do """ @spec get_app_specs(t()) :: list(Livebook.Apps.AppSpec.t()) def get_app_specs(hub) + + @doc """ + Gets the app folders from the given hub. + """ + @spec get_app_folders(t()) :: list(%{id: String.t(), name: String.t()}) + def get_app_folders(hub) end diff --git a/lib/livebook/hubs/team.ex b/lib/livebook/hubs/team.ex index 2e86e38bb8a..b7bc8c566c7 100644 --- a/lib/livebook/hubs/team.ex +++ b/lib/livebook/hubs/team.ex @@ -259,6 +259,12 @@ defimpl Livebook.Hubs.Provider, for: Livebook.Hubs.Team do end end + def get_app_folders(team) do + team.id + |> TeamClient.get_app_folders() + |> Enum.sort_by(& &1.name) + end + defp parse_secret_errors(errors_map) do Teams.Requests.to_error_list(Secret, errors_map) end diff --git a/lib/livebook/live_markdown/import.ex b/lib/livebook/live_markdown/import.ex index cb4a132527e..33642b5f48b 100644 --- a/lib/livebook/live_markdown/import.ex +++ b/lib/livebook/live_markdown/import.ex @@ -667,7 +667,22 @@ defmodule Livebook.LiveMarkdown.Import do # validate it against the public key). teams_enabled = is_struct(hub, Livebook.Hubs.Team) and (hub.offline == nil or stamp_verified?) - {%{notebook | teams_enabled: teams_enabled}, stamp_verified?, messages} + {app_settings, messages} = + if app_folder_id = notebook.app_settings.app_folder_id do + app_folders = Hubs.Provider.get_app_folders(hub) + + if Enum.any?(app_folders, &(&1.id == app_folder_id)) do + {notebook.app_settings, messages} + else + {Map.replace!(notebook.app_settings, :app_folder_id, nil), + messages ++ ["found an invalid app folder, defaulting to ungrouped app folder"]} + end + else + {notebook.app_settings, messages} + end + + {%{notebook | app_settings: app_settings, teams_enabled: teams_enabled}, stamp_verified?, + messages} end defp safe_binary_split(binary, offset) diff --git a/lib/livebook/session/data.ex b/lib/livebook/session/data.ex index 358f67b4af7..e56d1561d5a 100644 --- a/lib/livebook/session/data.ex +++ b/lib/livebook/session/data.ex @@ -307,13 +307,7 @@ defmodule Livebook.Session.Data do hub = Livebook.Hubs.fetch_hub!(notebook.hub_id) hub_secrets = Livebook.Hubs.get_secrets(hub) hub_file_systems = Livebook.Hubs.get_file_systems(hub) - - hub_app_folders = - if is_struct(hub, Livebook.Hubs.Team) do - Livebook.Teams.get_app_folders(hub) - else - [] - end + hub_app_folders = Livebook.Hubs.Provider.get_app_folders(hub) startup_secrets = for secret <- Livebook.Secrets.get_startup_secrets(), @@ -1975,21 +1969,16 @@ defmodule Livebook.Session.Data do end defp set_notebook_hub({data, _} = data_actions, hub) do - teams_enabled = is_struct(hub, Livebook.Hubs.Team) - - app_folders = - if teams_enabled do - Livebook.Teams.get_app_folders(hub) - else - [] - end - data_actions |> set!( - notebook: %{data.notebook | hub_id: hub.id, teams_enabled: teams_enabled}, + notebook: %{ + data.notebook + | hub_id: hub.id, + teams_enabled: is_struct(hub, Livebook.Hubs.Team) + }, hub_secrets: Livebook.Hubs.get_secrets(hub), hub_file_systems: Livebook.Hubs.get_file_systems(hub), - hub_app_folders: app_folders + hub_app_folders: Livebook.Hubs.Provider.get_app_folders(hub) ) end @@ -2010,13 +1999,9 @@ defmodule Livebook.Session.Data do end defp sync_hub_app_folders({data, _} = data_actions) do - if data.notebook.teams_enabled do - hub = Livebook.Hubs.fetch_hub!(data.notebook.hub_id) - app_folders = Livebook.Teams.get_app_folders(hub) - set!(data_actions, hub_app_folders: app_folders) - else - data_actions - end + hub = Livebook.Hubs.fetch_hub!(data.notebook.hub_id) + app_folders = Livebook.Hubs.Provider.get_app_folders(hub) + set!(data_actions, hub_app_folders: app_folders) end defp update_notebook_hub_secret_names({data, _} = data_actions) do diff --git a/lib/livebook/teams.ex b/lib/livebook/teams.ex index be48634a955..7121f172b3f 100644 --- a/lib/livebook/teams.ex +++ b/lib/livebook/teams.ex @@ -311,8 +311,6 @@ defmodule Livebook.Teams do """ @spec get_app_folders(Team.t()) :: list(Teams.AppFolder.t()) def get_app_folders(team) do - team.id - |> TeamClient.get_app_folders() - |> Enum.sort_by(& &1.name) + Hubs.Provider.get_app_folders(team) end end diff --git a/lib/livebook_web/live/session_live.ex b/lib/livebook_web/live/session_live.ex index bbebde58687..33b5b931591 100644 --- a/lib/livebook_web/live/session_live.ex +++ b/lib/livebook_web/live/session_live.ex @@ -1872,8 +1872,7 @@ defmodule LivebookWeb.SessionLive do quarantine_file_entry_names: data.notebook.quarantine_file_entry_names, app_settings: data.notebook.app_settings, deployed_app_slug: data.deployed_app_slug, - deployment_group_id: data.notebook.deployment_group_id, - teams_enabled: data.notebook.teams_enabled + deployment_group_id: data.notebook.deployment_group_id } end diff --git a/lib/livebook_web/live/session_live/app_settings_component.ex b/lib/livebook_web/live/session_live/app_settings_component.ex index b2fedcbf730..037e567f4f7 100644 --- a/lib/livebook_web/live/session_live/app_settings_component.ex +++ b/lib/livebook_web/live/session_live/app_settings_component.ex @@ -48,14 +48,13 @@ defmodule LivebookWeb.SessionLive.AppSettingsComponent do
<.text_field field={f[:slug]} label="Slug" spellcheck="false" phx-debounce /> <.select_field - :if={@teams_enabled} field={f[:app_folder_id]} label="Folder" prompt="Select a folder..." options={@app_folder_options} help={ ~S''' - Use folders to organize how how apps are displayed. + You can create folders inside Teams to organize how apps are displayed. ''' } /> diff --git a/lib/livebook_web/live/session_live/app_teams_live.ex b/lib/livebook_web/live/session_live/app_teams_live.ex index 676130bb903..816167c1e13 100644 --- a/lib/livebook_web/live/session_live/app_teams_live.ex +++ b/lib/livebook_web/live/session_live/app_teams_live.ex @@ -602,13 +602,11 @@ defmodule LivebookWeb.SessionLive.AppTeamsLive do end) end - defp app_folder_name(%{id: "team-" <> _}, id) when id in [nil, ""], do: "Ungrouped apps" + defp app_folder_name(_hub, id) when id in [nil, ""], do: "Ungrouped apps" - defp app_folder_name(%{id: "team-" <> _} = hub, id) do + defp app_folder_name(hub, id) do hub |> Teams.get_app_folders() |> Enum.find_value(&(&1.id == id && &1.name)) end - - defp app_folder_name(_hub, _id), do: "" end diff --git a/lib/livebook_web/live/session_live/render.ex b/lib/livebook_web/live/session_live/render.ex index df300f4ded6..b11f11f1404 100644 --- a/lib/livebook_web/live/session_live/render.ex +++ b/lib/livebook_web/live/session_live/render.ex @@ -104,7 +104,6 @@ defmodule LivebookWeb.SessionLive.Render do context={@action_assigns.context} deployed_app_slug={@data_view.deployed_app_slug} app_folders={@data_view.hub_app_folders} - teams_enabled={@data_view.teams_enabled} /> diff --git a/test/livebook_teams/cli/deploy_test.exs b/test/livebook_teams/cli/deploy_test.exs index 5b3da0642a4..c5a955547c5 100644 --- a/test/livebook_teams/cli/deploy_test.exs +++ b/test/livebook_teams/cli/deploy_test.exs @@ -23,11 +23,14 @@ defmodule LivebookCLI.Integration.DeployTest do app_path = Path.join(tmp_dir, "#{slug}.livemd") {key, _} = TeamsRPC.create_org_token(node, org: org) deployment_group = TeamsRPC.create_deployment_group(node, org: org, url: @url) + app_folder = TeamsRPC.create_app_folder(node, org: org) + hub_id = team.id deployment_group_id = to_string(deployment_group.id) + app_folder_id = to_string(app_folder.id) stamp_notebook(app_path, """ - + # #{title} @@ -56,7 +59,7 @@ defmodule LivebookCLI.Integration.DeployTest do title: ^title, slug: ^slug, deployment_group_id: ^deployment_group_id, - app_folder_id: nil, + app_folder_id: ^app_folder_id, hub_id: ^hub_id, deployed_by: "CLI" }} @@ -176,55 +179,6 @@ defmodule LivebookCLI.Integration.DeployTest do }} end - test "successfully deploys a notebook with app folder via CLI", - %{team: team, node: node, org: org, tmp_dir: tmp_dir} do - title = "App with folder" - slug = Utils.random_short_id() - app_path = Path.join(tmp_dir, "#{slug}.livemd") - {key, _} = TeamsRPC.create_org_token(node, org: org) - deployment_group = TeamsRPC.create_deployment_group(node, org: org, url: @url) - app_folder = TeamsRPC.create_app_folder(node, org: org) - - hub_id = team.id - deployment_group_id = to_string(deployment_group.id) - app_folder_id = to_string(app_folder.id) - - stamp_notebook(app_path, """ - - - # #{title} - - ## Test Section - - ```elixir - IO.puts("Hello from CLI deployed app!") - ``` - """) - - output = - ExUnit.CaptureIO.capture_io(fn -> - assert deploy( - key, - team.teams_key, - deployment_group.id, - app_path - ) == :ok - end) - - assert output =~ "* Preparing to deploy notebook #{slug}.livemd" - assert output =~ " * #{title} deployed successfully. (#{@url}/apps/#{slug})" - - assert_receive {:app_deployment_started, - %{ - title: ^title, - slug: ^slug, - deployment_group_id: ^deployment_group_id, - app_folder_id: ^app_folder_id, - hub_id: ^hub_id, - deployed_by: "CLI" - }} - end - test "fails with unauthorized org token", %{team: team, node: node, org: org, tmp_dir: tmp_dir} do title = "Test CLI Deploy App" diff --git a/test/livebook_teams/live_markdown/import_test.exs b/test/livebook_teams/live_markdown/import_test.exs new file mode 100644 index 00000000000..0760aedb576 --- /dev/null +++ b/test/livebook_teams/live_markdown/import_test.exs @@ -0,0 +1,48 @@ +defmodule Livebook.Integration.LiveMarkdown.ImportTest do + use Livebook.TeamsIntegrationCase, async: true + + alias Livebook.Notebook + alias Livebook.LiveMarkdown + + @moduletag teams_for: :user + setup :teams + + @moduletag subscribe_to_hubs_topics: [:connection] + @moduletag subscribe_to_teams_topics: [:clients, :app_folders] + + describe "app settings" do + test "don't import app folder if does not exists anymore", + %{node: node, team: team, org: org} do + app_folder = TeamsRPC.create_app_folder(node, name: "delete me", org: org) + + app_folder_id = to_string(app_folder.id) + hub_id = team.id + + assert_receive {:app_folder_created, %{id: ^app_folder_id, hub_id: ^hub_id}} + + notebook = %{ + Notebook.new() + | name: "Deleted from folder", + hub_id: hub_id, + app_settings: %{Notebook.AppSettings.new() | app_folder_id: app_folder_id}, + sections: [ + %{ + Notebook.Section.new() + | name: "Section 1", + cells: [] + } + ] + } + + {markdown, []} = LiveMarkdown.Export.notebook_to_livemd(notebook) + + TeamsRPC.delete_app_folder(node, app_folder) + assert_receive {:app_folder_deleted, %{id: ^app_folder_id, hub_id: ^hub_id}} + + assert {%Notebook{name: "Deleted from folder", app_settings: %{app_folder_id: nil}}, + %{warnings: warnings}} = LiveMarkdown.Import.notebook_from_livemd(markdown) + + assert "found an invalid app folder, defaulting to ungrouped app folder" in warnings + end + end +end diff --git a/test/livebook_teams/web/session_live_test.exs b/test/livebook_teams/web/session_live_test.exs index a87972b7d6f..8f8cecfca2e 100644 --- a/test/livebook_teams/web/session_live_test.exs +++ b/test/livebook_teams/web/session_live_test.exs @@ -821,7 +821,7 @@ defmodule LivebookWeb.Integration.SessionLiveTest do id = to_string(app_folder.id) assert_receive {:app_folder_created, %{id: ^id, name: "Tidewave"}} - assert_receive {:operation, {:sync_hub_app_folders, "__server__"}} + assert_receive {:operation, {:sync_hub_app_folders, _}} assert render(view) =~ ~s() @@ -829,14 +829,14 @@ defmodule LivebookWeb.Integration.SessionLiveTest do {:ok, %{name: "Wavetide"}} = TeamsRPC.update_app_folder(node, app_folder, name: "Wavetide") assert_receive {:app_folder_updated, %{id: ^id, name: "Wavetide"}} - assert_receive {:operation, {:sync_hub_app_folders, "__server__"}} + assert_receive {:operation, {:sync_hub_app_folders, _}} refute render(view) =~ ~s() assert render(view) =~ ~s() TeamsRPC.delete_app_folder(node, app_folder) assert_receive {:app_folder_deleted, %{id: ^id, name: "Wavetide"}} - assert_receive {:operation, {:sync_hub_app_folders, "__server__"}} + assert_receive {:operation, {:sync_hub_app_folders, _}} refute render(view) =~ ~s() refute render(view) =~ ~s() end From 4509e535c55de6065e57105889371991ee72c99b Mon Sep 17 00:00:00 2001 From: Alexandre de Souza Date: Mon, 3 Nov 2025 16:25:49 -0300 Subject: [PATCH 7/7] Apply suggestion from code review --- lib/livebook/live_markdown/import.ex | 5 ++++- test/livebook_teams/live_markdown/import_test.exs | 2 +- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/lib/livebook/live_markdown/import.ex b/lib/livebook/live_markdown/import.ex index 33642b5f48b..09473c72737 100644 --- a/lib/livebook/live_markdown/import.ex +++ b/lib/livebook/live_markdown/import.ex @@ -675,7 +675,10 @@ defmodule Livebook.LiveMarkdown.Import do {notebook.app_settings, messages} else {Map.replace!(notebook.app_settings, :app_folder_id, nil), - messages ++ ["found an invalid app folder, defaulting to ungrouped app folder"]} + messages ++ + [ + "notebook is assigned to a non-existent app folder, defaulting to ungrouped app folder" + ]} end else {notebook.app_settings, messages} diff --git a/test/livebook_teams/live_markdown/import_test.exs b/test/livebook_teams/live_markdown/import_test.exs index 0760aedb576..d14c443d350 100644 --- a/test/livebook_teams/live_markdown/import_test.exs +++ b/test/livebook_teams/live_markdown/import_test.exs @@ -42,7 +42,7 @@ defmodule Livebook.Integration.LiveMarkdown.ImportTest do assert {%Notebook{name: "Deleted from folder", app_settings: %{app_folder_id: nil}}, %{warnings: warnings}} = LiveMarkdown.Import.notebook_from_livemd(markdown) - assert "found an invalid app folder, defaulting to ungrouped app folder" in warnings + assert "notebook is assigned to a non-existent app folder, defaulting to ungrouped app folder" in warnings end end end