Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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