mirror of
https://github.com/plausible/analytics.git
synced 2024-09-11 18:07:33 +03:00
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:
parent
26d9e16d7d
commit
f464ceae88
@ -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
|
||||
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
@ -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))
|
||||
})
|
||||
})
|
||||
|
@ -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
|
||||
|
30
lib/plausible/site/user_preference.ex
Normal file
30
lib/plausible/site/user_preference.ex
Normal 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
|
@ -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
|
||||
|
@ -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]} />
|
||||
|
@ -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
|
||||
|
@ -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">
|
||||
|
@ -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
|
||||
|
@ -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
|
||||
|
@ -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
|
||||
|
@ -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
|
||||
|
@ -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")
|
||||
|
Loading…
Reference in New Issue
Block a user