Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Team Management UI #4997

Open
wants to merge 34 commits into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
34 commits
Select commit Hold shift + click to select a range
ff200f9
Start PromEx first; don't run the Oban plugin in test
aerosol Jan 28, 2025
bf36b8f
Implement `find_team_invitations`
aerosol Jan 28, 2025
dd9fe1e
Implement `all_members`
aerosol Jan 28, 2025
18239e5
Allow disabling e-mail notifications on team member removal
aerosol Jan 28, 2025
9b12c76
Fix visuals per @ukutath's suggestions
aerosol Jan 28, 2025
aa5290c
Add `:setup_team` test context function
aerosol Jan 28, 2025
87faab8
Don't show team settings in the sidebar, if setup incomplete
aerosol Jan 28, 2025
bda79f7
Add high-level interface for team layout arrangement
aerosol Jan 28, 2025
e08a140
Update team/setup to use `Team.Management.Layout`
aerosol Jan 28, 2025
081861e
Implement team general settings allowing layout arrangement
aerosol Jan 28, 2025
eaf627d
Format
aerosol Jan 28, 2025
9aa2971
Remove unused setup_team
aerosol Jan 28, 2025
e81f71e
Add id attributes to member dropdown elements
aerosol Jan 28, 2025
6535e54
Format
aerosol Jan 28, 2025
5d04728
Unify team management experience
aerosol Jan 29, 2025
96b9c44
Rename Invitations/Memberships getters
aerosol Feb 5, 2025
08292ab
Tweak team setup layout
aerosol Feb 5, 2025
7002971
Update team setup markers only once
aerosol Feb 5, 2025
5a78106
Update tests
aerosol Feb 5, 2025
bcb496d
Add another future regression test
aerosol Feb 5, 2025
0d32d0c
Fix typo
aerosol Feb 5, 2025
e7035dc
Prune guest memberships on guest->team member promotion
aerosol Feb 5, 2025
739f565
Remove now unnecessary `Candidates` module
aerosol Feb 5, 2025
d0b443f
Add missing tests
aerosol Feb 5, 2025
98ec51d
Merge remote-tracking branch 'origin/master' into team-people-settings
aerosol Feb 5, 2025
8df3b86
Catch up on multiple owners fixes
aerosol Feb 5, 2025
b8f0178
Add missing describe-block setup
aerosol Feb 5, 2025
54d98fa
Hopefully make Layout easier to follow
aerosol Feb 5, 2025
e54115d
Remove default prevention from dropdown
aerosol Feb 5, 2025
72399d7
Remove unused assign
aerosol Feb 5, 2025
47d37ef
Make `sorted_for_display` skip scheduled for deletion
aerosol Feb 5, 2025
c5d3e87
`use PlausibleWeb.Component`
aerosol Feb 5, 2025
0bcdb28
Use `data-test-kind` for test specific selectors
aerosol Feb 6, 2025
5e950ab
Remove `class="relative"` from `.dropdown` instances
aerosol Feb 6, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion lib/plausible/application.ex
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ defmodule Plausible.Application do

children =
[
Plausible.PromEx,
Plausible.Cache.Stats,
Plausible.Repo,
Plausible.ClickhouseRepo,
Expand Down Expand Up @@ -104,7 +105,6 @@ defmodule Plausible.Application do
end,
endpoint,
{Oban, Application.get_env(:plausible, Oban)},
Plausible.PromEx,
on_ee do
help_scout_vault()
end
Expand Down
42 changes: 26 additions & 16 deletions lib/plausible/prom_ex.ex
Original file line number Diff line number Diff line change
Expand Up @@ -3,23 +3,33 @@ defmodule Plausible.PromEx do

alias PromEx.Plugins

@plugins [
Plugins.Application,
Plugins.Beam,
Plugins.PhoenixLiveView,
{Plugins.Phoenix, router: PlausibleWeb.Router, endpoint: PlausibleWeb.Endpoint},
{Plugins.Ecto,
repos: [
Plausible.Repo,
Plausible.ClickhouseRepo,
Plausible.IngestRepo,
Plausible.AsyncInsertRepo
]},
Plausible.PromEx.Plugins.PlausibleMetrics
]

@impl true
def plugins do
[
Plugins.Application,
Plugins.Beam,
Plugins.PhoenixLiveView,
{Plugins.Phoenix, router: PlausibleWeb.Router, endpoint: PlausibleWeb.Endpoint},
{Plugins.Ecto,
repos: [
Plausible.Repo,
Plausible.ClickhouseRepo,
Plausible.IngestRepo,
Plausible.AsyncInsertRepo
]},
Plugins.Oban,
Plausible.PromEx.Plugins.PlausibleMetrics
]
if Mix.env() in [:test, :ce_test] do
# PromEx tries to query Oban's DB tables in order to retrieve metrics.
# During tests, however, this is pointless as Oban is in manual mode,
# and that leads to connection ownership clashes.
def plugins do
@plugins
end
else
def plugins do
[Plugins.Oban | @plugins]
end
end

@impl true
Expand Down
33 changes: 0 additions & 33 deletions lib/plausible/teams.ex
Original file line number Diff line number Diff line change
Expand Up @@ -273,39 +273,6 @@ defmodule Plausible.Teams do
)
end

def setup_team(team, candidates) do
inviter = Repo.preload(team, :owner).owner

setup_team_fn = fn {{email, _name}, role} ->
case Teams.Invitations.InviteToTeam.invite(team, inviter, email, role, send_email?: false) do
{:ok, invitation} -> invitation
{:error, error} -> Repo.rollback(error)
end
end

result =
Repo.transaction(fn ->
team
|> Teams.Team.setup_changeset()
|> Repo.update!()

Enum.map(candidates, setup_team_fn)
end)

case result do
{:ok, invitations} ->
Enum.each(invitations, fn invitation ->
invitee = Auth.find_user_by(email: invitation.email)
Teams.Invitations.InviteToTeam.send_invitation_email(invitation, invitee)
end)

{:ok, invitations}

{:error, {:over_limit, _}} = error ->
error
end
end

defp create_my_team(user) do
team =
"My Team"
Expand Down
11 changes: 10 additions & 1 deletion lib/plausible/teams/invitations.ex
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,16 @@ defmodule Plausible.Teams.Invitations do
end
end

def find_team_invitations(user) do
def all(%Teams.Team{} = team) do
Repo.all(
from ti in Teams.Invitation,
inner_join: inviter in assoc(ti, :inviter),
where: ti.team_id == ^team.id,
preload: [inviter: inviter]
)
end

def all(%Plausible.Auth.User{} = user) do
Repo.all(
from ti in Teams.Invitation,
inner_join: inviter in assoc(ti, :inviter),
Expand Down
47 changes: 0 additions & 47 deletions lib/plausible/teams/invitations/candidates.ex

This file was deleted.

178 changes: 178 additions & 0 deletions lib/plausible/teams/management/layout.ex
Original file line number Diff line number Diff line change
@@ -0,0 +1,178 @@
defmodule Plausible.Teams.Management.Layout do
@moduledoc """
Abstraction for team membership(s) layout - provides a high level CRUD for
setting up team memberships, including invitations. Persisting the layout,
effectively takes care of delegating the operations to specialized services
and sending out e-mail notifications on success, if need be.
To be used in UIs allowing team memberships adjustments.
"""
alias Plausible.Teams
alias Plausible.Teams.Management.Layout.Entry
alias Plausible.Repo
alias Plausible.Auth.User

@type t() :: %{String.t() => Entry.t()}

@spec init(Teams.Team.t()) :: t()
def init(%Teams.Team{} = team) do
invitations_sent = Teams.Invitations.all(team)
all_members = Teams.Memberships.all(team)
build_by_email(invitations_sent ++ all_members)
end

@spec build_by_email([Teams.Invitation.t() | Teams.Membership.t()]) ::
t()
def build_by_email(entities) do
Enum.reduce(entities, %{}, fn
%Teams.Invitation{} = invitation, acc ->
Map.put(acc, invitation.email, Entry.new(invitation))

%Teams.Membership{} = membership, acc ->
Map.put(
acc,
membership.user.email,
Entry.new(membership)
)
end)
end

@spec active_count(t()) :: non_neg_integer()
def active_count(layout) do
Enum.count(layout, fn {_, entry} -> entry.queued_op != :delete end)
end

@spec owners_count(t()) :: non_neg_integer()
def owners_count(layout) do
Enum.count(layout, fn {_, entry} -> entry.queued_op != :delete and entry.role == :owner end)
end

@spec has_guests?(t()) :: boolean()
def has_guests?(layout) do
not is_nil(
Enum.find(
layout,
fn
{_, entry} -> entry.role == :guest and entry.queued_op != :delete
end
)
)
end

@spec update_role(t(), String.t(), atom()) :: t()
def update_role(layout, email, role) do
entry = Map.fetch!(layout, email)
Map.put(layout, email, Entry.patch(entry, role: role, queued_op: :update))
end

@spec schedule_send(t(), String.t(), atom(), Keyword.t()) :: t()
def schedule_send(layout, email, role, entry_attrs \\ []) do
invitation = %Teams.Invitation{email: email, role: role}
Map.put(layout, email, Entry.new(invitation, Keyword.merge(entry_attrs, queued_op: :send)))
end

@spec schedule_delete(t(), String.t()) :: t()
def schedule_delete(layout, email) do
entry = Map.fetch!(layout, email)
Map.put(layout, email, Entry.patch(entry, queued_op: :delete))
end

@spec verify_removable(t(), String.t()) :: :ok | {:error, String.t()}
def verify_removable(layout, email) do
ensure_at_least_one_owner(layout, email)
end

@spec removable?(t(), String.t()) :: boolean()
def removable?(layout, email) do
verify_removable(layout, email) == :ok
end

@spec sorted_for_display(t()) :: [{String.t(), Entry.t()}]
def sorted_for_display(layout) do
layout
|> Enum.reject(fn {_, entry} -> entry.queued_op == :delete end)
|> Enum.sort_by(fn {email, entry} ->
primary_criterion =
case entry do
%{role: :guest, type: :invitation_pending} -> 10
%{role: :guest, type: :invitation_sent} -> 11
%{role: :guest, type: :membership} -> 12
%{type: :invitation_pending} -> 0
%{type: :invitation_sent} -> 1
%{type: :membership} -> 2
end

secondary_criterion = entry.name
tertiary_criterion = email
{primary_criterion, secondary_criterion, tertiary_criterion}
end)
end

@spec persist(t(), %{current_user: User.t(), my_team: Teams.Team.t()}) ::
{:ok, integer()} | {:error, any()}
def persist(layout, context) do
result =
Repo.transaction(fn ->
if not context.my_team.setup_complete do
context.my_team
|> Teams.Team.setup_changeset()
|> Repo.update!()
end

layout
|> sorted_for_persistence()
|> Enum.reduce([], fn {_, entry}, acc ->
persist_entry(entry, context, acc)
end)
end)

case result do
{:ok, persisted} ->
persisted
|> Enum.each(fn
{%Entry{type: :invitation_pending}, invitation} ->
invitee = Plausible.Auth.find_user_by(email: invitation.email)
Teams.Invitations.InviteToTeam.send_invitation_email(invitation, invitee)

{%Entry{type: :membership, queued_op: :delete}, team_membership} ->
Teams.Memberships.Remove.send_team_member_removed_email(team_membership)

_ ->
:noop
end)

{:ok, length(persisted)}

{:error, _} = error ->
error
end
end

defp sorted_for_persistence(layout) do
# sort by deletions first, so team member limits are triggered accurately
Enum.sort_by(layout, fn {_email, entry} ->
case entry.queued_op do
:delete -> 0
_ -> 1
end
end)
end

defp ensure_at_least_one_owner(layout, email) do
if Enum.find(layout, fn {_email, entry} ->
entry.email != email and
entry.role == :owner and
entry.type == :membership and
entry.queued_op != :delete
end),
do: :ok,
else: {:error, "The team has to have at least one owner"}
end

def persist_entry(entry, context, acc) do
case Entry.persist(entry, context) do
{:ok, :ignore} -> acc
{:ok, persist_result} -> [{entry, persist_result} | acc]
{:error, error} -> Repo.rollback(error)
end
end
end
Loading
Loading