Skip to content
Merged
Show file tree
Hide file tree
Changes from 12 commits
Commits
Show all changes
32 commits
Select commit Hold shift + click to select a range
05bec55
Migration: add consolidated views feature to enterprise plans
aerosol Nov 10, 2025
92ef4a0
Migration: Add user preferences per team table
aerosol Nov 10, 2025
0ce1fe3
Update static plan definitions
aerosol Nov 10, 2025
7ae18f5
Add feature module definition
aerosol Nov 10, 2025
b6b4edf
Display consolidated view availability in crm
aerosol Nov 10, 2025
b11e3f5
Extend ConsolidatedView interface:
aerosol Nov 10, 2025
5fa19b7
Team/User preferences schema
aerosol Nov 10, 2025
11e031c
Implement consolidated view life cycle on /sites
aerosol Nov 10, 2025
80c13dd
Enroll `consolidated_view` feature flag
aerosol Nov 10, 2025
87e593f
Consolidated view access hardening (+feature flag)
aerosol Nov 10, 2025
2ae4797
Remove no longer needed `ConsolidatedView.enabled?/1`
aerosol Nov 10, 2025
9b2bfe3
Alias PrimaDropdown
aerosol Nov 10, 2025
00556c8
No consolidates views for shared links
aerosol Nov 11, 2025
93412ec
Remove user argument from `ok_to_display?`
aerosol Nov 11, 2025
c116447
Adjust a temporary test
aerosol Nov 11, 2025
386c173
More elaborate alert
aerosol Nov 11, 2025
a54bff8
Fix responsive design issues on sites page
sanne-san Nov 11, 2025
111294d
Use the plus icon for standalone "Add website" too
aerosol Nov 11, 2025
d5d4ba7
Format
aerosol Nov 11, 2025
f2b5a2d
Fix z-index issue with dropdowns on sites page
sanne-san Nov 11, 2025
0d7b949
Remove TODOs
aerosol Nov 12, 2025
6e4409d
Make consolidated view cards disappear when searching
aerosol Nov 12, 2025
876ae2a
Clean up test
aerosol Nov 12, 2025
5108d40
Use per-team membership user preferences
aerosol Nov 12, 2025
ba695c4
Use conditional instead of `with` statement
aerosol Nov 12, 2025
9a1dff7
Inline `ensure_eligible`
aerosol Nov 12, 2025
47e30e3
Use `Map.fetch!` getting preference from default struct
aerosol Nov 12, 2025
ad8712e
fixup
aerosol Nov 12, 2025
c92e06f
Revert "Migration: add consolidated views feature to enterprise plans"
aerosol Nov 12, 2025
3ff5d0c
Merge branch 'master' into consolidated-view-billing
aerosol Nov 12, 2025
9e70222
Fix and test feature-flag effect on both view and CTA cards
aerosol Nov 12, 2025
9cab9b2
Merge remote-tracking branch 'origin/master' into consolidated-view-b…
aerosol Nov 12, 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 assets/js/dashboard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -68,7 +68,7 @@ if (container && container.dataset) {
team: {
identifier: container.dataset.teamIdentifier ?? null,
hasConsolidatedView:
container.dataset.teamHasConsolidatedView === 'true'
container.dataset.consolidatedViewAvailable === 'true'
}
}
: {
Expand Down
4 changes: 2 additions & 2 deletions assets/js/liveview/live_socket.js
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
import 'phoenix_html'
import { Socket } from 'phoenix'
import { LiveSocket } from 'phoenix_live_view'
import { Modal } from 'prima'
import { Modal, Dropdown } from 'prima'
import topbar from 'topbar'
/* eslint-enable import/no-unresolved */

Expand All @@ -15,7 +15,7 @@ import Alpine from 'alpinejs'
let csrfToken = document.querySelector("meta[name='csrf-token']")
let websocketUrl = document.querySelector("meta[name='websocket-url']")
if (csrfToken && websocketUrl) {
let Hooks = { Modal }
let Hooks = { Modal, Dropdown }
Hooks.Metrics = {
mounted() {
this.handleEvent('send-metrics', ({ event_name }) => {
Expand Down
60 changes: 45 additions & 15 deletions extra/lib/plausible/consolidated_view.ex
Original file line number Diff line number Diff line change
Expand Up @@ -16,13 +16,33 @@ defmodule Plausible.ConsolidatedView do

import Ecto.Query

@spec cta_dismissed?(User.t(), Team.t()) :: boolean()
def cta_dismissed?(%User{} = user, %Team{} = team) do
Teams.Users.get_preference(user, team, :consolidated_view_cta_dismissed)
end

@spec dismiss_cta(User.t(), Team.t()) :: :ok
def dismiss_cta(%User{} = user, %Team{} = team) do
Teams.Users.set_preference(user, team, :consolidated_view_cta_dismissed, true)

:ok
end

@spec restore_cta(User.t(), Team.t()) :: :ok
def restore_cta(%User{} = user, %Team{} = team) do
Teams.Users.set_preference(user, team, :consolidated_view_cta_dismissed, false)

:ok
end

@spec ok_to_display?(Team.t() | nil, User.t() | nil) :: boolean()
def ok_to_display?(team, user) do
with %Team{} <- team,
%User{} <- user,
true <- Plausible.Auth.is_super_admin?(user),
true <- enabled?(team),
true <- has_sites_to_consolidate?(team) do
true <- flag_enabled?(team),
true <- view_enabled?(team),
true <- has_sites_to_consolidate?(team),
:ok <- Plausible.Billing.Feature.ConsolidatedView.check_availability(team) do
true
else
_ ->
Expand Down Expand Up @@ -56,16 +76,12 @@ defmodule Plausible.ConsolidatedView do
from(s in q, where: s.consolidated == true)
end

@spec enable(Team.t()) :: {:ok, Site.t()} | {:error, :no_sites | :team_not_setup}
@spec enable(Team.t()) ::
{:ok, Site.t()} | {:error, :no_sites | :team_not_setup | :upgrade_required}
def enable(%Team{} = team) do
with :ok <- ensure_eligible(team), do: do_enable(team)
end

@spec enabled?(Team.t()) :: boolean()
def enabled?(%Team{} = team) do
not is_nil(get(team))
end

@spec disable(Team.t()) :: :ok
def disable(%Team{} = team) do
# consider `Plausible.Site.Removal.run/1` if we ever support memberships or invitations
Expand Down Expand Up @@ -159,13 +175,19 @@ defmodule Plausible.ConsolidatedView do
team.identifier
end

# TODO: Only active trials and business subscriptions should be eligible.
# This function should also call a new underlying feature module.
defp ensure_eligible(%Team{} = team) do
cond do
not Teams.setup?(team) -> {:error, :team_not_setup}
not has_sites_to_consolidate?(team) -> {:error, :no_sites}
true -> :ok
not has_sites_to_consolidate?(team) ->
{:error, :no_sites}

not Teams.setup?(team) ->
{:error, :team_not_setup}

not flag_enabled?(team) ->
{:error, :unavailable}

true ->
Plausible.Billing.Feature.ConsolidatedView.check_availability(team)
end
end

Expand All @@ -181,7 +203,7 @@ defmodule Plausible.ConsolidatedView do
end

defp has_sites_to_consolidate?(%Team{} = team) do
Teams.owned_sites_count(team) > 0
Teams.owned_sites_count(team) > 1
end

defp majority_sites_timezone(%Team{} = team) do
Expand All @@ -200,4 +222,12 @@ defmodule Plausible.ConsolidatedView do
nil -> "Etc/UTC"
end
end

defp flag_enabled?(team) do
FunWithFlags.enabled?(:consolidated_view, for: team)
end

defp view_enabled?(%Team{} = team) do
not is_nil(get(team))
end
end
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@ defmodule PlausibleWeb.CustomerSupport.Team.Components.ConsolidatedViews do
<:thead>
<.th>Domain</.th>
<.th>Timezone</.th>
<.th>Available?</.th>
<.th invisible>Dashboard</.th>
<.th invisible>24H</.th>
<.th invisible>Delete</.th>
Expand All @@ -53,6 +54,7 @@ defmodule PlausibleWeb.CustomerSupport.Team.Components.ConsolidatedViews do
<:tbody :let={consolidated_view}>
<.td>{consolidated_view.domain}</.td>
<.td>{consolidated_view.timezone}</.td>
<.td>{availability(@team)}</.td>
<.td>
<.styled_link
new_tab={true}
Expand All @@ -74,7 +76,7 @@ defmodule PlausibleWeb.CustomerSupport.Team.Components.ConsolidatedViews do
<.delete_button
phx-click="delete-consolidated-view"
phx-target={@myself}
data-confirm="Are you sure you want to delete this consolidated view?"
data-confirm="Are you sure you want to delete this consolidated view? It will be recreated whenever eligible subscription/trial accesses /sites for that team."
/>
</.td>
</:tbody>
Expand All @@ -101,4 +103,11 @@ defmodule PlausibleWeb.CustomerSupport.Team.Components.ConsolidatedViews do
success("Deleted consolidated view")
{:noreply, assign(socket, consolidated_views: [])}
end

defp availability(team) do
case Plausible.Billing.Feature.ConsolidatedView.check_availability(team) do
:ok -> "Yes"
{:error, :upgrade_required} -> "No - upgrade required"
end
end
end
12 changes: 11 additions & 1 deletion lib/plausible/billing/feature.ex
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
defmodule Plausible.Billing.Feature do
@moduledoc """
This module provides an interface for managing features, e.g. Revenue Goals,
Expand Down Expand Up @@ -71,7 +71,8 @@
Plausible.Billing.Feature.SiteSegments,
Plausible.Billing.Feature.SitesAPI,
Plausible.Billing.Feature.StatsAPI,
Plausible.Billing.Feature.SSO
Plausible.Billing.Feature.SSO,
Plausible.Billing.Feature.ConsolidatedView
]

# Generate a union type for features
Expand Down Expand Up @@ -229,6 +230,15 @@
display_name: "Single Sign-On"
end

defmodule Plausible.Billing.Feature.ConsolidatedView do
use Plausible

@moduledoc false
use Plausible.Billing.Feature,
name: :consolidated_view,
display_name: "Consolidated View"
end

defmodule Plausible.Billing.Feature.Teams do
@moduledoc """
Unlike other feature modules, this one only exists to make feature gating
Expand Down
30 changes: 30 additions & 0 deletions lib/plausible/teams/user_preference.ex
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
defmodule Plausible.Teams.UserPreference do
@moduledoc """
Team-specific user preferences schema
"""

use Ecto.Schema
import Ecto.Changeset

@type t() :: %__MODULE__{}

@options [:consolidated_view_cta_dismissed]

schema "team_user_preferences" do
field :consolidated_view_cta_dismissed, :boolean, default: false

belongs_to :team, Plausible.Teams.Team
belongs_to :user, Plausible.Auth.User

timestamps()
end

defmacro options, do: @options

def changeset(user, team, attrs \\ %{}) do
%__MODULE__{}
|> cast(attrs, @options)
|> put_assoc(:user, user)
|> put_assoc(:team, team)
end
end
31 changes: 31 additions & 0 deletions lib/plausible/teams/users.ex
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,37 @@ defmodule Plausible.Teams.Users do

alias Plausible.Repo
alias Plausible.Teams
require Teams.UserPreference

@spec set_preference(Plausible.Auth.User.t(), Teams.Team.t(), atom(), any()) ::
Teams.UserPreference.t()
def set_preference(user, team, option, value) when option in Teams.UserPreference.options() do
user
|> Teams.UserPreference.changeset(team, %{option => value})
|> Repo.insert!(
conflict_target: [:user_id, :team_id],
# This way of conflict handling enables doing upserts of options leaving
# existing, unrelated values intact.
on_conflict: from(p in Teams.UserPreference, update: [set: [{^option, ^value}]]),
returning: true
)
end

@spec get_preference(Plausible.Auth.User.t(), Teams.Team.t(), atom()) :: any()
def get_preference(user, team, option)
when option in Teams.UserPreference.options() do
defaults = %Teams.UserPreference{}

query =
from(
tup in Teams.UserPreference,
where: tup.user_id == ^user.id,
where: tup.team_id == ^team.id,
select: field(tup, ^option)
)

Repo.one(query) || Map.get(defaults, option)
end

def owned_teams(user) do
Repo.all(teams_query(user, roles: :owner))
Expand Down
41 changes: 41 additions & 0 deletions lib/plausible_web/components/prima_dropdown.ex
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
defmodule PlausibleWeb.Components.PrimaDropdown do
@moduledoc false
alias Prima.Dropdown
use Phoenix.Component

defdelegate dropdown(assigns), to: Prima.Dropdown
defdelegate dropdown_trigger(assigns), to: Prima.Dropdown

slot(:inner_block, required: true)

# placement: bottom-end should probably be default in prima. Feels more natural
# for dropdown menus than bottom-start which is the current default
def dropdown_menu(assigns) do
~H"""
<Dropdown.dropdown_menu
placement="bottom-end"
class="p-1.5 rounded-md bg-white shadow-xs ring-1 ring-gray-300 focus:outline-none"
>
{render_slot(@inner_block)}
</Dropdown.dropdown_menu>
"""
end

attr(:as, :any, default: nil)
attr(:disabled, :boolean, default: false)
attr(:rest, :global, include: ~w(navigate patch href))
slot(:inner_block, required: true)

def dropdown_item(assigns) do
~H"""
<Dropdown.dropdown_item
as={@as}
disabled={@disabled}
class="rounded-md text-gray-700 data-focus:bg-gray-100 data-focus:text-gray-900 block px-4 py-2 text-sm"
{@rest}
>
{render_slot(@inner_block)}
</Dropdown.dropdown_item>
"""
end
end
16 changes: 10 additions & 6 deletions lib/plausible_web/controllers/stats_controller.ex
Original file line number Diff line number Diff line change
Expand Up @@ -63,7 +63,7 @@ defmodule PlausibleWeb.StatsController do

consolidated_view? = Plausible.Sites.consolidated?(site)

team_has_consolidated_view? =
consolidated_view_available? =
on_ee(do: Plausible.ConsolidatedView.ok_to_display?(site.team, current_user), else: false)

team_identifier = site.team.identifier
Expand All @@ -74,6 +74,9 @@ defmodule PlausibleWeb.StatsController do
{:ok, segments} = Plausible.Segments.get_all_for_site(site, site_role)

cond do
consolidated_view? and not consolidated_view_available? and site_role != :super_admin ->
redirect(conn, to: Routes.site_path(conn, :index))

(stats_start_date && can_see_stats?) || (can_see_stats? && skip_to_dashboard?) ->
flags = get_flags(current_user, site)

Expand All @@ -96,7 +99,7 @@ defmodule PlausibleWeb.StatsController do
load_dashboard_js: true,
hide_footer?: if(ce?() || demo, do: false, else: site_role != :public),
consolidated_view?: consolidated_view?,
team_has_consolidated_view?: team_has_consolidated_view?,
consolidated_view_available?: consolidated_view_available?,
team_identifier: team_identifier
)

Expand Down Expand Up @@ -402,9 +405,9 @@ defmodule PlausibleWeb.StatsController do

embedded? = conn.params["embed"] == "true"

consolidated_view? = Plausible.Sites.consolidated?(shared_link.site)
true = Plausible.Sites.regular?(shared_link.site)

team_has_consolidated_view? =
consolidated_view_available? =
on_ee(
do: Plausible.ConsolidatedView.ok_to_display?(shared_link.site.team, current_user),
else: false
Expand Down Expand Up @@ -435,8 +438,9 @@ defmodule PlausibleWeb.StatsController do
segments: segments,
load_dashboard_js: true,
hide_footer?: if(ce?(), do: embedded?, else: embedded? || site_role != :public),
consolidated_view?: consolidated_view?,
team_has_consolidated_view?: team_has_consolidated_view?,
# no shared links for consolidated views
consolidated_view?: false,
consolidated_view_available?: consolidated_view_available?,
team_identifier: team_identifier
)
end
Expand Down
Loading
Loading