Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
90 changes: 61 additions & 29 deletions extra/lib/plausible/consolidated_view.ex
Original file line number Diff line number Diff line change
Expand Up @@ -16,18 +16,45 @@ defmodule Plausible.ConsolidatedView do

import Ecto.Query

@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
else
_ ->
false
end
@spec flag_enabled?(Team.t()) :: boolean()
def flag_enabled?(team) do
FunWithFlags.enabled?(:consolidated_view, for: team)
end

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

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

:ok
end

@spec restore_cta(User.t(), Team.t()) :: :ok
def restore_cta(%User{} = user, %Team{} = team) do
{:ok, team_membership} = Teams.Memberships.get_team_membership(team, user)

Teams.Memberships.set_preference(
team_membership,
:consolidated_view_cta_dismissed,
false
)

:ok
end

@spec ok_to_display?(Team.t() | nil) :: boolean()
def ok_to_display?(team) do
is_struct(team, Team) and
flag_enabled?(team) and
view_enabled?(team) and
has_sites_to_consolidate?(team) and
Plausible.Billing.Feature.ConsolidatedView.check_availability(team) == :ok
end

@spec reset_if_enabled(Team.t()) :: :ok
Expand Down Expand Up @@ -56,14 +83,25 @@ 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
cond do
not has_sites_to_consolidate?(team) ->
{:error, :no_sites}

@spec enabled?(Team.t()) :: boolean()
def enabled?(%Team{} = team) do
not is_nil(get(team))
not Teams.setup?(team) ->
{:error, :team_not_setup}

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

true ->
case Plausible.Billing.Feature.ConsolidatedView.check_availability(team) do
:ok -> do_enable(team)
error -> error
end
end
end

@spec disable(Team.t()) :: :ok
Expand Down Expand Up @@ -159,16 +197,6 @@ 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
end
end

defp native_stats_start_at(%Team{} = team) do
q =
from(sr in Site.regular(),
Expand All @@ -181,7 +209,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 +228,8 @@ defmodule Plausible.ConsolidatedView do
nil -> "Etc/UTC"
end
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? All existing consolidated view configuration will be lost. The view itself 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
Expand Up @@ -71,7 +71,8 @@ defmodule Plausible.Billing.Feature do
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 @@ defmodule Plausible.Billing.Feature.SSO do
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
31 changes: 31 additions & 0 deletions lib/plausible/teams/memberships.ex
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@ defmodule Plausible.Teams.Memberships do
alias Plausible.Repo
alias Plausible.Teams

require Teams.Memberships.UserPreference

@spec all(Teams.Team.t(), Keyword.t()) :: [Teams.Membership.t()]
def all(team, opts \\ []) do
exclude_guests? = Keyword.get(opts, :exclude_guests?, false)
Expand Down Expand Up @@ -223,6 +225,35 @@ defmodule Plausible.Teams.Memberships do
end
end

@spec set_preference(Teams.Membership.t(), atom(), any()) ::
Teams.Memberships.UserPreference.t()
def set_preference(team_membership, option, value)
when option in Teams.Memberships.UserPreference.options() do
team_membership
|> Teams.Memberships.UserPreference.changeset(%{option => value})
|> Repo.insert!(
conflict_target: [:team_membership_id],
on_conflict:
from(p in Teams.Memberships.UserPreference, update: [set: [{^option, ^value}]]),
returning: true
)
end

@spec get_preference(Teams.Membership.t(), atom()) :: any()
def get_preference(team_membership, option)
when option in Teams.Memberships.UserPreference.options() do
defaults = %Teams.Memberships.UserPreference{}

query =
from(
tup in Teams.Memberships.UserPreference,
where: tup.team_membership_id == ^team_membership.id,
select: field(tup, ^option)
)

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

defp get_guest_membership(site_id, user_id) do
query =
from(
Expand Down
28 changes: 28 additions & 0 deletions lib/plausible/teams/memberships/user_preference.ex
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
defmodule Plausible.Teams.Memberships.UserPreference do
@moduledoc """
Team-specific user preferences schema
"""

use Ecto.Schema
import Ecto.Changeset

@type t() :: %__MODULE__{}

@options [:consolidated_view_cta_dismissed]

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

belongs_to :team_membership, Plausible.Teams.Membership

timestamps()
end

defmacro options, do: @options

def changeset(team_membership, attrs \\ %{}) do
%__MODULE__{}
|> cast(attrs, @options)
|> put_assoc(:team_membership, team_membership)
end
end
4 changes: 2 additions & 2 deletions lib/plausible_web/components/generic.ex
Original file line number Diff line number Diff line change
Expand Up @@ -1020,7 +1020,7 @@ defmodule PlausibleWeb.Components.Generic do

def filter_bar(assigns) do
~H"""
<div class="mb-6 flex items-center justify-between" x-data>
<div class="flex items-center justify-between" x-data>
<div :if={@filtering_enabled?} class="relative rounded-md flex">
<form id="filter-form" phx-change="filter" phx-submit="filter" class="flex items-center">
<div class="text-gray-800 inline-flex items-center">
Expand All @@ -1031,7 +1031,7 @@ defmodule PlausibleWeb.Components.Generic do
type="text"
name="filter-text"
id="filter-text"
class="w-36 sm:w-full pl-8 text-sm dark:bg-gray-750 dark:text-gray-300 focus:ring-indigo-500 focus:border-indigo-500 block border-gray-300 dark:border-gray-750 rounded-md dark:placeholder:text-gray-400 focus:outline-none focus:ring-3 focus:ring-indigo-500/20 dark:focus:ring-indigo-500/25 focus:border-indigo-500"
class="w-full max-w-64 pl-8 text-sm dark:bg-gray-750 dark:text-gray-300 focus:ring-indigo-500 focus:border-indigo-500 block border-gray-300 dark:border-gray-750 rounded-md dark:placeholder:text-gray-400 focus:outline-none focus:ring-3 focus:ring-indigo-500/20 dark:focus:ring-indigo-500/25 focus:border-indigo-500"
placeholder="Press / to search"
x-ref="filter_text"
phx-debounce={200}
Expand Down
47 changes: 47 additions & 0 deletions lib/plausible_web/components/prima_dropdown.ex
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
defmodule PlausibleWeb.Components.PrimaDropdown do
@moduledoc false
alias Prima.Dropdown
use Phoenix.Component

@dropdown_item_icon_base_class "text-gray-600 dark:text-gray-400 group-hover/item:text-gray-900 group-data-focus/item:text-gray-900 dark:group-hover/item:text-gray-100 dark:group-data-focus/item:text-gray-100"

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="bg-white rounded-md shadow-lg ring-1 ring-black/5 focus:outline-none p-1.5 dark:bg-gray-800"
>
{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="group/item z-50 flex items-center gap-x-2 min-w-max rounded-md px-4 py-2 text-gray-700 text-sm dark:text-gray-300 data-focus:bg-gray-100 dark:data-focus:bg-gray-700 data-focus:text-gray-900 dark:data-focus:text-gray-100"
{@rest}
>
{render_slot(@inner_block)}
</Dropdown.dropdown_item>
"""
end

def dropdown_item_icon_class(size \\ "size-4") do
"#{size} #{@dropdown_item_icon_base_class}"
end
end
Loading
Loading