From 74bcbd2155b8d6948c9779d6dd6f103a4fe224e1 Mon Sep 17 00:00:00 2001 From: Robert Joonas Date: Mon, 7 Sep 2026 14:17:16 +0100 Subject: [PATCH 01/11] deduplicate team role descriptions and extract role_picker --- lib/plausible_web/live/components/team.ex | 98 +++++++++++------------ lib/plausible_web/live/team_management.ex | 45 ++--------- 2 files changed, 53 insertions(+), 90 deletions(-) diff --git a/lib/plausible_web/live/components/team.ex b/lib/plausible_web/live/components/team.ex index 06d2a14218e9..f22fc1bbb9fd 100644 --- a/lib/plausible_web/live/components/team.ex +++ b/lib/plausible_web/live/components/team.ex @@ -8,6 +8,16 @@ defmodule PlausibleWeb.Live.Components.Team do alias Plausible.Auth.User + @role_descriptions [ + owner: "Manage the team without restrictions", + admin: "Manage all team settings", + editor: "Create and view new sites", + billing: "Manage subscription", + viewer: "View all sites under your team" + ] + + defp role_descriptions, do: @role_descriptions + attr(:user, User, required: true) attr(:label, :string, default: nil) attr(:role, :atom, default: nil) @@ -59,62 +69,19 @@ defmodule PlausibleWeb.Live.Components.Team do <:menu class="dropdown-items max-w-60"> <.role_item + :for={{role, description} <- role_descriptions()} user={@user} - id={"option-#{:erlang.phash2(@user.email)}-owner"} - phx-value-email={@user.email} - phx-value-name={@user.name} - role={:owner} - disabled={@disabled or @role == :owner} - dispatch_animation?={@role == :guest} - > - Manage the team without restrictions - - <.role_item - user={@user} - id={"option-#{:erlang.phash2(@user.email)}-admin"} - phx-value-email={@user.email} - phx-value-name={@user.name} - role={:admin} - disabled={@disabled or @role == :admin} - dispatch_animation?={@role == :guest} - > - Manage all team settings - - <.role_item - user={@user} - id={"option-#{:erlang.phash2(@user.email)}-editor"} + id={"option-#{:erlang.phash2(@user.email)}-#{role}"} phx-value-email={@user.email} phx-value-name={@user.name} - role={:editor} - disabled={@disabled or @role == :editor} + role={role} + disabled={@disabled or @role == role} dispatch_animation?={@role == :guest} - data-confirm={if @me?, do: lower_role_warning()} + data-confirm={ + if @me? and role in [:editor, :billing, :viewer], do: lower_role_warning() + } > - Create and view new sites - - <.role_item - user={@user} - id={"option-#{:erlang.phash2(@user.email)}-billing"} - phx-value-email={@user.email} - phx-value-name={@user.name} - role={:billing} - disabled={@disabled or @role == :billing} - dispatch_animation?={@role == :guest} - data-confirm={if @me?, do: lower_role_warning()} - > - Manage subscription - - <.role_item - user={@user} - id={"option-#{:erlang.phash2(@user.email)}-viewer"} - phx-value-email={@user.email} - phx-value-name={@user.name} - role={:viewer} - disabled={@disabled or @role == :viewer} - dispatch_animation?={@role == :guest} - data-confirm={if @me?, do: lower_role_warning()} - > - View all sites under your team + {description} <.dropdown_divider /> @@ -145,6 +112,32 @@ defmodule PlausibleWeb.Live.Components.Team do """ end + attr(:id, :string, required: true) + attr(:role, :atom, required: true) + attr(:my_role, :atom, required: true) + attr(:rest, :global) + + def role_picker(assigns) do + ~H""" + <.dropdown id={@id}> + <:button class="role inline-flex items-center gap-x-2 font-medium rounded-md px-3 py-2 text-sm border border-gray-300 dark:border-gray-750 rounded-md text-gray-800 dark:text-gray-100 dark:bg-gray-750 dark:hover:bg-gray-700 focus-visible:outline-gray-100 whitespace-nowrap truncate shadow-xs hover:shadow-sm transition-all duration-150 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 disabled:bg-gray-400 dark:disabled:text-white dark:disabled:text-gray-400 dark:disabled:bg-gray-700"> + {@role |> Atom.to_string() |> String.capitalize()} + + + <:menu class="dropdown-items max-w-60"> + <.role_item + :for={{role, description} <- role_descriptions()} + role={role} + disabled={role_change_disabled?(@my_role, role)} + {@rest} + > + {description} + + + + """ + end + attr(:role, :atom, required: true) attr(:disabled, :boolean, default: false) attr(:dispatch_animation?, :boolean, default: false) @@ -191,6 +184,9 @@ defmodule PlausibleWeb.Live.Components.Team do """ end + defp role_change_disabled?(my_role, :owner), do: my_role != :owner + defp role_change_disabled?(my_role, _role), do: my_role not in [:owner, :admin] + defp lower_role_warning() do "You're about to lower your own role. Some team management features will no longer be accessible to you, and you'll need to ask a team owner to restore your access. Do you want to continue?" end diff --git a/lib/plausible_web/live/team_management.ex b/lib/plausible_web/live/team_management.ex index be42a2a79650..2f75381f4c8a 100644 --- a/lib/plausible_web/live/team_management.ex +++ b/lib/plausible_web/live/team_management.ex @@ -117,45 +117,12 @@ defmodule PlausibleWeb.Live.TeamManagement do /> - <.dropdown id="input-role-picker"> - <:button class="role inline-flex items-center gap-x-2 font-medium rounded-md px-3 py-2 text-sm border border-gray-300 dark:border-gray-750 rounded-md text-gray-800 dark:text-gray-100 dark:bg-gray-750 dark:hover:bg-gray-700 focus-visible:outline-gray-100 whitespace-nowrap truncate shadow-xs hover:shadow-sm transition-all duration-150 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 disabled:bg-gray-400 dark:disabled:text-white dark:disabled:text-gray-400 dark:disabled:bg-gray-700"> - {@input_role |> Atom.to_string() |> String.capitalize()} - - - <:menu class="dropdown-items max-w-60"> - <.role_item role={:owner} disabled={@my_role != :owner} phx-click="switch-role"> - Manage the team without restrictions - - <.role_item - role={:admin} - disabled={@my_role not in [:owner, :admin]} - phx-click="switch-role" - > - Manage all team settings - - <.role_item - role={:editor} - disabled={@my_role not in [:owner, :admin]} - phx-click="switch-role" - > - Create and view new sites - - <.role_item - role={:billing} - disabled={@my_role not in [:owner, :admin]} - phx-click="switch-role" - > - Manage subscription - - <.role_item - role={:viewer} - disabled={@my_role not in [:owner, :admin]} - phx-click="switch-role" - > - View all sites under your team - - - + <.role_picker + id="input-role-picker" + role={@input_role} + my_role={@my_role} + phx-click="switch-role" + /> <.button id="invite-member" From 1953ae5f995f1895b4db5f812a8f026b38a5ecc3 Mon Sep 17 00:00:00 2001 From: Robert Joonas Date: Tue, 8 Sep 2026 12:12:55 +0100 Subject: [PATCH 02/11] new team setup ui --- lib/plausible_web/live/components/team.ex | 6 +- lib/plausible_web/live/team_management.ex | 148 +------- lib/plausible_web/live/team_setup.ex | 257 ++++++++++++- test/plausible_web/live/team_setup_test.exs | 379 ++++++-------------- 4 files changed, 372 insertions(+), 418 deletions(-) diff --git a/lib/plausible_web/live/components/team.ex b/lib/plausible_web/live/components/team.ex index f22fc1bbb9fd..7b41aa416deb 100644 --- a/lib/plausible_web/live/components/team.ex +++ b/lib/plausible_web/live/components/team.ex @@ -18,6 +18,10 @@ defmodule PlausibleWeb.Live.Components.Team do defp role_descriptions, do: @role_descriptions + @roles_cast_map Enum.into(@role_descriptions, %{}, fn {role, _} -> {to_string(role), role} end) + + def role_to_atom(role), do: Map.fetch!(@roles_cast_map, role) + attr(:user, User, required: true) attr(:label, :string, default: nil) attr(:role, :atom, default: nil) @@ -120,7 +124,7 @@ defmodule PlausibleWeb.Live.Components.Team do def role_picker(assigns) do ~H""" <.dropdown id={@id}> - <:button class="role inline-flex items-center gap-x-2 font-medium rounded-md px-3 py-2 text-sm border border-gray-300 dark:border-gray-750 rounded-md text-gray-800 dark:text-gray-100 dark:bg-gray-750 dark:hover:bg-gray-700 focus-visible:outline-gray-100 whitespace-nowrap truncate shadow-xs hover:shadow-sm transition-all duration-150 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 disabled:bg-gray-400 dark:disabled:text-white dark:disabled:text-gray-400 dark:disabled:bg-gray-700"> + <:button class="role w-[100px] inline-flex items-center justify-between font-medium rounded-md px-3 py-2 text-sm border border-gray-300 dark:border-gray-750 rounded-md text-gray-800 dark:text-gray-100 dark:bg-gray-750 dark:hover:bg-gray-700 focus-visible:outline-gray-100 whitespace-nowrap truncate shadow-xs hover:shadow-sm transition-all duration-150 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 disabled:bg-gray-400 dark:disabled:text-white dark:disabled:text-gray-400 dark:disabled:bg-gray-700"> {@role |> Atom.to_string() |> String.capitalize()} diff --git a/lib/plausible_web/live/team_management.ex b/lib/plausible_web/live/team_management.ex index 2f75381f4c8a..196d6501930d 100644 --- a/lib/plausible_web/live/team_management.ex +++ b/lib/plausible_web/live/team_management.ex @@ -10,42 +10,8 @@ defmodule PlausibleWeb.Live.TeamManagement do alias Plausible.Teams.Management.Layout - def mount(_params, session, socket) do - mode = - if session["mode"] == "team-setup" do - :team_setup - else - :team_management - end - - socket = - if mode == :team_setup do - setup_team_name(socket) - else - socket - end - - {:ok, socket |> assign(mode: mode) |> reset()} - end - - # In team setup the name is part of this form, so that creating the team can - # only ever happen with a name this LiveView has accepted. - defp setup_team_name( - %{assigns: %{current_user: current_user, current_team: current_team}} = socket - ) do - suggested_name = Teams.Team.suggested_name(current_user.name) - - current_team = - current_team - |> Teams.Team.name_changeset(%{name: suggested_name}) - |> Plausible.Repo.update!() - - assign(socket, - current_team: current_team, - suggested_name: suggested_name, - team_name_form: to_form(Teams.Team.name_changeset(current_team, %{})), - locked?: Plausible.Teams.Billing.solo?(current_team) - ) + def mount(_params, _session, socket) do + {:ok, reset(socket)} end defp reset(%{assigns: %{current_user: current_user, current_team: current_team}} = socket) do @@ -66,32 +32,6 @@ defmodule PlausibleWeb.Live.TeamManagement do def render(assigns) do ~H""" - <.form - :let={f} - :if={@mode == :team_setup} - for={@team_name_form} - method="post" - phx-change="update-team" - phx-submit="update-team" - phx-blur="update-team" - id="update-team-form" - class="mt-4 mb-8" - > - <.input - type="text" - placeholder={@suggested_name} - autofocus={not @locked?} - field={f[:name]} - label="Name" - width="w-full" - phx-debounce="500" - /> - - - <.label :if={@mode == :team_setup} class="mb-2"> - Team members - - <.flash_messages flash={@flash} /> - - <.button - :if={@mode == :team_setup} - id="save-layout" - type="submit" - phx-click="save-team-layout" - disabled={not @team_name_form.source.valid?} - class="mt-8 w-full" - > - Create Team - """ end - @roles Plausible.Teams.Membership.roles() -- [:guest] - @roles_cast_map Enum.into(@roles, %{}, fn role -> {to_string(role), role} end) - def handle_event("form-changed", params, socket) do {:noreply, assign(socket, input_email: params["input-email"])} end def handle_event("switch-role", %{"role" => role}, socket) do - socket = assign(socket, input_role: Map.fetch!(@roles_cast_map, role)) + socket = assign(socket, input_role: role_to_atom(role)) {:noreply, socket} end @@ -242,33 +168,6 @@ defmodule PlausibleWeb.Live.TeamManagement do {:noreply, socket} end - def handle_event("update-team", %{"team" => %{"name" => name}}, socket) do - changeset = Teams.Team.name_changeset(socket.assigns.current_team, %{name: name}) - - socket = - case Plausible.Repo.update(changeset) do - {:ok, team} -> - assign(socket, team_name_form: to_form(changeset), current_team: team) - - {:error, changeset} -> - assign(socket, team_name_form: to_form(changeset)) - end - - {:noreply, socket} - end - - def handle_event( - "save-team-layout", - _params, - socket - ) do - if team_name_accepted?(socket) do - {:noreply, save_team_layout(socket)} - else - {:noreply, put_live_flash(socket, :error, "Please fix the team name first")} - end - end - def handle_event("remove-member", %{"email" => email}, %{assigns: %{layout: layout}} = socket) do socket = case Layout.verify_removable(layout, email) do @@ -292,7 +191,7 @@ defmodule PlausibleWeb.Live.TeamManagement do %{assigns: %{layout: layout}} = socket ) do socket = - update_layout(socket, Layout.update_role(layout, email, Map.fetch!(@roles_cast_map, role))) + update_layout(socket, Layout.update_role(layout, email, role_to_atom(role))) |> push_event("js-exec", %{ to: "#member-row-#{:erlang.phash2(email)}", attr: "data-role-changed" @@ -301,28 +200,14 @@ defmodule PlausibleWeb.Live.TeamManagement do {:noreply, socket} end - defp team_name_accepted?(%{assigns: %{mode: :team_setup}} = socket) do - socket.assigns.team_name_form.source.valid? - end - - defp team_name_accepted?(_socket), do: true - defp valid_email?(email) do String.contains?(email, "@") and String.contains?(email, ".") end defp update_layout(socket, layout) do - socket = - assign(socket, - layout: layout, - team_layout_changed?: true - ) - - if socket.assigns.mode == :team_management do - save_team_layout(socket) - else - socket - end + socket + |> assign(layout: layout, team_layout_changed?: true) + |> save_team_layout() end defp save_team_layout( @@ -335,15 +220,8 @@ defmodule PlausibleWeb.Live.TeamManagement do current_team: Plausible.Repo.reload!(current_team) }) - case {result, socket.assigns.mode} do - {{:ok, _}, :team_setup} -> - socket - |> put_flash(:success, "Your team is now created") - |> redirect( - to: Routes.settings_path(socket, :team_general, __team: current_team.identifier) - ) - - {{:ok, _}, :team_management} -> + case result do + {:ok, _} -> case Teams.Memberships.team_role(current_team, current_user) do {:ok, role} when role in [:viewer, :billing, :editor] -> redirect(socket, @@ -357,28 +235,28 @@ defmodule PlausibleWeb.Live.TeamManagement do redirect(socket, to: Routes.site_path(socket, :index, __team: "none")) end - {{:error, :permission_denied}, _} -> + {:error, :permission_denied} -> socket |> put_live_flash( :error, "Permission denied" ) - {{:error, :only_one_owner}, _} -> + {:error, :only_one_owner} -> socket |> put_live_flash( :error, "The team has to have at least one owner" ) - {{:error, :disabled_2fa}, _} -> + {:error, :disabled_2fa} -> socket |> put_live_flash( :error, "User must have 2FA enabled to become an owner" ) - {{:error, {:over_limit, limit}}, _} -> + {:error, {:over_limit, limit}} -> socket |> put_live_flash( :error, diff --git a/lib/plausible_web/live/team_setup.ex b/lib/plausible_web/live/team_setup.ex index fe33db208e1c..147351d1f763 100644 --- a/lib/plausible_web/live/team_setup.ex +++ b/lib/plausible_web/live/team_setup.ex @@ -5,7 +5,9 @@ defmodule PlausibleWeb.Live.TeamSetup do use PlausibleWeb, :live_view + alias Plausible.Repo alias Plausible.Teams + alias Plausible.Teams.Management.Layout alias PlausibleWeb.Router.Helpers, as: Routes def mount(_params, _session, socket) do @@ -16,8 +18,8 @@ defmodule PlausibleWeb.Live.TeamSetup do |> put_flash(:success, "Your team is now created") |> redirect(to: Routes.settings_path(socket, :team_general)) - %Teams.Team{} -> - socket + %Teams.Team{} = team -> + setup(socket, team) _ -> socket @@ -28,9 +30,27 @@ defmodule PlausibleWeb.Live.TeamSetup do {:ok, socket} end - def render(assigns) do - assigns = assign(assigns, :locked?, Plausible.Teams.Billing.solo?(assigns.current_team)) + defp setup(socket, team) do + suggested_name = Teams.Team.suggested_name(socket.assigns.current_user.name) + + team = + team + |> Teams.Team.name_changeset(%{name: suggested_name}) + |> Repo.update!() + + {:ok, my_role} = Teams.Memberships.team_role(team, socket.assigns.current_user) + assign(socket, + current_team: team, + team_name_form: to_form(Teams.Team.name_changeset(team, %{})), + locked?: Plausible.Teams.Billing.solo?(team), + my_role: my_role, + rows: [%{id: 1, email: "", role: :viewer}], + next_row_id: 2 + ) + end + + def render(assigns) do ~H""" <.focus_box padding?={false}> <:title> @@ -43,7 +63,7 @@ defmodule PlausibleWeb.Live.TeamSetup do <:subtitle>

- Name your team, add team members and assign roles. When ready, click "Create Team" to send invitations + Name your team and optionally invite members by email. When ready, click "Create team"

@@ -53,16 +73,229 @@ defmodule PlausibleWeb.Live.TeamSetup do current_team={@current_team} locked?={@locked?} > - {live_render(@socket, PlausibleWeb.Live.TeamManagement, - id: "team-management-setup", - container: {:div, id: "team-setup"}, - session: %{ - "mode" => "team-setup" - } - )} + <.flash_messages flash={@flash} /> + + <.form + :let={f} + for={@team_name_form} + method="post" + phx-change="update-team" + phx-submit="update-team" + phx-blur="update-team" + id="update-team-form" + class="mt-4 mb-8" + > + <.input + type="text" + placeholder={"#{@current_user.name}'s team"} + autofocus={not @locked?} + field={f[:name]} + label="Name" + width="w-full" + phx-debounce="500" + /> + + +
+ <.label class="mb-0"> + Team members + + + +
+ + <.form id="member-rows-form" for={} phx-change="update-rows" phx-submit="create-team"> +
+
+
+ <.input + type="email" + name={"rows[#{row.id}][email]"} + value={row.email} + placeholder="Enter e-mail" + phx-debounce={200} + mt?={false} + /> +
+ + + + +
+
+ + <.button + id="create-team-submit" + type="submit" + disabled={not @team_name_form.source.valid?} + class="mt-8 w-full" + > + Create team + + """ end + + def handle_event("update-team", %{"team" => %{"name" => name}}, socket) do + changeset = Teams.Team.name_changeset(socket.assigns.current_team, %{name: name}) + + socket = + case Repo.update(changeset) do + {:ok, team} -> + assign(socket, team_name_form: to_form(changeset), current_team: team) + + {:error, changeset} -> + assign(socket, team_name_form: to_form(changeset)) + end + + {:noreply, socket} + end + + def handle_event("add-row", _params, socket) do + id = socket.assigns.next_row_id + rows = socket.assigns.rows ++ [%{id: id, email: "", role: :viewer}] + {:noreply, assign(socket, rows: rows, next_row_id: id + 1)} + end + + def handle_event("remove-row", %{"row-id" => row_id}, socket) do + row_id = String.to_integer(row_id) + rows = Enum.reject(socket.assigns.rows, &(&1.id == row_id)) + {:noreply, assign(socket, rows: rows)} + end + + def handle_event("select-row-role", %{"row-id" => row_id, "role" => role}, socket) do + row_id = String.to_integer(row_id) + role = PlausibleWeb.Live.Components.Team.role_to_atom(role) + + rows = + Enum.map(socket.assigns.rows, fn + %{id: ^row_id} = row -> %{row | role: role} + row -> row + end) + + {:noreply, assign(socket, rows: rows)} + end + + def handle_event("update-rows", params, socket) do + rows_params = Map.get(params, "rows", %{}) + + rows = + Enum.map(socket.assigns.rows, fn row -> + case rows_params[to_string(row.id)] do + %{"email" => email} -> %{row | email: String.trim(email)} + _ -> row + end + end) + + {:noreply, assign(socket, rows: rows)} + end + + def handle_event("create-team", params, socket) do + if socket.assigns.team_name_form.source.valid? do + create_team(socket, Map.get(params, "rows", %{})) + else + {:noreply, put_live_flash(socket, :error, "Please fix the team name first")} + end + end + + defp create_team(socket, rows_params) do + entries = + socket.assigns.rows + |> Enum.map(fn row -> + email = + rows_params + |> Map.get(to_string(row.id), %{}) + |> Map.get("email", "") + |> String.trim() + + %{row | email: email} + end) + |> Enum.filter(&(&1.email != "")) + + emails = Enum.map(entries, & &1.email) + + cond do + Enum.any?(emails, &(not valid_email?(&1))) -> + {:noreply, put_live_flash(socket, :error, "Make sure all e-mails are valid")} + + Enum.uniq(emails) != emails -> + {:noreply, put_live_flash(socket, :error, "Make sure e-mails are unique")} + + true -> + layout = + Enum.reduce(entries, %{}, fn %{email: email, role: role}, layout -> + Layout.schedule_send(layout, email, role) + end) + + persist_layout(socket, layout) + end + end + + defp persist_layout(socket, layout) do + case Layout.persist(layout, %{ + current_user: socket.assigns.current_user, + current_team: socket.assigns.current_team + }) do + {:ok, _} -> + {:noreply, + socket + |> put_flash(:success, "Your team is now created") + |> redirect( + to: + Routes.settings_path(socket, :team_general, + __team: socket.assigns.current_team.identifier + ) + )} + + {:error, :permission_denied} -> + {:noreply, put_live_flash(socket, :error, "Permission denied")} + + {:error, :only_one_owner} -> + {:noreply, put_live_flash(socket, :error, "The team has to have at least one owner")} + + {:error, :disabled_2fa} -> + {:noreply, + put_live_flash(socket, :error, "User must have 2FA enabled to become an owner")} + + {:error, {:over_limit, limit}} -> + {:noreply, + put_live_flash( + socket, + :error, + "Your account is limited to #{limit} team members. You can upgrade your plan to increase this limit" + )} + end + end + + defp valid_email?(email) do + String.contains?(email, "@") and String.contains?(email, ".") + end end diff --git a/test/plausible_web/live/team_setup_test.exs b/test/plausible_web/live/team_setup_test.exs index 9089500d46f8..ff907575dbf4 100644 --- a/test/plausible_web/live/team_setup_test.exs +++ b/test/plausible_web/live/team_setup_test.exs @@ -31,7 +31,7 @@ defmodule PlausibleWeb.Live.TeamSetupTest do {:ok, conn: conn} = log_in(%{user: user, conn: conn}) {:ok, team} = Teams.get_or_create(user) - {_lv, html} = get_child_lv(conn, with_html?: true) + {:ok, _lv, html} = live(conn, @url) expected = String.duplicate("a", 43) <> "'s team" @@ -46,7 +46,7 @@ defmodule PlausibleWeb.Live.TeamSetupTest do {:ok, conn: conn} = log_in(%{user: user, conn: conn}) {:ok, team} = Teams.get_or_create(user) - {_lv, html} = get_child_lv(conn, with_html?: true) + {:ok, _lv, html} = live(conn, @url) assert text_of_attr(html, ~s|input#update-team-form_name[name="team[name]"]|, "value") == "My team" @@ -59,7 +59,7 @@ defmodule PlausibleWeb.Live.TeamSetupTest do {:ok, conn: conn} = log_in(%{user: user, conn: conn}) {:ok, team} = Teams.get_or_create(user) - {_lv, html} = get_child_lv(conn, with_html?: true) + {:ok, _lv, html} = live(conn, @url) assert text_of_attr(html, ~s|input#update-team-form_name[name="team[name]"]|, "value") == "My team" @@ -68,7 +68,7 @@ defmodule PlausibleWeb.Live.TeamSetupTest do end end - describe "/team/setup - main differences from team management" do + describe "/team/setup - team name" do setup [:create_user, :log_in, :create_team] test "renames the team on first render", %{conn: conn, team: team} do @@ -93,19 +93,16 @@ defmodule PlausibleWeb.Live.TeamSetupTest do end test "renders form", %{conn: conn} do - {:ok, lv, html} = live(conn, @url) + {:ok, _lv, html} = live(conn, @url) assert element_exists?(html, ~s|input#update-team-form_name[name="team[name]"]|) - assert element_exists?(html, ~s|button[phx-click="save-team-layout"]|) - - _ = render(lv) + assert element_exists?(html, "#create-team-submit") + assert elem_count(html, row_el()) == 1 end test "changing team name, updates team name in db", %{conn: conn, team: team} do - lv = get_child_lv(conn) + {:ok, lv, _html} = live(conn, @url) type_into_input(lv, "team[name]", "New Team Name") assert Repo.reload!(team).name == "New Team Name" - - _ = render(lv) end test "setting team name to 'My personal sites' is reserved", %{ @@ -113,15 +110,13 @@ defmodule PlausibleWeb.Live.TeamSetupTest do team: team, user: user } do - {lv, html} = get_child_lv(conn, with_html?: true) + {:ok, lv, html} = live(conn, @url) assert text_of_attr(html, ~s|input#update-team-form_name[name="team[name]"]|, "value") == "#{user.name}'s team" type_into_input(lv, "team[name]", "Team Name 1") - _ = render(lv) type_into_input(lv, "team[name]", "My personal sites") - _ = render(lv) assert Repo.reload!(team).name == "Team Name 1" end @@ -130,7 +125,7 @@ defmodule PlausibleWeb.Live.TeamSetupTest do team: team, user: user } do - lv = get_child_lv(conn) + {:ok, lv, _html} = live(conn, @url) type_into_input(lv, "team[name]", "My personal sites") @@ -139,7 +134,7 @@ defmodule PlausibleWeb.Live.TeamSetupTest do end test "setting team name containing a URL is rejected", %{conn: conn, team: team} do - lv = get_child_lv(conn) + {:ok, lv, _html} = live(conn, @url) type_into_input(lv, "team[name]", "Team Name 1") _ = render(lv) @@ -147,12 +142,12 @@ defmodule PlausibleWeb.Live.TeamSetupTest do type_into_input(lv, "team[name]", "Cheap meds at https://spam.example.com") assert render(lv) =~ "cannot contain a URL" - assert element_exists?(render(lv), "button#save-layout[disabled]") + assert element_exists?(render(lv), "#create-team-submit[disabled]") assert Repo.reload!(team).name == "Team Name 1" end test "setting team name longer than the limit is rejected", %{conn: conn, team: team} do - lv = get_child_lv(conn) + {:ok, lv, _html} = live(conn, @url) type_into_input(lv, "team[name]", "Team Name 1") _ = render(lv) @@ -160,35 +155,33 @@ defmodule PlausibleWeb.Live.TeamSetupTest do type_into_input(lv, "team[name]", String.duplicate("a", 51)) assert render(lv) =~ "should be at most 50 character(s)" - assert element_exists?(render(lv), "button#save-layout[disabled]") + assert element_exists?(render(lv), "#create-team-submit[disabled]") assert Repo.reload!(team).name == "Team Name 1" end test "creating the team is blocked while the name is rejected", %{conn: conn, team: team} do - lv = get_child_lv(conn) + {:ok, lv, html} = live(conn, @url) - refute element_exists?(render(lv), "button#save-layout[disabled]") + refute element_exists?(html, "#create-team-submit[disabled]") type_into_input(lv, "team[name]", "My personal sites") assert render(lv) =~ "is reserved" - assert element_exists?(render(lv), "button#save-layout[disabled]") + assert element_exists?(render(lv), "#create-team-submit[disabled]") # the server refuses as well, not just the disabled button - assert render_click(lv, "save-team-layout", %{}) =~ "Please fix the team name first" + assert render_click(lv, "create-team", %{}) =~ "Please fix the team name first" refute Repo.reload!(team).setup_complete end test "creating the team goes through once the name is accepted", %{conn: conn, team: team} do - lv = get_child_lv(conn) + {:ok, lv, _html} = live(conn, @url) type_into_input(lv, "team[name]", "My personal sites") - _ = render(lv) type_into_input(lv, "team[name]", "Fixed Team Name") - _ = render(lv) - save_layout(lv) + submit_form(lv) assert_redirect(lv, "/settings/team/general?__team=" <> team.identifier) @@ -212,262 +205,119 @@ defmodule PlausibleWeb.Live.TeamSetupTest do end end - describe "/team/setup - full integration" do + describe "/team/setup - adding members" do setup [:create_user, :log_in, :create_team] - test "renders member, enqueues invitation, delivers it", %{conn: conn, user: user, team: team} do - {lv, html} = get_child_lv(conn, with_html?: true) - member_row1 = find(html, "#{member_el()}:nth-of-type(1)") |> text() - assert member_row1 =~ "#{user.name}" - assert member_row1 =~ "#{user.email}" - assert member_row1 =~ "You" - - add_invite(lv, "new@example.com", "admin") - - html = render(lv) - - member_row1 = find(html, "#{member_el()}:nth-of-type(1)") |> text() - assert member_row1 =~ "new@example.com" - assert member_row1 =~ "Invited User" - assert member_row1 =~ "Invitation pending" - - member_row2 = find(html, "#{member_el()}:nth-of-type(2)") |> text() - assert member_row2 =~ "#{user.name}" - assert member_row2 =~ "#{user.email}" - - save_layout(lv) + test "starts out with a single empty row", %{conn: conn} do + {:ok, _lv, html} = live(conn, @url) + assert elem_count(html, row_el()) == 1 + end - assert_redirect(lv, "/settings/team/general?__team=" <> team.identifier) + test "add-row appends a row, remove-row removes it", %{conn: conn} do + {:ok, lv, html} = live(conn, @url) + assert elem_count(html, row_el()) == 1 - team = Repo.reload!(team) + html = add_row(lv) + assert elem_count(html, row_el()) == 2 - assert_email_delivered_with( - to: [nil: "new@example.com"], - subject: @subject_prefix <> "You've been invited to \"#{team.name}\" team" - ) + [row_id, _] = row_ids(html) + html = remove_row(lv, row_id) + assert elem_count(html, row_el()) == 1 end - test "allows updating pending invitation role in place", %{conn: conn, team: team} do - lv = get_child_lv(conn) - add_invite(lv, "new@example.com", "admin") - - html = render(lv) - - assert text_of_element(html, "#{member_el()}:nth-of-type(1) button") == "Admin" - assert text_of_element(html, "#{member_el()}:nth-of-type(2) button") == "Owner" + test "creating the team sends out an invitation for a filled row with the selected role", %{ + conn: conn, + team: team + } do + {:ok, lv, html} = live(conn, @url) + [row_id] = row_ids(html) - change_role(lv, 1, "viewer") - html = render(lv) + fill_row(lv, row_id, "new@example.com") + select_role(lv, row_id, "admin") - assert text_of_element(html, "#{member_el()}:nth-of-type(1) button") == "Viewer" + submit_form(lv) - save_layout(lv) + assert_redirect(lv, "/settings/team/general?__team=" <> team.identifier) team = Repo.reload!(team) + assert team.setup_complete assert_email_delivered_with( to: [nil: "new@example.com"], subject: @subject_prefix <> "You've been invited to \"#{team.name}\" team" ) - end - - test "allows updating membership role in place", %{conn: conn, team: team} do - member2 = add_member(team, role: :admin) - {lv, html} = get_child_lv(conn, with_html?: true) - - assert text_of_element(html, "#{member_el()}:nth-of-type(1) button") == "Owner" - assert text_of_element(html, "#{member_el()}:nth-of-type(2) button") == "Admin" - - change_role(lv, 2, "viewer") - html = render(lv) - assert text_of_element(html, "#{member_el()}:nth-of-type(2) button") == "Viewer" - - save_layout(lv) - - assert_no_emails_delivered() - - assert_team_membership(member2, team, :viewer) + assert [invitation] = Teams.Invitations.pending_team_invitations_for(team) + assert invitation.email == "new@example.com" + assert invitation.role == :admin end - test "allows updating guest membership so it moves sections and sends out promotion e-mail", - %{ - conn: conn, - user: user, - team: team - } do - site = new_site(owner: user) - add_guest(site, role: :viewer, user: new_user(name: "Mr Guest", email: "guest@example.com")) - - lv = get_child_lv(conn) - - type_into_input(lv, "team[name]", "A-Team!") - - assert Repo.reload!(team).name == "A-Team!" - - html = render(lv) - - assert elem_count(html, member_el()) == 1 - - assert text_of_element(html, "#{guest_el()}:first-of-type button") == "Guest" - - change_role(lv, 1, "viewer", guest_el()) - html = render(lv) - - assert elem_count(html, member_el()) == 2 - refute element_exists?(html, "#guest-list") - - save_layout(lv) - - assert_email_delivered_with( - to: [nil: "guest@example.com"], - subject: @subject_prefix <> "Welcome to \"A-Team!\" team" - ) - end - - @tag :ee_only - test "fails to save layout with limits breached", %{conn: conn, team: team} do - insert(:growth_subscription, team: team) - - lv = get_child_lv(conn) - html = render(lv) - refute attr_defined?(html, ~s|#team-layout-form input[name="input-email"]|, "readonly") - refute attr_defined?(html, ~s|#invite-member|, "disabled") - - add_invite(lv, "new1@example.com", "admin") - add_invite(lv, "new2@example.com", "admin") - add_invite(lv, "new3@example.com", "admin") - add_invite(lv, "new4@example.com", "admin") - - html = render(lv) + test "blank rows are ignored on submit", %{conn: conn, team: team} do + {:ok, lv, html} = live(conn, @url) + [empty_row_id] = row_ids(html) - assert attr_defined?(html, ~s|#team-layout-form input[name="input-email"]|, "readonly") - assert attr_defined?(html, ~s|#invite-member|, "disabled") + html = add_row(lv) + [^empty_row_id, filled_row_id] = row_ids(html) + fill_row(lv, filled_row_id, "second@example.com") - assert text_of_element(html, ~s/[data-test="limit-exceeded-notice"]/) =~ - "This account is limited to 3 members" - end + submit_form(lv) - test "all options are disabled for the sole owner", %{conn: conn} do - lv = get_child_lv(conn) - - options = - lv - |> render() - |> find("#{member_el()} a") + assert_redirect(lv, "/settings/team/general?__team=" <> team.identifier) - assert Enum.empty?(options) + team = Repo.reload!(team) + assert [invitation] = Teams.Invitations.pending_team_invitations_for(team) + assert invitation.email == "second@example.com" end - test "in case of >1 owner, the one owner limit is still enforced", %{conn: conn, team: team} do - _other_owner = add_member(team, role: :owner) - lv = get_child_lv(conn) - - options = - lv - |> render() - |> find("#{member_el()} a") - - refute Enum.empty?(options) - - change_role(lv, 1, "viewer") + test "rejects invalid e-mails", %{conn: conn, team: team} do + {:ok, lv, html} = live(conn, @url) + [row_id] = row_ids(html) - html = lv |> render() + fill_row(lv, row_id, "not-an-email") - assert element_exists?(html, "#{member_el()}:nth-of-type(1) a") - refute element_exists?(html, "#{member_el()}:nth-of-type(2) a") + assert submit_form(lv) =~ "Make sure all e-mails are valid" + refute Repo.reload!(team).setup_complete end - test "allows removing any type of entry", %{ - conn: conn, - user: user, - team: team - } do - member2 = add_member(team, role: :admin) - _invitation = invite_member(team, "sent@example.com", inviter: user, role: :viewer) - - site = new_site(owner: user) - - guest = - add_guest(site, - role: :viewer, - user: new_user(name: "Mr Guest", email: "guest@example.com") - ) - - lv = get_child_lv(conn) - add_invite(lv, "pending@example.com", "admin") - - html = render(lv) - - assert elem_count(html, member_el()) == 4 - assert elem_count(html, guest_el()) == 1 - - pending = find(html, "#{member_el()}:nth-of-type(1)") |> text() - sent = find(html, "#{member_el()}:nth-of-type(2)") |> text() - owner = find(html, "#{member_el()}:nth-of-type(3)") |> text() - admin = find(html, "#{member_el()}:nth-of-type(4)") |> text() - - guest_member = find(html, "#{guest_el()}:first-of-type") |> text() - - assert pending =~ "Invitation pending" - assert sent =~ "Invitation sent" - assert owner =~ "You" - assert admin != "" - assert guest_member =~ "Guest" - - remove_member(lv, 1) - # next becomes first - remove_member(lv, 1) - # last becomes second - remove_member(lv, 2) - - # remove guest - remove_member(lv, 1, guest_el()) - - html = render(lv) |> text() - - refute html =~ "Invitation pending" - refute html =~ "Invitation sent" - refute text_of_element(render(lv), "#member-list") =~ "Team member" - refute html =~ "Guest" - - save_layout(lv) - - team = Repo.reload!(team) - - assert_email_delivered_with( - to: [nil: guest.email], - subject: @subject_prefix <> "Your access to \"#{team.name}\" team has been revoked" - ) + test "rejects duplicate e-mails across rows", %{conn: conn, team: team} do + {:ok, lv, html} = live(conn, @url) + [row_id] = row_ids(html) + html = add_row(lv) + [^row_id, row_id2] = row_ids(html) - assert_email_delivered_with( - to: [nil: member2.email], - subject: @subject_prefix <> "Your access to \"#{team.name}\" team has been revoked" - ) + fill_row(lv, row_id, "dup@example.com") + fill_row(lv, row_id2, "dup@example.com") - assert_no_emails_delivered() + assert submit_form(lv) =~ "Make sure e-mails are unique" + refute Repo.reload!(team).setup_complete end - test "respawns membersip enqueued for deletion", %{ + @tag :ee_only + test "fails to create the team when the plan's member limit is breached", %{ conn: conn, team: team } do - member2 = add_member(team, role: :editor, user: new_user(email: "another@example.com")) - - lv = get_child_lv(conn) - - remove_member(lv, 1) + insert(:growth_subscription, team: team) - add_invite(lv, "another@example.com", "viewer") + {:ok, lv, html} = live(conn, @url) + [row_id] = row_ids(html) + fill_row(lv, row_id, "new1@example.com") - html = render(lv) + html = add_row(lv) + [_, row_id2] = row_ids(html) + fill_row(lv, row_id2, "new2@example.com") - assert find(html, "#{member_el()}:nth-of-type(2)") |> text() =~ "You" + html = add_row(lv) + [_, _, row_id3] = row_ids(html) + fill_row(lv, row_id3, "new3@example.com") - save_layout(lv) + html = add_row(lv) + [_, _, _, row_id4] = row_ids(html) + fill_row(lv, row_id4, "new4@example.com") + assert submit_form(lv) =~ "Your account is limited to 3 team members" + refute Repo.reload!(team).setup_complete assert_no_emails_delivered() - assert_team_membership(member2, team, :viewer) end end @@ -477,52 +327,41 @@ defmodule PlausibleWeb.Live.TeamSetupTest do |> render_change(%{id => text}) end - defp add_invite(lv, email, role) do - lv - |> element(~s|#input-role-picker a[phx-value-role="#{role}"]|) - |> render_click() + defp row_el(), do: ~s|#member-rows > div| - lv - |> element("#team-layout-form") - |> render_submit(%{ - "input-email" => email - }) + defp row_ids(html) do + html + |> find(~s|button[phx-click="remove-row"]|) + |> Enum.map(&text_of_attr(&1, "phx-value-row-id")) end - defp save_layout(lv) do + defp add_row(lv) do lv - |> element("button#save-layout") + |> element(~s|button[phx-click="add-row"]|) |> render_click() end - defp change_role(lv, index, role, main_selector \\ member_el()) do + defp remove_row(lv, row_id) do lv - |> element(~s|#{main_selector}:nth-of-type(#{index}) a[phx-value-role="#{role}"]|) + |> element(~s|button[phx-click="remove-row"][phx-value-row-id="#{row_id}"]|) |> render_click() end - defp get_child_lv(conn, opts \\ []) do - {:ok, lv, _} = live(conn, @url) - assert lv = find_live_child(lv, "team-management-setup") - - if Keyword.get(opts, :with_html?) do - {lv, render(lv)} - else - lv - end + defp fill_row(lv, row_id, email) do + lv + |> element("#member-rows-form") + |> render_change(%{"rows" => %{row_id => %{"email" => email}}}) end - defp remove_member(lv, index, main_selector \\ member_el()) do + defp select_role(lv, row_id, role) do lv - |> element(~s|#{main_selector}:nth-of-type(#{index}) a[phx-click="remove-member"]|) + |> element(~s|#role-picker-#{row_id} a[phx-value-role="#{role}"]|) |> render_click() end - defp member_el() do - ~s|#member-list div[data-test-kind="member"]| - end - - defp guest_el() do - ~s|#guest-list div[data-test-kind="guest"]| + defp submit_form(lv) do + lv + |> element("#member-rows-form") + |> render_submit() end end From 0116bee63a1dc39ca8bd940d6e7496aa8540c9db Mon Sep 17 00:00:00 2001 From: Robert Joonas Date: Tue, 8 Sep 2026 12:58:58 +0100 Subject: [PATCH 03/11] simplify form --- lib/plausible/teams/team.ex | 20 ++- lib/plausible_web/live/team_setup.ex | 86 +++++------- test/plausible_web/live/team_setup_test.exs | 144 ++++++-------------- 3 files changed, 92 insertions(+), 158 deletions(-) diff --git a/lib/plausible/teams/team.ex b/lib/plausible/teams/team.ex index 86e08967c8b4..47342f67408a 100644 --- a/lib/plausible/teams/team.ex +++ b/lib/plausible/teams/team.ex @@ -147,11 +147,21 @@ defmodule Plausible.Teams.Team do end def name_changeset(team, attrs \\ %{}) do - team - |> cast(attrs, [:name]) - |> validate_required(:name) - |> validate_name() - |> validate_exclusion(:name, [Plausible.Teams.default_name()]) + changeset = + team + |> cast(attrs, [:name]) + |> validate_name() + |> validate_required(:name) + + # validate_exclusion/3 only runs its check when the field actually changed + # relative to the struct's current value, which would let a freshly + # auto-created team (whose name already equals the reserved default) keep + # that name simply by resubmitting it unchanged. Check unconditionally. + if get_field(changeset, :name) == Plausible.Teams.default_name() do + add_error(changeset, :name, "is reserved") + else + changeset + end end def setup_changeset(team, now \\ NaiveDateTime.utc_now(:second)) do diff --git a/lib/plausible_web/live/team_setup.ex b/lib/plausible_web/live/team_setup.ex index 147351d1f763..29c6b5c8a4e2 100644 --- a/lib/plausible_web/live/team_setup.ex +++ b/lib/plausible_web/live/team_setup.ex @@ -33,16 +33,13 @@ defmodule PlausibleWeb.Live.TeamSetup do defp setup(socket, team) do suggested_name = Teams.Team.suggested_name(socket.assigns.current_user.name) - team = - team - |> Teams.Team.name_changeset(%{name: suggested_name}) - |> Repo.update!() + name_changeset = Teams.Team.name_changeset(team, %{name: suggested_name}) {:ok, my_role} = Teams.Memberships.team_role(team, socket.assigns.current_user) assign(socket, current_team: team, - team_name_form: to_form(Teams.Team.name_changeset(team, %{})), + team_name_form: to_form(name_changeset), locked?: Plausible.Teams.Billing.solo?(team), my_role: my_role, rows: [%{id: 1, email: "", role: :viewer}], @@ -78,12 +75,9 @@ defmodule PlausibleWeb.Live.TeamSetup do <.form :let={f} for={@team_name_form} - method="post" - phx-change="update-team" - phx-submit="update-team" - phx-blur="update-team" - id="update-team-form" - class="mt-4 mb-8" + id="create-team-form" + phx-change="update-rows" + phx-submit="create-team" > <.input type="text" @@ -92,26 +86,23 @@ defmodule PlausibleWeb.Live.TeamSetup do field={f[:name]} label="Name" width="w-full" - phx-debounce="500" /> - -
- <.label class="mb-0"> - Team members - - - -
+
+ <.label> + Team members + + + +
- <.form id="member-rows-form" for={} phx-change="update-rows" phx-submit="create-team">
- <.button - id="create-team-submit" - type="submit" - disabled={not @team_name_form.source.valid?} - class="mt-8 w-full" - > + <.button id="create-team-submit" type="submit" class="mt-8 w-full"> Create team @@ -164,21 +150,6 @@ defmodule PlausibleWeb.Live.TeamSetup do """ end - def handle_event("update-team", %{"team" => %{"name" => name}}, socket) do - changeset = Teams.Team.name_changeset(socket.assigns.current_team, %{name: name}) - - socket = - case Repo.update(changeset) do - {:ok, team} -> - assign(socket, team_name_form: to_form(changeset), current_team: team) - - {:error, changeset} -> - assign(socket, team_name_form: to_form(changeset)) - end - - {:noreply, socket} - end - def handle_event("add-row", _params, socket) do id = socket.assigns.next_row_id rows = socket.assigns.rows ++ [%{id: id, email: "", role: :viewer}] @@ -218,11 +189,18 @@ defmodule PlausibleWeb.Live.TeamSetup do {:noreply, assign(socket, rows: rows)} end - def handle_event("create-team", params, socket) do - if socket.assigns.team_name_form.source.valid? do - create_team(socket, Map.get(params, "rows", %{})) - else - {:noreply, put_live_flash(socket, :error, "Please fix the team name first")} + def handle_event("create-team", %{"team" => %{"name" => name}} = params, socket) do + changeset = Teams.Team.name_changeset(socket.assigns.current_team, %{name: name}) + + case Repo.update(changeset) do + {:ok, team} -> + create_team( + assign(socket, current_team: team), + Map.get(params, "rows", %{}) + ) + + {:error, changeset} -> + {:noreply, assign(socket, team_name_form: to_form(changeset))} end end diff --git a/test/plausible_web/live/team_setup_test.exs b/test/plausible_web/live/team_setup_test.exs index ff907575dbf4..255618e4c353 100644 --- a/test/plausible_web/live/team_setup_test.exs +++ b/test/plausible_web/live/team_setup_test.exs @@ -29,159 +29,111 @@ defmodule PlausibleWeb.Live.TeamSetupTest do test "shortens a long user name to fit the limit", %{conn: conn} do user = new_user(name: String.duplicate("a", 55)) {:ok, conn: conn} = log_in(%{user: user, conn: conn}) - {:ok, team} = Teams.get_or_create(user) + {:ok, _team} = Teams.get_or_create(user) {:ok, _lv, html} = live(conn, @url) expected = String.duplicate("a", 43) <> "'s team" - assert text_of_attr(html, ~s|input#update-team-form_name[name="team[name]"]|, "value") == + assert text_of_attr(html, ~s|input#create-team-form_name[name="team[name]"]|, "value") == expected - - assert Repo.reload!(team).name == expected end test "falls back to a generic name when the user name carries a URL scheme", %{conn: conn} do user = new_user(name: "Cheap meds https://spam.example.com") {:ok, conn: conn} = log_in(%{user: user, conn: conn}) - {:ok, team} = Teams.get_or_create(user) + {:ok, _team} = Teams.get_or_create(user) {:ok, _lv, html} = live(conn, @url) - assert text_of_attr(html, ~s|input#update-team-form_name[name="team[name]"]|, "value") == + assert text_of_attr(html, ~s|input#create-team-form_name[name="team[name]"]|, "value") == "My team" - - assert Repo.reload!(team).name == "My team" end test "falls back to a generic name when shortening overflows the column", %{conn: conn} do user = new_user(name: String.duplicate("๐Ÿ‘จโ€๐Ÿ‘ฉโ€๐Ÿ‘งโ€๐Ÿ‘ฆ", 36)) {:ok, conn: conn} = log_in(%{user: user, conn: conn}) - {:ok, team} = Teams.get_or_create(user) + {:ok, _team} = Teams.get_or_create(user) {:ok, _lv, html} = live(conn, @url) - assert text_of_attr(html, ~s|input#update-team-form_name[name="team[name]"]|, "value") == + assert text_of_attr(html, ~s|input#create-team-form_name[name="team[name]"]|, "value") == "My team" - - assert Repo.reload!(team).name == "My team" end end describe "/team/setup - team name" do setup [:create_user, :log_in, :create_team] - test "renames the team on first render", %{conn: conn, team: team} do + test "suggests a default name without persisting it", %{conn: conn, team: team} do assert team.name == "My personal sites" {:ok, _lv, html} = live(conn, @url) - assert text_of_attr(html, ~s|input#update-team-form_name[name="team[name]"]|, "value") == + assert text_of_attr(html, ~s|input#create-team-form_name[name="team[name]"]|, "value") == "Jane Smith's team" - assert Repo.reload!(team).name == "Jane Smith's team" + assert Repo.reload!(team).name == "My personal sites" end - test "renames even if team already has non-default name", %{conn: conn, team: team} do - assert team.name == "My personal sites" - Repo.update!(Teams.Team.name_changeset(team, %{name: "Foo"})) - {:ok, _lv, html} = live(conn, @url) + test "typing in the name field does not persist it", %{conn: conn, team: team} do + {:ok, lv, _html} = live(conn, @url) - assert text_of_attr(html, ~s|input#update-team-form_name[name="team[name]"]|, "value") == - "Jane Smith's team" + lv + |> element("#create-team-form") + |> render_change(%{"team" => %{"name" => "Some Other Name"}}) - assert Repo.reload!(team).name == "Jane Smith's team" + assert Repo.reload!(team).name == "My personal sites" end test "renders form", %{conn: conn} do {:ok, _lv, html} = live(conn, @url) - assert element_exists?(html, ~s|input#update-team-form_name[name="team[name]"]|) + assert element_exists?(html, ~s|input#create-team-form_name[name="team[name]"]|) assert element_exists?(html, "#create-team-submit") assert elem_count(html, row_el()) == 1 end - test "changing team name, updates team name in db", %{conn: conn, team: team} do + test "rejects a blank name on submit", %{conn: conn, team: team} do {:ok, lv, _html} = live(conn, @url) - type_into_input(lv, "team[name]", "New Team Name") - assert Repo.reload!(team).name == "New Team Name" - end - test "setting team name to 'My personal sites' is reserved", %{ - conn: conn, - team: team, - user: user - } do - {:ok, lv, html} = live(conn, @url) - - assert text_of_attr(html, ~s|input#update-team-form_name[name="team[name]"]|, "value") == - "#{user.name}'s team" - - type_into_input(lv, "team[name]", "Team Name 1") - type_into_input(lv, "team[name]", "My personal sites") - assert Repo.reload!(team).name == "Team Name 1" - end - - test "reserved name is rejected on the very first edit", %{ - conn: conn, - team: team, - user: user - } do - {:ok, lv, _html} = live(conn, @url) - - type_into_input(lv, "team[name]", "My personal sites") - - assert render(lv) =~ "is reserved" - assert Repo.reload!(team).name == "#{user.name}'s team" + assert finish_setup(lv, "") =~ "blank" + refute Repo.reload!(team).setup_complete end test "setting team name containing a URL is rejected", %{conn: conn, team: team} do {:ok, lv, _html} = live(conn, @url) - type_into_input(lv, "team[name]", "Team Name 1") - _ = render(lv) - - type_into_input(lv, "team[name]", "Cheap meds at https://spam.example.com") + assert finish_setup(lv, "Cheap meds at https://spam.example.com") =~ + "cannot contain a URL" - assert render(lv) =~ "cannot contain a URL" - assert element_exists?(render(lv), "#create-team-submit[disabled]") - assert Repo.reload!(team).name == "Team Name 1" + refute Repo.reload!(team).setup_complete + assert Repo.reload!(team).name == "My personal sites" end test "setting team name longer than the limit is rejected", %{conn: conn, team: team} do {:ok, lv, _html} = live(conn, @url) - type_into_input(lv, "team[name]", "Team Name 1") - _ = render(lv) + assert finish_setup(lv, String.duplicate("a", 51)) =~ + "should be at most 50 character(s)" - type_into_input(lv, "team[name]", String.duplicate("a", 51)) - - assert render(lv) =~ "should be at most 50 character(s)" - assert element_exists?(render(lv), "#create-team-submit[disabled]") - assert Repo.reload!(team).name == "Team Name 1" + refute Repo.reload!(team).setup_complete + assert Repo.reload!(team).name == "My personal sites" end - test "creating the team is blocked while the name is rejected", %{conn: conn, team: team} do - {:ok, lv, html} = live(conn, @url) - - refute element_exists?(html, "#create-team-submit[disabled]") - - type_into_input(lv, "team[name]", "My personal sites") - - assert render(lv) =~ "is reserved" - assert element_exists?(render(lv), "#create-team-submit[disabled]") - - # the server refuses as well, not just the disabled button - assert render_click(lv, "create-team", %{}) =~ "Please fix the team name first" + test "rejects the reserved default team name on submit", %{conn: conn, team: team} do + {:ok, lv, _html} = live(conn, @url) + assert finish_setup(lv, "My personal sites") =~ "is reserved" refute Repo.reload!(team).setup_complete + assert Repo.reload!(team).name == "My personal sites" end - test "creating the team goes through once the name is accepted", %{conn: conn, team: team} do + test "creating the team goes through once a valid name is submitted", %{ + conn: conn, + team: team + } do {:ok, lv, _html} = live(conn, @url) - type_into_input(lv, "team[name]", "My personal sites") - type_into_input(lv, "team[name]", "Fixed Team Name") - - submit_form(lv) + finish_setup(lv, "Fixed Team Name") assert_redirect(lv, "/settings/team/general?__team=" <> team.identifier) @@ -235,7 +187,7 @@ defmodule PlausibleWeb.Live.TeamSetupTest do fill_row(lv, row_id, "new@example.com") select_role(lv, row_id, "admin") - submit_form(lv) + finish_setup(lv) assert_redirect(lv, "/settings/team/general?__team=" <> team.identifier) @@ -260,7 +212,7 @@ defmodule PlausibleWeb.Live.TeamSetupTest do [^empty_row_id, filled_row_id] = row_ids(html) fill_row(lv, filled_row_id, "second@example.com") - submit_form(lv) + finish_setup(lv) assert_redirect(lv, "/settings/team/general?__team=" <> team.identifier) @@ -275,7 +227,7 @@ defmodule PlausibleWeb.Live.TeamSetupTest do fill_row(lv, row_id, "not-an-email") - assert submit_form(lv) =~ "Make sure all e-mails are valid" + assert finish_setup(lv) =~ "Make sure all e-mails are valid" refute Repo.reload!(team).setup_complete end @@ -288,7 +240,7 @@ defmodule PlausibleWeb.Live.TeamSetupTest do fill_row(lv, row_id, "dup@example.com") fill_row(lv, row_id2, "dup@example.com") - assert submit_form(lv) =~ "Make sure e-mails are unique" + assert finish_setup(lv) =~ "Make sure e-mails are unique" refute Repo.reload!(team).setup_complete end @@ -315,18 +267,12 @@ defmodule PlausibleWeb.Live.TeamSetupTest do [_, _, _, row_id4] = row_ids(html) fill_row(lv, row_id4, "new4@example.com") - assert submit_form(lv) =~ "Your account is limited to 3 team members" + assert finish_setup(lv) =~ "Your account is limited to 3 team members" refute Repo.reload!(team).setup_complete assert_no_emails_delivered() end end - defp type_into_input(lv, id, text) do - lv - |> element("form#update-team-form") - |> render_change(%{id => text}) - end - defp row_el(), do: ~s|#member-rows > div| defp row_ids(html) do @@ -349,7 +295,7 @@ defmodule PlausibleWeb.Live.TeamSetupTest do defp fill_row(lv, row_id, email) do lv - |> element("#member-rows-form") + |> element("#create-team-form") |> render_change(%{"rows" => %{row_id => %{"email" => email}}}) end @@ -359,9 +305,9 @@ defmodule PlausibleWeb.Live.TeamSetupTest do |> render_click() end - defp submit_form(lv) do + defp finish_setup(lv, name \\ "Jane Smith's team") do lv - |> element("#member-rows-form") - |> render_submit() + |> element("#create-team-form") + |> render_submit(%{"team" => %{"name" => name}}) end end From 40328b8f858cefaba4d82627124e688a59ee5321 Mon Sep 17 00:00:00 2001 From: Robert Joonas Date: Tue, 8 Sep 2026 15:35:19 +0100 Subject: [PATCH 04/11] move add/remove rows and role picking interactions to FE --- assets/js/liveview/live_socket.js | 3 +- assets/js/liveview/member-rows.js | 58 +++++++ lib/plausible_web/live/components/team.ex | 2 +- lib/plausible_web/live/team_setup.ex | 176 +++++++++----------- test/plausible_web/live/team_setup_test.exs | 141 +++++----------- 5 files changed, 177 insertions(+), 203 deletions(-) create mode 100644 assets/js/liveview/member-rows.js diff --git a/assets/js/liveview/live_socket.js b/assets/js/liveview/live_socket.js index ea6803110682..e1c9513fb210 100644 --- a/assets/js/liveview/live_socket.js +++ b/assets/js/liveview/live_socket.js @@ -14,11 +14,12 @@ import topbar from 'topbar' import Alpine from 'alpinejs' import CopySnippet from './copy-snippet' +import MemberRows from './member-rows' let csrfToken = document.querySelector("meta[name='csrf-token']") let websocketUrl = document.querySelector("meta[name='websocket-url']") if (csrfToken && websocketUrl) { - let Hooks = { Modal, Dropdown, CopySnippet } + let Hooks = { Modal, Dropdown, CopySnippet, MemberRows } Hooks.VerificationLifecycle = { mounted() { diff --git a/assets/js/liveview/member-rows.js b/assets/js/liveview/member-rows.js new file mode 100644 index 000000000000..e72737ff43df --- /dev/null +++ b/assets/js/liveview/member-rows.js @@ -0,0 +1,58 @@ +// Instantly adds/removes rows, and selects a role, in the "create team" +// form's member list - entirely client-side, no server round trip. Row and +// role state is plain form data (an email input and a hidden role input per +// row), read once when the form is submitted. +// +// Expects a `template[data-row-template]` (the row blueprint, with the +// literal placeholder `__ROW_ID__` standing in for the row id in its +// attributes), a `[data-row-list]` container to append/remove rows from, a +// `[data-add-row]` button, `[data-remove-row]` buttons, and role pickers +// built from a `details[data-role-picker]` containing a `[data-role-label]` +// span, a `[data-role-value]` hidden input, and `[data-role-item]` buttons. + +const ROW_ID_PLACEHOLDER = '__ROW_ID__' + +const capitalize = (s) => s.charAt(0).toUpperCase() + s.slice(1) + +export default { + mounted() { + this.template = this.el.querySelector('template[data-row-template]') + this.list = this.el.querySelector('[data-row-list]') + + this.el + .querySelector('[data-add-row]') + .addEventListener('click', () => this.addRow()) + + this.list.addEventListener('click', (e) => { + const removeButton = e.target.closest('[data-remove-row]') + if (removeButton) return this.removeRow(removeButton) + + const roleItem = e.target.closest('[data-role-item]') + if (roleItem) return this.selectRole(roleItem) + }) + }, + + addRow() { + const rowId = + window.crypto?.randomUUID?.() ?? `${Date.now()}-${Math.random()}` + + const html = this.template.innerHTML.replaceAll(ROW_ID_PLACEHOLDER, rowId) + const wrapper = document.createElement('div') + wrapper.innerHTML = html + + this.list.appendChild(wrapper.firstElementChild) + }, + + removeRow(button) { + button.closest('[data-row]').remove() + }, + + selectRole(item) { + const role = item.dataset.roleItem + const row = item.closest('[data-row]') + + row.querySelector('[data-role-value]').value = role + row.querySelector('[data-role-label]').textContent = capitalize(role) + row.querySelector('[data-role-picker]').removeAttribute('open') + } +} diff --git a/lib/plausible_web/live/components/team.ex b/lib/plausible_web/live/components/team.ex index 7b41aa416deb..3a2215c8980e 100644 --- a/lib/plausible_web/live/components/team.ex +++ b/lib/plausible_web/live/components/team.ex @@ -16,7 +16,7 @@ defmodule PlausibleWeb.Live.Components.Team do viewer: "View all sites under your team" ] - defp role_descriptions, do: @role_descriptions + def role_descriptions, do: @role_descriptions @roles_cast_map Enum.into(@role_descriptions, %{}, fn {role, _} -> {to_string(role), role} end) diff --git a/lib/plausible_web/live/team_setup.ex b/lib/plausible_web/live/team_setup.ex index 29c6b5c8a4e2..7621f12d41d1 100644 --- a/lib/plausible_web/live/team_setup.ex +++ b/lib/plausible_web/live/team_setup.ex @@ -32,18 +32,12 @@ defmodule PlausibleWeb.Live.TeamSetup do defp setup(socket, team) do suggested_name = Teams.Team.suggested_name(socket.assigns.current_user.name) - name_changeset = Teams.Team.name_changeset(team, %{name: suggested_name}) - {:ok, my_role} = Teams.Memberships.team_role(team, socket.assigns.current_user) - assign(socket, current_team: team, team_name_form: to_form(name_changeset), - locked?: Plausible.Teams.Billing.solo?(team), - my_role: my_role, - rows: [%{id: 1, email: "", role: :viewer}], - next_row_id: 2 + locked?: Plausible.Teams.Billing.solo?(team) ) end @@ -72,13 +66,7 @@ defmodule PlausibleWeb.Live.TeamSetup do > <.flash_messages flash={@flash} /> - <.form - :let={f} - for={@team_name_form} - id="create-team-form" - phx-change="update-rows" - phx-submit="create-team" - > + <.form :let={f} for={@team_name_form} id="create-team-form" phx-submit="create-team"> <.input type="text" placeholder={"#{@current_user.name}'s team"} @@ -88,56 +76,29 @@ defmodule PlausibleWeb.Live.TeamSetup do width="w-full" /> -
- <.label> - Team members - - - -
- -
-
-
- <.input - type="email" - name={"rows[#{row.id}][email]"} - value={row.email} - placeholder="Enter e-mail" - phx-debounce={200} - mt?={false} - /> -
- - +
+
+ <.label> + Team members +
+ +
+ <.member_row row={%{id: "1", email: "", role: :viewer}} /> +
+ +
<.button id="create-team-submit" type="submit" class="mt-8 w-full"> @@ -150,43 +111,56 @@ defmodule PlausibleWeb.Live.TeamSetup do """ end - def handle_event("add-row", _params, socket) do - id = socket.assigns.next_row_id - rows = socket.assigns.rows ++ [%{id: id, email: "", role: :viewer}] - {:noreply, assign(socket, rows: rows, next_row_id: id + 1)} - end - - def handle_event("remove-row", %{"row-id" => row_id}, socket) do - row_id = String.to_integer(row_id) - rows = Enum.reject(socket.assigns.rows, &(&1.id == row_id)) - {:noreply, assign(socket, rows: rows)} - end - - def handle_event("select-row-role", %{"row-id" => row_id, "role" => role}, socket) do - row_id = String.to_integer(row_id) - role = PlausibleWeb.Live.Components.Team.role_to_atom(role) + attr(:row, :map, required: true) - rows = - Enum.map(socket.assigns.rows, fn - %{id: ^row_id} = row -> %{row | role: role} - row -> row - end) - - {:noreply, assign(socket, rows: rows)} - end - - def handle_event("update-rows", params, socket) do - rows_params = Map.get(params, "rows", %{}) - - rows = - Enum.map(socket.assigns.rows, fn row -> - case rows_params[to_string(row.id)] do - %{"email" => email} -> %{row | email: String.trim(email)} - _ -> row - end - end) + defp member_row(assigns) do + ~H""" +
+
+ <.input + type="email" + name={"rows[#{@row.id}][email]"} + value={@row.email} + placeholder="Enter e-mail" + mt?={false} + /> +
- {:noreply, assign(socket, rows: rows)} +
+ + {@row.role |> Atom.to_string() |> String.capitalize()} + + + +
+ +
+
+ + + + +
+ """ end def handle_event("create-team", %{"team" => %{"name" => name}} = params, socket) do @@ -206,15 +180,15 @@ defmodule PlausibleWeb.Live.TeamSetup do defp create_team(socket, rows_params) do entries = - socket.assigns.rows - |> Enum.map(fn row -> - email = - rows_params - |> Map.get(to_string(row.id), %{}) - |> Map.get("email", "") - |> String.trim() - - %{row | email: email} + rows_params + |> Map.values() + |> Enum.map(fn row_params -> + email = row_params |> Map.get("email", "") |> String.trim() + + role = + PlausibleWeb.Live.Components.Team.role_to_atom(Map.get(row_params, "role", "viewer")) + + %{email: email, role: role} end) |> Enum.filter(&(&1.email != "")) diff --git a/test/plausible_web/live/team_setup_test.exs b/test/plausible_web/live/team_setup_test.exs index 255618e4c353..4d6093129f82 100644 --- a/test/plausible_web/live/team_setup_test.exs +++ b/test/plausible_web/live/team_setup_test.exs @@ -75,16 +75,6 @@ defmodule PlausibleWeb.Live.TeamSetupTest do assert Repo.reload!(team).name == "My personal sites" end - test "typing in the name field does not persist it", %{conn: conn, team: team} do - {:ok, lv, _html} = live(conn, @url) - - lv - |> element("#create-team-form") - |> render_change(%{"team" => %{"name" => "Some Other Name"}}) - - assert Repo.reload!(team).name == "My personal sites" - end - test "renders form", %{conn: conn} do {:ok, _lv, html} = live(conn, @url) assert element_exists?(html, ~s|input#create-team-form_name[name="team[name]"]|) @@ -95,14 +85,14 @@ defmodule PlausibleWeb.Live.TeamSetupTest do test "rejects a blank name on submit", %{conn: conn, team: team} do {:ok, lv, _html} = live(conn, @url) - assert finish_setup(lv, "") =~ "blank" + assert finish_setup(lv, name: "") =~ "blank" refute Repo.reload!(team).setup_complete end test "setting team name containing a URL is rejected", %{conn: conn, team: team} do {:ok, lv, _html} = live(conn, @url) - assert finish_setup(lv, "Cheap meds at https://spam.example.com") =~ + assert finish_setup(lv, name: "Cheap meds at https://spam.example.com") =~ "cannot contain a URL" refute Repo.reload!(team).setup_complete @@ -112,7 +102,7 @@ defmodule PlausibleWeb.Live.TeamSetupTest do test "setting team name longer than the limit is rejected", %{conn: conn, team: team} do {:ok, lv, _html} = live(conn, @url) - assert finish_setup(lv, String.duplicate("a", 51)) =~ + assert finish_setup(lv, name: String.duplicate("a", 51)) =~ "should be at most 50 character(s)" refute Repo.reload!(team).setup_complete @@ -122,7 +112,7 @@ defmodule PlausibleWeb.Live.TeamSetupTest do test "rejects the reserved default team name on submit", %{conn: conn, team: team} do {:ok, lv, _html} = live(conn, @url) - assert finish_setup(lv, "My personal sites") =~ "is reserved" + assert finish_setup(lv, name: "My personal sites") =~ "is reserved" refute Repo.reload!(team).setup_complete assert Repo.reload!(team).name == "My personal sites" end @@ -133,7 +123,7 @@ defmodule PlausibleWeb.Live.TeamSetupTest do } do {:ok, lv, _html} = live(conn, @url) - finish_setup(lv, "Fixed Team Name") + finish_setup(lv, name: "Fixed Team Name") assert_redirect(lv, "/settings/team/general?__team=" <> team.identifier) @@ -157,37 +147,27 @@ defmodule PlausibleWeb.Live.TeamSetupTest do end end + # Adding/removing rows and picking a role all happen entirely client-side + # (see assets/js/liveview/member-rows.js) - the server only ever sees the + # final "rows" form data once, on submit. These tests exercise that submit + # handling directly with the payload a real form submission would produce, + # since ExUnit's LiveViewTest can't drive the client-side JS itself. describe "/team/setup - adding members" do setup [:create_user, :log_in, :create_team] - test "starts out with a single empty row", %{conn: conn} do + test "starts out with a single empty row defaulting to viewer", %{conn: conn} do {:ok, _lv, html} = live(conn, @url) assert elem_count(html, row_el()) == 1 + assert text_of_attr(html, ~s|#member-rows input[type="hidden"]|, "value") == "viewer" end - test "add-row appends a row, remove-row removes it", %{conn: conn} do - {:ok, lv, html} = live(conn, @url) - assert elem_count(html, row_el()) == 1 - - html = add_row(lv) - assert elem_count(html, row_el()) == 2 - - [row_id, _] = row_ids(html) - html = remove_row(lv, row_id) - assert elem_count(html, row_el()) == 1 - end - - test "creating the team sends out an invitation for a filled row with the selected role", %{ + test "creating the team sends out an invitation for a filled row with the given role", %{ conn: conn, team: team } do - {:ok, lv, html} = live(conn, @url) - [row_id] = row_ids(html) - - fill_row(lv, row_id, "new@example.com") - select_role(lv, row_id, "admin") + {:ok, lv, _html} = live(conn, @url) - finish_setup(lv) + finish_setup(lv, rows: %{"1" => %{"email" => "new@example.com", "role" => "admin"}}) assert_redirect(lv, "/settings/team/general?__team=" <> team.identifier) @@ -205,14 +185,14 @@ defmodule PlausibleWeb.Live.TeamSetupTest do end test "blank rows are ignored on submit", %{conn: conn, team: team} do - {:ok, lv, html} = live(conn, @url) - [empty_row_id] = row_ids(html) - - html = add_row(lv) - [^empty_row_id, filled_row_id] = row_ids(html) - fill_row(lv, filled_row_id, "second@example.com") + {:ok, lv, _html} = live(conn, @url) - finish_setup(lv) + finish_setup(lv, + rows: %{ + "1" => %{"email" => "", "role" => "viewer"}, + "2" => %{"email" => "second@example.com", "role" => "viewer"} + } + ) assert_redirect(lv, "/settings/team/general?__team=" <> team.identifier) @@ -222,25 +202,23 @@ defmodule PlausibleWeb.Live.TeamSetupTest do end test "rejects invalid e-mails", %{conn: conn, team: team} do - {:ok, lv, html} = live(conn, @url) - [row_id] = row_ids(html) + {:ok, lv, _html} = live(conn, @url) - fill_row(lv, row_id, "not-an-email") + assert finish_setup(lv, rows: %{"1" => %{"email" => "not-an-email", "role" => "viewer"}}) =~ + "Make sure all e-mails are valid" - assert finish_setup(lv) =~ "Make sure all e-mails are valid" refute Repo.reload!(team).setup_complete end test "rejects duplicate e-mails across rows", %{conn: conn, team: team} do - {:ok, lv, html} = live(conn, @url) - [row_id] = row_ids(html) - html = add_row(lv) - [^row_id, row_id2] = row_ids(html) + {:ok, lv, _html} = live(conn, @url) - fill_row(lv, row_id, "dup@example.com") - fill_row(lv, row_id2, "dup@example.com") + rows = %{ + "1" => %{"email" => "dup@example.com", "role" => "viewer"}, + "2" => %{"email" => "dup@example.com", "role" => "admin"} + } - assert finish_setup(lv) =~ "Make sure e-mails are unique" + assert finish_setup(lv, rows: rows) =~ "Make sure e-mails are unique" refute Repo.reload!(team).setup_complete end @@ -250,24 +228,14 @@ defmodule PlausibleWeb.Live.TeamSetupTest do team: team } do insert(:growth_subscription, team: team) + {:ok, lv, _html} = live(conn, @url) - {:ok, lv, html} = live(conn, @url) - [row_id] = row_ids(html) - fill_row(lv, row_id, "new1@example.com") - - html = add_row(lv) - [_, row_id2] = row_ids(html) - fill_row(lv, row_id2, "new2@example.com") - - html = add_row(lv) - [_, _, row_id3] = row_ids(html) - fill_row(lv, row_id3, "new3@example.com") - - html = add_row(lv) - [_, _, _, row_id4] = row_ids(html) - fill_row(lv, row_id4, "new4@example.com") + rows = + for n <- 1..4, into: %{} do + {to_string(n), %{"email" => "new#{n}@example.com", "role" => "viewer"}} + end - assert finish_setup(lv) =~ "Your account is limited to 3 team members" + assert finish_setup(lv, rows: rows) =~ "Your account is limited to 3 team members" refute Repo.reload!(team).setup_complete assert_no_emails_delivered() end @@ -275,39 +243,12 @@ defmodule PlausibleWeb.Live.TeamSetupTest do defp row_el(), do: ~s|#member-rows > div| - defp row_ids(html) do - html - |> find(~s|button[phx-click="remove-row"]|) - |> Enum.map(&text_of_attr(&1, "phx-value-row-id")) - end - - defp add_row(lv) do - lv - |> element(~s|button[phx-click="add-row"]|) - |> render_click() - end - - defp remove_row(lv, row_id) do - lv - |> element(~s|button[phx-click="remove-row"][phx-value-row-id="#{row_id}"]|) - |> render_click() - end - - defp fill_row(lv, row_id, email) do - lv - |> element("#create-team-form") - |> render_change(%{"rows" => %{row_id => %{"email" => email}}}) - end - - defp select_role(lv, row_id, role) do - lv - |> element(~s|#role-picker-#{row_id} a[phx-value-role="#{role}"]|) - |> render_click() - end + defp finish_setup(lv, opts) do + name = Keyword.get(opts, :name, "Jane Smith's team") + rows = Keyword.get(opts, :rows, %{"1" => %{"email" => "", "role" => "viewer"}}) - defp finish_setup(lv, name \\ "Jane Smith's team") do lv |> element("#create-team-form") - |> render_submit(%{"team" => %{"name" => name}}) + |> render_submit(%{"team" => %{"name" => name}, "rows" => rows}) end end From 92ee57e3db50f88c99135a048ecc506c0766dcf8 Mon Sep 17 00:00:00 2001 From: Robert Joonas Date: Tue, 8 Sep 2026 16:39:50 +0100 Subject: [PATCH 05/11] improve UX (keyboard nav, ARIA, close dropdown clicking outside) --- assets/js/liveview/member-rows.js | 111 +++++++++++++++++++++++++-- lib/plausible_web/live/team_setup.ex | 17 +++- 2 files changed, 121 insertions(+), 7 deletions(-) diff --git a/assets/js/liveview/member-rows.js b/assets/js/liveview/member-rows.js index e72737ff43df..c93d078b3c4b 100644 --- a/assets/js/liveview/member-rows.js +++ b/assets/js/liveview/member-rows.js @@ -7,8 +7,10 @@ // literal placeholder `__ROW_ID__` standing in for the row id in its // attributes), a `[data-row-list]` container to append/remove rows from, a // `[data-add-row]` button, `[data-remove-row]` buttons, and role pickers -// built from a `details[data-role-picker]` containing a `[data-role-label]` -// span, a `[data-role-value]` hidden input, and `[data-role-item]` buttons. +// built from a `details[data-role-picker]` (listbox-button pattern: a +// `` trigger, a `[role=listbox]` container, and `[data-role-item]` +// `[role=option]` buttons) containing a `[data-role-label]` span and a +// `[data-role-value]` hidden input. const ROW_ID_PLACEHOLDER = '__ROW_ID__' @@ -30,6 +32,23 @@ export default { const roleItem = e.target.closest('[data-role-item]') if (roleItem) return this.selectRole(roleItem) }) + + // Native
only closes on a second click on - close it + // on an outside click too, like any other dropdown. + this.handleOutsideClick = (e) => { + this.list.querySelectorAll('[data-role-picker][open]').forEach((details) => { + if (!details.contains(e.target)) this.closeRolePicker(details) + }) + } + document.addEventListener('click', this.handleOutsideClick) + + this.list + .querySelectorAll('[data-role-picker]') + .forEach((details) => this.wireRolePicker(details)) + }, + + destroyed() { + document.removeEventListener('click', this.handleOutsideClick) }, addRow() { @@ -39,8 +58,10 @@ export default { const html = this.template.innerHTML.replaceAll(ROW_ID_PLACEHOLDER, rowId) const wrapper = document.createElement('div') wrapper.innerHTML = html + const row = wrapper.firstElementChild - this.list.appendChild(wrapper.firstElementChild) + this.list.appendChild(row) + this.wireRolePicker(row.querySelector('[data-role-picker]')) }, removeRow(button) { @@ -48,11 +69,91 @@ export default { }, selectRole(item) { - const role = item.dataset.roleItem const row = item.closest('[data-row]') + const details = row.querySelector('[data-role-picker]') + const items = [...details.querySelectorAll('[data-role-item]')] + const role = item.dataset.roleItem row.querySelector('[data-role-value]').value = role row.querySelector('[data-role-label]').textContent = capitalize(role) - row.querySelector('[data-role-picker]').removeAttribute('open') + items.forEach((i) => i.setAttribute('aria-selected', i === item)) + + this.closeRolePicker(details) + details.querySelector('summary').focus() + }, + + // Wires up the WAI-ARIA listbox-button keyboard pattern for a role picker: + // arrow keys move a roving tabindex between options (opening the listbox on + // first use if needed), Home/End jump to the ends, Escape closes and + // returns focus to the trigger, and Tab closes the listbox on its way out. + wireRolePicker(details) { + const summary = details.querySelector('summary') + const items = [...details.querySelectorAll('[data-role-item]')] + + details.addEventListener('toggle', () => { + summary.setAttribute('aria-expanded', details.open) + this.setRovingIndex(items, 0) + }) + + details.addEventListener('keydown', (e) => { + const currentIndex = items.indexOf(document.activeElement) + + switch (e.key) { + case 'ArrowDown': + e.preventDefault() + details.open = true + this.focusItem( + items, + currentIndex === -1 ? 0 : (currentIndex + 1) % items.length + ) + break + + case 'ArrowUp': + e.preventDefault() + details.open = true + this.focusItem( + items, + currentIndex === -1 + ? items.length - 1 + : (currentIndex - 1 + items.length) % items.length + ) + break + + case 'Home': + if (!details.open) return + e.preventDefault() + this.focusItem(items, 0) + break + + case 'End': + if (!details.open) return + e.preventDefault() + this.focusItem(items, items.length - 1) + break + + case 'Escape': + if (!details.open) return + this.closeRolePicker(details) + summary.focus() + break + + case 'Tab': + this.closeRolePicker(details) + break + } + }) + }, + + focusItem(items, index) { + this.setRovingIndex(items, index) + items[index].focus() + }, + + setRovingIndex(items, index) { + items.forEach((item, i) => item.setAttribute('tabindex', i === index ? '0' : '-1')) + }, + + closeRolePicker(details) { + details.removeAttribute('open') } } diff --git a/lib/plausible_web/live/team_setup.ex b/lib/plausible_web/live/team_setup.ex index 7621f12d41d1..09f541a1103f 100644 --- a/lib/plausible_web/live/team_setup.ex +++ b/lib/plausible_web/live/team_setup.ex @@ -131,15 +131,28 @@ defmodule PlausibleWeb.Live.TeamSetup do data-role-picker class="relative inline-block text-left" > - + -
+