Implement pinned sites (#3469)

* Revert "Remove site pins for now"

This reverts commit 5eccf4eaf6.

* Implement basic site pin schema level logic within user specific preferences

* Add vertical ellipsis menu markup

* Implement basic changesets for user preferences

* Implement pin toggling

* Try to fix pin sorting

* Implement pin toggling in LV

* Adjust moduledocs for new schema(s)

* Remove unnecessary `distinct` from query

* Use `button` for pin/unpin action

* Generalize preference setting

* Rename schema and fields for clarity

* Rename `list_type` -> `entry_type`

* Safeguard setting options

* Test `set_option/4` and `toggle_pin/2`

* Add test for listing pinned sites via `Sites.list`

* Disallow pinning sites outside page explicitly

* Test pinning in LV

* Test conditional rendering of site settings in /sites

* Remove unnecessary TODO comment

* Safeguard `Sites.set_option/4` against invalid user/site combo

* Handle pinned sites in dashboard site picker

* Clear flashes upon (un)pinning sites

* Update CHANGELOG

* Prevent blinking of hamburger menu items on first paint

* Highlight hamburger handle on hover in /sites

* Start showing hotkeys in site picker again

* Sort pinned sites in the order they were pinned

* Update sites list order immediately after pin/unpin toggle

* Refactor and split `Sites.list/3`, extracting `Sites.list_with_invitations/3`

* Cap number of pinned sites at 9 per user

* First pass on visual indication of site cards (dis)appearing

* Apply ellipsis gradient+shadow on card hover

* Fix responsive padding of site cards

* Sort by invitations first, pinned sites second and then the rest

* Revert "Apply ellipsis gradient+shadow on card hover"

This reverts commit 0608796612639030ccbb12df639709f78edc1434.

* Apply more subtle hover effect on the ellipsis menu

* Make error and success flash LV boxes use separate component containers

* Promote `pinned_at` in table migration to a column

* Switch logic to using `pinned_at` as a standard schema field

* Refactor `Sites.list*` getting rid of subquery (h/t @ukutaht)

* Remove migration which is already merged upstream

---------

Co-authored-by: Adam Rutkowski <hq@mtod.org>
This commit is contained in:
Adrian Gruntkowski 2023-11-13 09:08:26 +01:00 committed by GitHub
parent 26d9e16d7d
commit f464ceae88
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
14 changed files with 897 additions and 114 deletions

View File

@ -12,6 +12,9 @@ All notable changes to this project will be documented in this file.
- Adds Gravatar profile image to navbar
- Enforce email reverification on update
- Add Plugins API Tokens provisioning UI
- Add searching sites by domain in /sites view
- Add last 24h plots to /sites view
- Add site pinning to /sites view
### Removed
- Removed the nested custom event property breakdown UI when filtering by a goal in Goal Conversions

View File

@ -131,7 +131,7 @@ export default class SiteSwitcher extends React.Component {
domain === this.props.site.domain
? 'font-medium text-gray-900 dark:text-gray-100 cursor-default font-bold'
: 'hover:bg-gray-100 dark:hover:bg-gray-900 hover:text-gray-900 dark:hover:text-gray-100 focus:outline-none focus:bg-gray-100 dark:focus:bg-gray-900 focus:text-gray-900 dark:focus:text-gray-100'
const showHotkey = !this.props.loggedIn
const showHotkey = this.props.loggedIn && this.state.sites.length > 1
return (
<a
href={
@ -151,7 +151,7 @@ export default class SiteSwitcher extends React.Component {
{domain}
</span>
</span>
{showHotkey ? index < 9 && <span>{index + 1}</span> : null}
{showHotkey && index < 9 ? <span>{index + 1}</span> : null}
</a>
)
}

View File

@ -5,3 +5,9 @@ window.addEventListener(`phx:update-value`, (e) => {
el.dispatchEvent(new Event("input", { bubbles: true }))
}
})
window.addEventListener(`phx:js-exec`, ({ detail }) => {
document.querySelectorAll(detail.to).forEach(el => {
window.liveSocket.execJS(el, el.getAttribute(detail.attr))
})
})

View File

@ -50,9 +50,10 @@ defmodule Plausible.Site do
field :from_cache?, :boolean, virtual: true, default: false
# Used in the context of paginated sites list to order in relation to
# user's membership state. Currently it can be either "invitation"
# or "site", where invitations are first.
field :list_type, :string, virtual: true
# user's membership state. Currently it can be either "invitation",
# "pinned_site" or "site", where invitations are first.
field :entry_type, :string, virtual: true
field :pinned_at, :naive_datetime, virtual: true
timestamps()
end

View File

@ -0,0 +1,30 @@
defmodule Plausible.Site.UserPreference do
@moduledoc """
User-specific site preferences schema
"""
use Ecto.Schema
import Ecto.Changeset
@type t() :: %__MODULE__{}
@options [:pinned_at]
schema "site_user_preferences" do
field :pinned_at, :naive_datetime
belongs_to :user, Plausible.Auth.User
belongs_to :site, Plausible.Site
timestamps()
end
defmacro options, do: @options
def changeset(user, site, attrs \\ %{}) do
%__MODULE__{}
|> cast(attrs, @options)
|> put_assoc(:user, user)
|> put_assoc(:site, site)
end
end

View File

@ -1,7 +1,18 @@
defmodule Plausible.Sites do
alias Plausible.{Repo, Site, Site.SharedLink, Billing.Quota}
@moduledoc """
Sites context functions.
"""
import Ecto.Query
alias Plausible.Auth
alias Plausible.Billing.Quota
alias Plausible.Repo
alias Plausible.Site
alias Plausible.Site.SharedLink
require Plausible.Site.UserPreference
@type list_opt() :: {:filter_by_domain, String.t()}
def get_by_domain(domain) do
@ -12,51 +23,128 @@ defmodule Plausible.Sites do
Repo.get_by!(Site, domain: domain)
end
@spec list(Plausible.Auth.User.t(), map(), [list_opt()]) :: Scrivener.Page.t()
@spec toggle_pin(Auth.User.t(), Site.t()) ::
{:ok, Site.UserPreference.t()} | {:error, :too_many_pins}
def toggle_pin(user, site) do
pinned_at =
if site.pinned_at do
nil
else
NaiveDateTime.utc_now()
end
with :ok <- check_user_pin_limit(user, pinned_at) do
{:ok, set_option(user, site, :pinned_at, pinned_at)}
end
end
@pins_limit 9
defp check_user_pin_limit(_user, nil), do: :ok
defp check_user_pin_limit(user, _) do
pins_count =
from(up in Site.UserPreference,
where: up.user_id == ^user.id and not is_nil(up.pinned_at)
)
|> Repo.aggregate(:count)
if pins_count + 1 > @pins_limit do
{:error, :too_many_pins}
else
:ok
end
end
@spec set_option(Auth.User.t(), Site.t(), atom(), any()) :: Site.UserPreference.t()
def set_option(user, site, option, value) when option in Site.UserPreference.options() do
get_for_user!(user.id, site.domain)
user
|> Site.UserPreference.changeset(site, %{option => value})
|> Repo.insert!(
conflict_target: [:user_id, :site_id],
# This way of conflict handling enables doing upserts of options leaving
# existing, unrelated values intact.
on_conflict: from(p in Site.UserPreference, update: [set: [{^option, ^value}]]),
returning: true
)
end
@spec list(Auth.User.t(), map(), [list_opt()]) :: Scrivener.Page.t()
def list(user, pagination_params, opts \\ []) do
domain_filter = Keyword.get(opts, :filter_by_domain)
base_query =
from(s in Plausible.Site,
left_join: sm in assoc(s, :memberships),
on: sm.user_id == ^user.id,
left_join: i in assoc(s, :invitations),
on: i.email == ^user.email,
where: not is_nil(i.id) or not is_nil(sm.id),
select: %{
s
| list_type:
from(s in Site,
left_join: up in Site.UserPreference,
on: up.site_id == s.id and up.user_id == ^user.id,
inner_join: sm in assoc(s, :memberships),
on: sm.user_id == ^user.id,
select: %{
s
| pinned_at: selected_as(up.pinned_at, :pinned_at),
entry_type:
selected_as(
fragment(
"""
CASE WHEN ? IS NOT NULL THEN 'invitation'
ELSE 'site'
END
CASE
WHEN ? IS NOT NULL THEN 'pinned_site'
ELSE 'site'
END
""",
i.id
up.pinned_at
),
:entry_type
)
},
order_by: [asc: selected_as(:entry_type), desc: selected_as(:pinned_at), asc: s.domain],
preload: [memberships: sm]
)
|> maybe_filter_by_domain(domain_filter)
|> Repo.paginate(pagination_params)
end
@spec list_with_invitations(Auth.User.t(), map(), [list_opt()]) :: Scrivener.Page.t()
def list_with_invitations(user, pagination_params, opts \\ []) do
domain_filter = Keyword.get(opts, :filter_by_domain)
result =
from(s in Site,
left_join: up in Site.UserPreference,
on: up.site_id == s.id and up.user_id == ^user.id,
left_join: i in assoc(s, :invitations),
on: i.email == ^user.email,
left_join: sm in assoc(s, :memberships),
on: sm.user_id == ^user.id,
where: not is_nil(sm.id) or not is_nil(i.id),
select: %{
s
| pinned_at: selected_as(up.pinned_at, :pinned_at),
entry_type:
selected_as(
fragment(
"""
CASE
WHEN ? IS NOT NULL THEN 'invitation'
WHEN ? IS NOT NULL THEN 'pinned_site'
ELSE 'site'
END
""",
i.id,
up.pinned_at
),
:entry_type
)
}
)
memberships_query =
from sm in Plausible.Site.Membership,
where: sm.user_id == ^user.id
invitations_query =
from i in Plausible.Auth.Invitation,
where: i.email == ^user.email
sites_query =
from(s in subquery(base_query),
order_by: [asc: s.list_type, asc: s.domain],
preload: [
memberships: ^memberships_query,
invitations: ^invitations_query
]
},
order_by: [asc: selected_as(:entry_type), desc: selected_as(:pinned_at), asc: s.domain],
preload: [memberships: sm, invitations: i]
)
|> maybe_filter_by_domain(domain_filter)
|> Repo.paginate(pagination_params)
result = Repo.paginate(sites_query, pagination_params)
# Populating `site` preload on `invitation`
# without requesting it from database.
# Necessary for invitation modals logic.
entries =
Enum.map(result.entries, fn
%{invitations: [invitation]} = site ->
@ -99,7 +187,7 @@ defmodule Plausible.Sites do
defp maybe_start_trial(multi, user) do
case user.trial_expiry_date do
nil ->
changeset = Plausible.Auth.User.start_trial(user)
changeset = Auth.User.start_trial(user)
Ecto.Multi.update(multi, :user, changeset)
_ ->
@ -107,7 +195,7 @@ defmodule Plausible.Sites do
end
end
@spec stats_start_date(Plausible.Site.t()) :: Date.t() | nil
@spec stats_start_date(Site.t()) :: Date.t() | nil
@doc """
Returns the date of the first event of the given site, or `nil` if the site
does not have stats yet.
@ -156,7 +244,7 @@ defmodule Plausible.Sites do
end
def get_for_user!(user_id, domain, roles \\ [:owner, :admin, :viewer]) do
if :super_admin in roles and Plausible.Auth.is_super_admin?(user_id) do
if :super_admin in roles and Auth.is_super_admin?(user_id) do
get_by_domain!(domain)
else
user_id
@ -166,7 +254,7 @@ defmodule Plausible.Sites do
end
def get_for_user(user_id, domain, roles \\ [:owner, :admin, :viewer]) do
if :super_admin in roles and Plausible.Auth.is_super_admin?(user_id) do
if :super_admin in roles and Auth.is_super_admin?(user_id) do
get_by_domain(domain)
else
user_id

View File

@ -182,6 +182,7 @@ defmodule PlausibleWeb.Components.Generic do
attr :new_tab, :boolean, default: false
attr :class, :string, default: ""
attr :id, :any, default: nil
attr :rest, :global
slot :inner_block
def unstyled_link(assigns) do
@ -198,6 +199,7 @@ defmodule PlausibleWeb.Components.Generic do
href={@href}
target="_blank"
rel="noopener noreferrer"
{@rest}
>
<%= render_slot(@inner_block) %>
<Heroicons.arrow_top_right_on_square class={["opacity-60", @icon_class]} />

View File

@ -66,17 +66,7 @@ defmodule PlausibleWeb.Api.InternalController do
end
defp sites_for(user) do
Repo.all(
from(
s in Site,
join: sm in Site.Membership,
on: sm.site_id == s.id,
where: sm.user_id == ^user.id,
order_by: s.domain,
select: %{domain: s.domain},
# there are keyboard shortcuts for switching between sites, hence 9
limit: 9
)
)
pagination = Sites.list(user, %{page_size: 9})
Enum.map(pagination.entries, &%{domain: &1.domain})
end
end

View File

@ -9,19 +9,29 @@ defmodule PlausibleWeb.Live.Flash do
def render(assigns) do
~H"""
<div id="liveview-flash">
<div :if={@flash != %{}}>
<.flash>
<div :if={@flash != %{}} class="">
<.flash :if={Flash.get(@flash, :success)}>
<:icon>
<.icon_success :if={Flash.get(@flash, :success)} />
<.icon_error :if={Flash.get(@flash, :error)} />
<.icon_success />
</:icon>
<:title :if={Flash.get(@flash, :success)}>
<:title>
<%= Flash.get(@flash, :success_title) || "Success!" %>
</:title>
<:message>
<%= Flash.get(@flash, :success) %>
</:message>
</.flash>
<.flash :if={Flash.get(@flash, :error)}>
<:icon>
<.icon_error />
</:icon>
<:title>
<%= Flash.get(@flash, :error_title) || "Error!" %>
</:title>
<:message>
<%= Flash.get(@flash, :error) %>
</:message>
</.flash>
</div>
<div
:if={Application.get_env(:plausible, :environment) == "dev"}
@ -53,7 +63,7 @@ defmodule PlausibleWeb.Live.Flash do
def flash(assigns) do
~H"""
<div class="z-50 fixed inset-0 flex items-end justify-center px-4 py-6 pointer-events-none sm:p-6 sm:items-start sm:justify-end">
<div class="inset-0 z-50 fixed flex items-end justify-center px-4 py-6 pointer-events-none sm:p-6 sm:items-start sm:justify-end">
<div class="max-w-sm w-full bg-white dark:bg-gray-800 shadow-lg rounded-lg pointer-events-auto">
<div class="rounded-lg ring-1 ring-black ring-opacity-5 overflow-hidden">
<div class="p-4">

View File

@ -4,6 +4,7 @@ defmodule PlausibleWeb.Live.Sites do
"""
use Phoenix.LiveView
alias Phoenix.LiveView.JS
use Phoenix.HTML
import PlausibleWeb.Components.Generic
@ -52,12 +53,13 @@ defmodule PlausibleWeb.Live.Sites do
def render(assigns) do
invitations =
assigns.sites.entries
|> Enum.filter(&(&1.list_type == "invitation"))
|> Enum.filter(&(&1.entry_type == "invitation"))
|> Enum.flat_map(& &1.invitations)
assigns = assign(assigns, :invitations, invitations)
~H"""
<.live_component id="embedded_liveview_flash" module={PlausibleWeb.Live.Flash} flash={@flash} />
<div
x-data={"{selectedInvitation: null, invitationOpen: false, invitations: #{Enum.map(@invitations, &({&1.invitation_id, &1})) |> Enum.into(%{}) |> Jason.encode!}}"}
x-on:keydown.escape.window="invitationOpen = false"
@ -95,12 +97,12 @@ defmodule PlausibleWeb.Live.Sites do
<ul class="my-6 grid grid-cols-1 gap-6 sm:grid-cols-2 lg:grid-cols-3">
<%= for site <- @sites.entries do %>
<.site
:if={site.list_type == "site"}
:if={site.entry_type in ["pinned_site", "site"]}
site={site}
hourly_stats={@hourly_stats[site.domain]}
/>
<.invitation
:if={site.list_type == "invitation"}
:if={site.entry_type == "invitation"}
site={site}
invitation={hd(site.invitations)}
hourly_stats={@hourly_stats[site.domain]}
@ -118,7 +120,7 @@ defmodule PlausibleWeb.Live.Sites do
Total of <span class="font-medium"><%= @sites.total_entries %></span> sites
</.pagination>
<.invitation_modal
:if={Enum.any?(@sites.entries, &(&1.list_type == "invitation"))}
:if={Enum.any?(@sites.entries, &(&1.entry_type == "invitation"))}
user={@user}
/>
</div>
@ -172,6 +174,7 @@ defmodule PlausibleWeb.Live.Sites do
~H"""
<li
class="group cursor-pointer"
id={"site-card-#{hash_domain(@site.domain)}"}
data-domain={@site.domain}
x-on:click={"invitationOpen = true; selectedInvitation = invitations['#{@invitation.invitation_id}']"}
>
@ -203,7 +206,24 @@ defmodule PlausibleWeb.Live.Sites do
def site(assigns) do
~H"""
<li class="group relative" data-domain={@site.domain}>
<li
class="group relative hidden"
id={"site-card-#{hash_domain(@site.domain)}"}
data-domain={@site.domain}
data-pin-toggled={
JS.show(
transition: {"duration-500", "opacity-0 shadow-2xl -translate-y-6", "opacity-100 shadow"},
time: 400
)
}
data-pin-failed={
JS.show(
transition: {"duration-500", "opacity-0", "opacity-100"},
time: 200
)
}
phx-mounted={JS.show()}
>
<.unstyled_link href={"/#{URI.encode_www_form(@site.domain)}"}>
<div class="col-span-1 bg-white dark:bg-gray-800 rounded-lg shadow p-4 group-hover:shadow-lg cursor-pointer">
<div class="w-full flex items-center justify-between space-x-4">
@ -220,33 +240,117 @@ defmodule PlausibleWeb.Live.Sites do
<.site_stats hourly_stats={@hourly_stats} />
</div>
</.unstyled_link>
<%= if List.first(@site.memberships).role != :viewer do %>
<.unstyled_link
href={"/#{URI.encode_www_form(@site.domain)}/settings"}
class="absolute top-0 right-0 p-4 mt-1"
>
<Heroicons.cog_8_tooth class="w-4 h-4 text-gray-800 dark:text-gray-400" />
</.unstyled_link>
<% end %>
<.ellipsis_menu site={@site} />
</li>
"""
end
def ellipsis_menu(assigns) do
~H"""
<div x-data="dropdown">
<a
x-on:click="toggle()"
x-ref="button"
x-bind:aria-expanded="open"
x-bind:aria-controls="$id('dropdown-button')"
class="absolute top-0 right-0 h-10 w-10 rounded-md hover:cursor-pointer text-gray-400 dark:text-gray-600 hover:text-black dark:hover:text-indigo-400"
>
<Heroicons.ellipsis_vertical
class="absolute top-3 right-3 w-4 h-4"
aria-expanded="false"
aria-haspopup="true"
/>
</a>
<div
x-ref="panel"
x-show="open"
x-bind:id="$id('dropdown-button')"
x-on:click.outside="close($refs.button)"
x-on:click="onPanelClick"
x-transition.origin.top.right
x-transition.duration.100ms
class="absolute top-7 right-3 z-10 mt-2 w-40 origin-top-right rounded-md bg-white dark:bg-gray-900 shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none"
style="display: none;"
role="menu"
aria-orientation="vertical"
aria-label={"#{@site.domain} menu button"}
tabindex="-1"
>
<div class="py-1 text-sm" role="none">
<.unstyled_link
:if={List.first(@site.memberships).role != :viewer}
href={"/#{URI.encode_www_form(@site.domain)}/settings"}
class="text-gray-500 flex px-4 py-2 hover:bg-gray-100 hover:text-gray-800 dark:text-gray-500 dark:hover:text-gray-100 dark:hover:bg-indigo-900 cursor-pointer"
role="menuitem"
tabindex="-1"
>
<Heroicons.cog_6_tooth class="mr-3 h-5 w-5" />
<span>Settings</span>
</.unstyled_link>
<button
type="button"
class="w-full text-gray-500 flex px-4 py-2 hover:bg-gray-100 hover:text-gray-800 dark:text-gray-500 dark:hover:text-gray-100 dark:hover:bg-indigo-900 cursor-pointer"
role="menuitem"
tabindex="-1"
x-on:click="close($refs.button)"
phx-click={
JS.hide(
transition: {"duration-500", "opacity-100", "opacity-0"},
to: "#site-card-#{hash_domain(@site.domain)}",
time: 500
)
|> JS.push("pin-toggle")
}
phx-value-domain={@site.domain}
>
<.icon_pin
:if={@site.pinned_at}
class="pt-1 mr-3 h-5 w-5 text-red-400 stroke-red-500 dark:text-yellow-600 dark:stroke-yellow-700"
/>
<span :if={@site.pinned_at}>Unpin Site</span>
<.icon_pin :if={!@site.pinned_at} class="pt-1 mr-3 h-5 w-5" />
<span :if={!@site.pinned_at}>Pin Site</span>
</button>
</div>
</div>
</div>
"""
end
attr :rest, :global
def icon_pin(assigns) do
~H"""
<svg
xmlns="http://www.w3.org/2000/svg"
width="16"
height="16"
fill="currentColor"
viewBox="0 0 16 16"
{@rest}
>
<path d="M9.828.722a.5.5 0 0 1 .354.146l4.95 4.95a.5.5 0 0 1 0 .707c-.48.48-1.072.588-1.503.588-.177 0-.335-.018-.46-.039l-3.134 3.134a5.927 5.927 0 0 1 .16 1.013c.046.702-.032 1.687-.72 2.375a.5.5 0 0 1-.707 0l-2.829-2.828-3.182 3.182c-.195.195-1.219.902-1.414.707-.195-.195.512-1.22.707-1.414l3.182-3.182-2.828-2.829a.5.5 0 0 1 0-.707c.688-.688 1.673-.767 2.375-.72a5.922 5.922 0 0 1 1.013.16l3.134-3.133a2.772 2.772 0 0 1-.04-.461c0-.43.108-1.022.589-1.503a.5.5 0 0 1 .353-.146z" />
</svg>
"""
end
attr :hourly_stats, :map, required: true
def site_stats(assigns) do
~H"""
<div class="md:h-[78px] h-20 pl-8 pr-8 pt-2">
<div class="md:h-[68px] sm:h-[58px] h-20 pl-8 pr-8 pt-2">
<div :if={@hourly_stats == :loading} class="text-center animate-pulse">
<div class="md:h-[34px] h-11 dark:bg-gray-700 bg-gray-100 rounded-md"></div>
<div class="md:h-[26px] h-6 mt-1 dark:bg-gray-700 bg-gray-100 rounded-md"></div>
<div class="md:h-[34px] sm:h-[30px] h-11 dark:bg-gray-700 bg-gray-100 rounded-md"></div>
<div class="md:h-[26px] sm:h-[18px] h-6 mt-1 dark:bg-gray-700 bg-gray-100 rounded-md"></div>
</div>
<div
:if={is_map(@hourly_stats)}
class="hidden h-50px"
phx-mounted={
Phoenix.LiveView.JS.show(transition: {"ease-in duration-500", "opacity-0", "opacity-100"})
}
phx-mounted={JS.show(transition: {"ease-in duration-500", "opacity-0", "opacity-100"})}
>
<span class="text-gray-600 dark:text-gray-400 text-sm truncate">
<PlausibleWeb.Live.Components.Visitors.chart intervals={@hourly_stats.intervals} />
@ -474,6 +578,53 @@ defmodule PlausibleWeb.Live.Sites do
"""
end
def handle_event("pin-toggle", %{"domain" => domain}, socket) do
site = Enum.find(socket.assigns.sites.entries, &(&1.domain == domain))
if site do
socket =
case Sites.toggle_pin(socket.assigns.user, site) do
{:ok, preference} ->
flash_message =
if preference.pinned_at do
"Site pinned"
else
"Site unpinned"
end
socket
|> put_flash(:success, flash_message)
|> load_sites()
|> push_event("js-exec", %{
to: "#site-card-#{hash_domain(site.domain)}",
attr: "data-pin-toggled"
})
{:error, :too_many_pins} ->
flash_message =
"Looks like you've hit the pinned sites limit! " <>
"Please unpin one of your pinned sites to make room for new pins"
socket
|> put_flash(:error, flash_message)
|> push_event("js-exec", %{
to: "#site-card-#{hash_domain(site.domain)}",
attr: "data-pin-failed"
})
end
Process.send_after(self(), :clear_flash, 5000)
{:noreply, socket}
else
Sentry.capture_message("Attempting to toggle pin for invalid domain.",
extra: %{domain: domain, user: socket.assigns.user.id}
)
{:noreply, socket}
end
end
def handle_event(
"filter",
%{"filter_text" => filter_text},
@ -500,8 +651,15 @@ defmodule PlausibleWeb.Live.Sites do
{:noreply, socket}
end
def handle_info(:clear_flash, socket) do
{:noreply, clear_flash(socket)}
end
defp load_sites(%{assigns: assigns} = socket) do
sites = Sites.list(assigns.user, assigns.params, filter_by_domain: assigns.filter_text)
sites =
Sites.list_with_invitations(assigns.user, assigns.params,
filter_by_domain: assigns.filter_text
)
hourly_stats =
if connected?(socket) do
@ -552,4 +710,8 @@ defmodule PlausibleWeb.Live.Sites do
params: Map.drop(socket.assigns.params, pagination_fields)
)
end
defp hash_domain(domain) do
:sha |> :crypto.hash(domain) |> Base.encode16()
end
end

View File

@ -1,5 +1,6 @@
defmodule Plausible.SitesTest do
use Plausible.DataCase
alias Plausible.Sites
describe "is_member?" do
@ -82,7 +83,7 @@ defmodule Plausible.SitesTest do
end
end
describe "list/3" do
describe "list/3 and list_with_invitations/3" do
test "returns empty when there are no sites" do
user = insert(:user)
_rogue_site = insert(:site)
@ -94,8 +95,274 @@ defmodule Plausible.SitesTest do
total_entries: 0,
total_pages: 1
} = Sites.list(user, %{})
assert %{
entries: [],
page_size: 24,
page_number: 1,
total_entries: 0,
total_pages: 1
} = Sites.list_with_invitations(user, %{})
end
test "pinned site doesn't matter with membership revoked (no active invitations)" do
user1 = insert(:user, email: "user1@example.com")
user2 = insert(:user, email: "user2@example.com")
insert(:site, members: [user1], domain: "one.example.com")
site2 =
insert(:site,
members: [user2],
domain: "two.example.com"
)
membership = insert(:site_membership, user: user1, role: :viewer, site: site2)
{:ok, _} = Sites.toggle_pin(user1, site2)
Repo.delete!(membership)
assert %{entries: [%{domain: "one.example.com"}]} = Sites.list(user1, %{})
assert %{entries: [%{domain: "one.example.com"}]} = Sites.list_with_invitations(user1, %{})
end
test "pinned site doesn't matter with membership revoked (with active invitation)" do
user1 = insert(:user, email: "user1@example.com")
user2 = insert(:user, email: "user2@example.com")
insert(:site, members: [user1], domain: "one.example.com")
site2 =
insert(:site,
members: [user2],
domain: "two.example.com"
)
membership = insert(:site_membership, user: user1, role: :viewer, site: site2)
insert(:invitation, email: user1.email, inviter: user2, role: :owner, site: site2)
{:ok, _} = Sites.toggle_pin(user1, site2)
Repo.delete!(membership)
assert %{entries: [%{domain: "one.example.com"}]} = Sites.list(user1, %{})
assert %{entries: [%{domain: "two.example.com"}, %{domain: "one.example.com"}]} =
Sites.list_with_invitations(user1, %{})
end
test "puts invitations first, pinned sites second, sites last" do
user = insert(:user, email: "hello@example.com")
site1 = %{id: site_id1} = insert(:site, members: [user], domain: "one.example.com")
site2 = %{id: site_id2} = insert(:site, members: [user], domain: "two.example.com")
%{id: site_id4} = insert(:site, members: [user], domain: "four.example.com")
_rogue_site = insert(:site, domain: "rogue.example.com")
insert(:invitation, email: user.email, inviter: build(:user), role: :owner, site: site1)
%{id: site_id3} =
insert(:site,
domain: "three.example.com",
invitations: [
build(:invitation, email: user.email, inviter: build(:user), role: :viewer)
]
)
insert(:invitation, email: "friend@example.com", inviter: user, role: :viewer, site: site1)
insert(:invitation,
site: site1,
inviter: user,
email: "another@example.com"
)
{:ok, _} = Sites.toggle_pin(user, site2)
assert %{
entries: [
%{id: ^site_id2, entry_type: "pinned_site"},
%{id: ^site_id4, entry_type: "site"},
%{id: ^site_id1, entry_type: "site"}
]
} = Sites.list(user, %{})
assert %{
entries: [
%{id: ^site_id1, entry_type: "invitation"},
%{id: ^site_id3, entry_type: "invitation"},
%{id: ^site_id2, entry_type: "pinned_site"},
%{id: ^site_id4, entry_type: "site"}
]
} = Sites.list_with_invitations(user, %{})
end
test "pinned sites are ordered according to the time they were pinned at" do
user = insert(:user, email: "hello@example.com")
site1 = %{id: site_id1} = insert(:site, members: [user], domain: "one.example.com")
site2 = %{id: site_id2} = insert(:site, members: [user], domain: "two.example.com")
site4 = %{id: site_id4} = insert(:site, members: [user], domain: "four.example.com")
_rogue_site = insert(:site, domain: "rogue.example.com")
insert(:invitation, email: user.email, inviter: build(:user), role: :owner, site: site1)
%{id: site_id3} =
insert(:site,
domain: "three.example.com",
invitations: [
build(:invitation, email: user.email, inviter: build(:user), role: :viewer)
]
)
insert(:invitation, email: "friend@example.com", inviter: user, role: :viewer, site: site1)
insert(:invitation,
site: site1,
inviter: user,
email: "another@example.com"
)
Sites.set_option(user, site2, :pinned_at, ~N[2023-10-22 12:00:00])
{:ok, _} = Sites.toggle_pin(user, site4)
assert %{
entries: [
%{id: ^site_id4, entry_type: "pinned_site"},
%{id: ^site_id2, entry_type: "pinned_site"},
%{id: ^site_id1, entry_type: "site"}
]
} = Sites.list(user, %{})
assert %{
entries: [
%{id: ^site_id1, entry_type: "invitation"},
%{id: ^site_id3, entry_type: "invitation"},
%{id: ^site_id4, entry_type: "pinned_site"},
%{id: ^site_id2, entry_type: "pinned_site"}
]
} = Sites.list_with_invitations(user, %{})
end
test "filters by domain" do
user = insert(:user)
%{id: site_id1} = insert(:site, domain: "first.example.com", members: [user])
%{id: _site_id2} = insert(:site, domain: "second.example.com", members: [user])
_rogue_site = insert(:site)
%{id: site_id3} =
insert(:site,
domain: "first-another.example.com",
invitations: [
build(:invitation, email: user.email, inviter: build(:user), role: :viewer)
]
)
assert %{
entries: [
%{id: ^site_id1}
]
} = Sites.list(user, %{}, filter_by_domain: "first")
assert %{
entries: [
%{id: ^site_id3},
%{id: ^site_id1}
]
} = Sites.list_with_invitations(user, %{}, filter_by_domain: "first")
end
end
describe "list/3" do
test "returns sites only, no invitations" do
user = insert(:user, email: "hello@example.com")
site1 = %{id: site_id1} = insert(:site, members: [user], domain: "one.example.com")
%{id: site_id2} = insert(:site, members: [user], domain: "two.example.com")
%{id: site_id4} = insert(:site, members: [user], domain: "four.example.com")
_rogue_site = insert(:site, domain: "rogue.example.com")
insert(:invitation, email: user.email, inviter: build(:user), role: :owner, site: site1)
insert(:site,
domain: "three.example.com",
invitations: [
build(:invitation, email: user.email, inviter: build(:user), role: :viewer)
]
)
insert(:invitation, email: "friend@example.com", inviter: user, role: :viewer, site: site1)
insert(:invitation,
site: site1,
inviter: user,
email: "another@example.com"
)
assert %{
entries: [
%{id: ^site_id4, entry_type: "site"},
%{id: ^site_id1, entry_type: "site"},
%{id: ^site_id2, entry_type: "site"}
]
} = Sites.list(user, %{})
end
test "handles pagination correctly" do
user = insert(:user)
%{id: site_id1} = insert(:site, members: [user])
%{id: site_id2} = insert(:site, members: [user])
_rogue_site = insert(:site)
insert(:site,
invitations: [
build(:invitation, email: user.email, inviter: build(:user), role: :viewer)
]
)
site4 = %{id: site_id4} = insert(:site, members: [user])
{:ok, _} = Sites.toggle_pin(user, site4)
assert %{
entries: [
%{id: ^site_id4},
%{id: ^site_id1}
],
page_number: 1,
page_size: 2,
total_entries: 3,
total_pages: 2
} = Sites.list(user, %{"page_size" => 2})
assert %{
entries: [
%{id: ^site_id2}
],
page_number: 2,
page_size: 2,
total_entries: 3,
total_pages: 2
} = Sites.list(user, %{"page" => 2, "page_size" => 2})
assert %{
entries: [
%{id: ^site_id4},
%{id: ^site_id1}
],
page_number: 1,
page_size: 2,
total_entries: 3,
total_pages: 2
} = Sites.list(user, %{"page" => 1, "page_size" => 2})
end
end
describe "list_with_invitations/3" do
test "returns invitations and sites" do
user = insert(:user, email: "hello@example.com")
@ -125,34 +392,12 @@ defmodule Plausible.SitesTest do
assert %{
entries: [
%{id: ^site_id1, list_type: "invitation"},
%{id: ^site_id3, list_type: "invitation"},
%{id: ^site_id4, list_type: "site"},
%{id: ^site_id2, list_type: "site"}
%{id: ^site_id1, entry_type: "invitation"},
%{id: ^site_id3, entry_type: "invitation"},
%{id: ^site_id4, entry_type: "site"},
%{id: ^site_id2, entry_type: "site"}
]
} = Sites.list(user, %{})
end
test "filters by domain" do
user = insert(:user)
%{id: site_id1} = insert(:site, domain: "first.example.com", members: [user])
%{id: _site_id2} = insert(:site, domain: "second.example.com", members: [user])
_rogue_site = insert(:site)
%{id: site_id3} =
insert(:site,
domain: "first-another.example.com",
invitations: [
build(:invitation, email: user.email, inviter: build(:user), role: :viewer)
]
)
assert %{
entries: [
%{id: ^site_id3},
%{id: ^site_id1}
]
} = Sites.list(user, %{}, filter_by_domain: "first")
} = Sites.list_with_invitations(user, %{})
end
test "handles pagination correctly" do
@ -177,7 +422,7 @@ defmodule Plausible.SitesTest do
page_size: 2,
total_entries: 3,
total_pages: 2
} = Sites.list(user, %{"page_size" => 2})
} = Sites.list_with_invitations(user, %{"page_size" => 2})
assert %{
entries: [
@ -187,7 +432,7 @@ defmodule Plausible.SitesTest do
page_size: 2,
total_entries: 3,
total_pages: 2
} = Sites.list(user, %{"page" => 2, "page_size" => 2})
} = Sites.list_with_invitations(user, %{"page" => 2, "page_size" => 2})
assert %{
entries: [
@ -198,7 +443,102 @@ defmodule Plausible.SitesTest do
page_size: 2,
total_entries: 3,
total_pages: 2
} = Sites.list(user, %{"page" => 1, "page_size" => 2})
} = Sites.list_with_invitations(user, %{"page" => 1, "page_size" => 2})
end
end
describe "set_option/4" do
test "allows setting option multiple times" do
user = insert(:user)
site = insert(:site, members: [user])
assert prefs =
%{pinned_at: %NaiveDateTime{}} =
Sites.set_option(user, site, :pinned_at, NaiveDateTime.utc_now())
prefs = Repo.reload!(prefs)
assert prefs.site_id == site.id
assert prefs.user_id == user.id
assert prefs.pinned_at
assert prefs =
%{pinned_at: nil} = Sites.set_option(user, site, :pinned_at, nil)
prefs = Repo.reload!(prefs)
assert prefs.site_id == site.id
assert prefs.user_id == user.id
refute prefs.pinned_at
assert prefs =
%{pinned_at: %NaiveDateTime{}} =
Sites.set_option(user, site, :pinned_at, NaiveDateTime.utc_now())
prefs = Repo.reload!(prefs)
assert prefs.site_id == site.id
assert prefs.user_id == user.id
assert prefs.pinned_at
end
test "raises on invalid option" do
user = insert(:user)
site = insert(:site, members: [user])
assert_raise FunctionClauseError, fn ->
Sites.set_option(user, site, :invalid, false)
end
end
test "raises on invalid site/user combination" do
user = insert(:user)
site = insert(:site)
assert_raise Ecto.NoResultsError, fn ->
Sites.set_option(user, site, :pinned_at, nil)
end
end
end
describe "toggle_pin/2" do
test "allows pinning and unpinning site" do
user = insert(:user)
site = insert(:site, members: [user])
site = %{site | pinned_at: nil}
assert {:ok, prefs} = Sites.toggle_pin(user, site)
assert prefs = %{pinned_at: %NaiveDateTime{}} = prefs
prefs = Repo.reload!(prefs)
assert prefs.site_id == site.id
assert prefs.user_id == user.id
assert prefs.pinned_at
site = %{site | pinned_at: NaiveDateTime.utc_now()}
assert {:ok, prefs} = Sites.toggle_pin(user, site)
assert %{pinned_at: nil} = prefs
prefs = Repo.reload!(prefs)
assert prefs.site_id == site.id
assert prefs.user_id == user.id
refute prefs.pinned_at
site = %{site | pinned_at: nil}
assert {:ok, prefs} = Sites.toggle_pin(user, site)
assert %{pinned_at: %NaiveDateTime{}} = prefs
prefs = Repo.reload!(prefs)
assert prefs.site_id == site.id
assert prefs.user_id == user.id
assert prefs.pinned_at
end
test "returns error when pins limit hit" do
user = insert(:user)
for _ <- 1..9 do
site = insert(:site, members: [user])
assert {:ok, _} = Sites.toggle_pin(user, site)
end
site = insert(:site, members: [user])
assert {:error, :too_many_pins} = Sites.toggle_pin(user, site)
end
end
end

View File

@ -50,6 +50,59 @@ defmodule PlausibleWeb.Api.InternalControllerTest do
assert %{"domain" => site.domain} in sites
assert %{"domain" => site2.domain} in sites
end
test "returns a list of max 9 site domains for the current user, putting pinned first", %{
conn: conn,
user: user
} do
inserted =
for i <- 1..10 do
i = to_string(i)
insert(:site,
members: [user],
domain: "site#{String.pad_leading(i, 2, "0")}.example.com"
)
end
_rogue = insert(:site, domain: "site00.example.com")
insert(:site,
domain: "friend.example.com",
invitations: [
build(:invitation, email: user.email, inviter: build(:user), role: :viewer)
]
)
insert(:invitation,
email: "friend@example.com",
inviter: user,
role: :viewer,
site: hd(inserted)
)
{:ok, _} =
Plausible.Sites.toggle_pin(user, Plausible.Sites.get_by_domain!("site07.example.com"))
{:ok, _} =
Plausible.Sites.toggle_pin(user, Plausible.Sites.get_by_domain!("site05.example.com"))
conn = get(conn, "/api/sites")
%{"data" => sites} =
json_response(conn, 200)
assert Enum.count(sites) == 9
assert [
%{"domain" => "site05.example.com"},
%{"domain" => "site07.example.com"},
%{"domain" => "site01.example.com"} | _
] = sites
assert %{"domain" => "site09.example.com"} in sites
refute %{"domain" => "sites10.example.com"} in sites
end
end
describe "GET /api/sites - user not logged in" do

View File

@ -197,6 +197,33 @@ defmodule PlausibleWeb.SiteControllerTest do
assert html_response(conn, 200) =~ "No sites found. Please search for something else."
refute html_response(conn, 200) =~ "You don't have any sites yet."
end
test "shows settings on sites when user is an admin", %{
conn: conn,
user: user
} do
site = insert(:site, domain: "example.com", members: [user])
conn = get(conn, "/sites")
resp = html_response(conn, 200)
assert resp =~ "/#{site.domain}/settings"
end
test "does not show settings on sites when user is not an admin or owner", %{
conn: conn,
user: user
} do
site =
insert(:site,
domain: "example.com",
memberships: [build(:site_membership, user: user, role: :viewer)]
)
conn = get(conn, "/sites")
resp = html_response(conn, 200)
refute resp =~ "/#{site.domain}/settings"
end
end
describe "POST /sites" do

View File

@ -4,6 +4,8 @@ defmodule PlausibleWeb.Live.SitesTest do
import Phoenix.LiveViewTest
import Plausible.Test.Support.HTML
alias Plausible.Repo
setup [:create_user, :log_in]
describe "/sites" do
@ -64,6 +66,75 @@ defmodule PlausibleWeb.Live.SitesTest do
end
end
describe "pinning" do
test "renders pin site option when site not pinned", %{conn: conn, user: user} do
site = insert(:site, members: [user])
{:ok, _lv, html} = live(conn, "/sites")
assert text_of_element(
html,
~s/li[data-domain="#{site.domain}"] button/
) == "Pin Site"
end
test "site state changes when pin toggled", %{conn: conn, user: user} do
site = insert(:site, members: [user])
{:ok, lv, _html} = live(conn, "/sites")
button_selector = ~s/li[data-domain="#{site.domain}"] button/
html =
lv
|> element(button_selector)
|> render_click()
assert html =~ "Site pinned"
assert text_of_element(html, button_selector) == "Unpin Site"
html =
lv
|> element(button_selector)
|> render_click()
assert html =~ "Site unpinned"
assert text_of_element(html, button_selector) == "Pin Site"
end
test "shows error when pins limit hit", %{conn: conn, user: user} do
for _ <- 1..9 do
site = insert(:site, members: [user])
assert {:ok, _} = Plausible.Sites.toggle_pin(user, site)
end
site = insert(:site, members: [user])
{:ok, lv, _html} = live(conn, "/sites")
button_selector = ~s/li[data-domain="#{site.domain}"] button/
html =
lv
|> element(button_selector)
|> render_click()
assert text(html) =~ "Looks like you've hit the pinned sites limit!"
end
test "does not allow pinning site user doesn't have access to", %{conn: conn, user: user} do
site = insert(:site)
{:ok, lv, _html} = live(conn, "/sites")
render_click(lv, "pin-toggle", %{"domain" => site.domain})
refute Repo.get_by(Plausible.Site.UserPreference, user_id: user.id, site_id: site.id)
end
end
defp type_into_input(lv, id, text) do
lv
|> element("form")