Skip to content

Commit d34b683

Browse files
aerosolsanne-sanzoldar
authored
Use Sites.Index on /sites (#6143)
* Remove Sites.list() in favour of Sites.Index * Implement basic sort options widget @sanne-san pls review 🙏 * Attempt to indicate pinned sites cc @sanne-san * Remove Scrivener * Format * Update mix.lock * Tweak sorting and pinning UI - Moved sorting dropdown to the right of the top bar - Changed pin icon behaviour to be a quick action button to unpin, and leaving the ellipsis menu always visible * Add sorting loading state * Fix pinning tests * Make CI pass * Move pin icon to `Icons` module * Indicate pinned status in CRM * Store user sort preference; migration to be extracted * Move @sort_options if we intend to keep it as a module attribute * Implement feedback - Change "Most visitors" to "Visitors, high to low" and "Fewest visitors" to "Visitors, low to high" in the sort dropdown. - Add dedicated styles to Prima dropdown, rather than using button styles directly, as they diverge from button styles in a few ways. - Add data-sort-trigger attribute to sort dropdown so that loading state only applies to sorting, not to pinning/unpinning. - Add padding to search form to ensure consistent height with other form elements. - Ensure dropdown menu is always at least as wide as the trigger button. - Changed site card hover effect to shadow-md instead of shadow-lg. * Revert removal of unused css - These changes weren't supposed to go into this PR * Fixup tests * Test no sort order persistence for guests (there's nowhere to store it) * Update changelog * Migration: store one map per membership wrt sort preferences * Use unified sorting preferences object * Cosmetics * Lose track of `filter_by_domain` in `Sites.Index` It's irrelevant to carry over * Rework index build/pagination options * Fix typespecs * Remove unused uri argument * Avoid passing URI around, maintain uri params instead * Remove unused assigns * Use prima's match_trigger_width instead of custom JS override - Upgrade prima to 0.2.6 and replace the custom Dropdown hook that manually set min-width with the built-in match_trigger_width={true}. * Skip an iteration * Update lib/plausible_web/live/sites.ex Co-authored-by: Adrian Gruntkowski <adrian.gruntkowski@gmail.com> * Keep `filter_text` socket assign after all * Turn sort_options into a list again to avoid undefined ordering * Use path helpers for :stats * Fix compilation error * Use PlausibleWeb.Endpoint to enable path helpers * Use an embed to store sort preferences * Restructure index user preference * Remove unused component attrs * Remove validate_inclusions covered by Ecto.Enum type --------- Co-authored-by: Sanne de Vries <sannedv@protonmail.com> Co-authored-by: Adrian Gruntkowski <adrian.gruntkowski@gmail.com>
1 parent b498aeb commit d34b683

22 files changed

Lines changed: 861 additions & 1160 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ All notable changes to this project will be documented in this file.
1717
### Changed
1818

1919
- Keybind hints are hidden on smaller screens
20+
- Site index is sortable alphanumerically and by traffic
2021

2122
### Fixed
2223

assets/css/app.css

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -103,6 +103,7 @@
103103
/* Set v3 default ring behavior */
104104
--default-ring-width: 2px;
105105
--default-ring-color: var(--color-indigo-500);
106+
--animate-pulse: pulse 1.5s cubic-bezier(0.4, 0, 0.6, 1) infinite;
106107
}
107108

108109
@media print {
@@ -128,7 +129,7 @@
128129
}
129130

130131
.button {
131-
@apply inline-flex justify-center px-3.5 py-2 text-sm font-medium text-white bg-indigo-600 border border-transparent rounded-md leading-5 transition hover:bg-indigo-700;
132+
@apply inline-flex justify-center px-3.5 py-2.5 text-sm font-medium text-white bg-indigo-600 border border-transparent rounded-md leading-5 transition hover:bg-indigo-700;
132133
}
133134

134135
.button[disabled] {

extra/lib/plausible_web/live/customer_support/team/components/sites.ex

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,8 @@ defmodule PlausibleWeb.CustomerSupport.Team.Components.Sites do
2222
Index.build(owner, team: team, sort_by: :traffic, sort_direction: :desc)
2323
end)
2424

25-
page = Index.paginate(socket.assigns.index_state, tab_params["page"], @page_size)
25+
page =
26+
Index.paginate(socket.assigns.index_state, page: tab_params["page"], page_size: @page_size)
2627

2728
sites = fetch_sites(page.entries)
2829

@@ -66,7 +67,7 @@ defmodule PlausibleWeb.CustomerSupport.Team.Components.Sites do
6667
end
6768

6869
new_state = Index.sort(current_state, sort_by: sort_by, sort_direction: sort_direction)
69-
page = Index.paginate(new_state, 1, @page_size)
70+
page = Index.paginate(new_state, page: 1, page_size: @page_size)
7071
sites = fetch_sites(page.entries)
7172

7273
hourly_stats = build_hourly_stats(sites, socket)
@@ -108,7 +109,7 @@ defmodule PlausibleWeb.CustomerSupport.Team.Components.Sites do
108109
<.th invisible>Dashboard</.th>
109110
<th
110111
scope="col"
111-
class="px-6 first:pl-0 last:pr-0 py-3 text-left text-sm font-semibold cursor-pointer select-none"
112+
class="max-w-40 px-6 first:pl-0 last:pr-0 py-3 text-left text-sm font-semibold cursor-pointer select-none"
112113
phx-click="sort"
113114
phx-value-by="traffic"
114115
phx-target={@myself}
@@ -129,6 +130,10 @@ defmodule PlausibleWeb.CustomerSupport.Team.Components.Sites do
129130
class="cursor-pointer flex block items-center"
130131
>
131132
{site.domain}
133+
134+
<span :if={@index_state.pins[site.id]}>
135+
<PlausibleWeb.Components.Icons.pin_icon class="w-4 ml-2" filled={true} />
136+
</span>
132137
</.styled_link>
133138
</div>
134139
</.td>
@@ -150,7 +155,7 @@ defmodule PlausibleWeb.CustomerSupport.Team.Components.Sites do
150155
Settings
151156
</.styled_link>
152157
</.td>
153-
<.td>
158+
<.td max_width="max-w-40">
154159
<span class="h-[24px] text-indigo-500">
155160
<PlausibleWeb.Live.Components.Visitors.chart
156161
:if={is_map(@hourly_stats[site.domain])}

lib/plausible/repo.ex

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,8 +3,6 @@ defmodule Plausible.Repo do
33
otp_app: :plausible,
44
adapter: Ecto.Adapters.Postgres
55

6-
use Scrivener, page_size: 24
7-
86
use Plausible.Audit.Repo
97

108
defmacro __using__(_) do

lib/plausible/sites.ex

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -134,7 +134,7 @@ defmodule Plausible.Sites do
134134
)
135135
end
136136

137-
defdelegate list(user, pagination_params, opts \\ []), to: Plausible.Teams.Sites
137+
defdelegate get_for_user_by_ids(user, site_ids, opts \\ []), to: Plausible.Teams.Sites
138138

139139
def list_people(site) do
140140
owner_memberships =

lib/plausible/sites/index.ex

Lines changed: 53 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -13,15 +13,22 @@ defmodule Plausible.Sites.Index do
1313
alias Plausible.Teams
1414
alias Plausible.Teams.Sites
1515

16+
def sort_direction_values, do: [:asc, :desc]
17+
def sort_by_values, do: [:traffic, :alnum]
18+
1619
@type sort_by() :: :alnum | :traffic
1720
@type sort_direction() :: :asc | :desc
1821

19-
@type list_opt() ::
20-
{:filter_by_domain, String.t()}
21-
| {:team, Teams.Team.t() | nil}
22+
@type index_option() ::
23+
{:team, Teams.Team.t() | nil}
2224
| {:sort_by, sort_by()}
2325
| {:sort_direction, sort_direction()}
2426

27+
@type pagination_option() ::
28+
{:page, pos_integer() | String.t() | nil}
29+
| {:page_size, pos_integer() | String.t() | nil}
30+
| {:filter_by_domain, String.t() | nil}
31+
2532
defmodule Page do
2633
@moduledoc """
2734
A single page of results, drop-in replacement for Scrivener.Page
@@ -46,7 +53,7 @@ defmodule Plausible.Sites.Index do
4653

4754
@type t() :: %__MODULE__{
4855
user: Auth.User.t(),
49-
opts: %{filter_by_domain: String.t() | nil, team: Plausible.Teams.Team.t() | nil},
56+
opts: %{team: Plausible.Teams.Team.t() | nil},
5057
ordered_ids: [pos_integer()],
5158
pins: %{pos_integer() => NaiveDateTime.t() | nil},
5259
domains: %{pos_integer() => String.t()},
@@ -71,21 +78,28 @@ defmodule Plausible.Sites.Index do
7178
@doc """
7279
Builds an `Index.State` for `user` by running all necessary queries
7380
"""
74-
@spec build(Auth.User.t(), [list_opt()]) :: State.t()
75-
def build(user, opts \\ []) do
81+
@spec build(Auth.User.t(), [index_option()] | map()) :: State.t()
82+
def build(user, opts \\ [])
83+
84+
def build(user, %{__struct__: _} = opts) do
85+
build(user, Map.from_struct(opts))
86+
end
87+
88+
def build(user, opts) when is_map(opts) do
89+
build(user, Keyword.new(opts))
90+
end
91+
92+
def build(user, opts) do
7693
sort_by = Keyword.get(opts, :sort_by, :alnum)
7794
sort_direction = Keyword.get(opts, :sort_direction, :asc)
7895

79-
# Fetch the full unfiltered set for the team; domain filtering is applied
80-
# locally from here on so filter/2 never needs to hit the database.
81-
site_ids = fetch_site_ids(user, Keyword.delete(opts, :filter_by_domain))
96+
site_ids = fetch_site_ids(user, opts)
8297
pins = fetch_pins(user, site_ids)
8398
domains = fetch_domains(site_ids)
8499

85100
%State{
86101
user: user,
87102
opts: %{
88-
filter_by_domain: Keyword.get(opts, :filter_by_domain),
89103
team: Keyword.get(opts, :team)
90104
},
91105
ordered_ids: site_ids,
@@ -100,14 +114,22 @@ defmodule Plausible.Sites.Index do
100114

101115
@spec paginate(
102116
State.t(),
103-
page :: pos_integer() | String.t() | nil,
104-
page_size :: pos_integer() | String.t() | nil
117+
[pagination_option()]
105118
) :: Page.t()
106-
def paginate(%State{ordered_ids: ordered_ids} = state, raw_page_number, raw_page_size) do
107-
page_number = cast_int(raw_page_number, min: 1, max: :unlimited, default: 1)
108-
page_size = cast_int(raw_page_size, min: 1, max: 100, default: 24)
119+
def paginate(%State{ordered_ids: ordered_ids} = state, opts \\ []) do
120+
page_number =
121+
opts
122+
|> Keyword.get(:page)
123+
|> cast_int(min: 1, max: :unlimited, default: 1)
124+
125+
page_size =
126+
opts
127+
|> Keyword.get(:page_size)
128+
|> cast_int(min: 1, max: 100, default: 24)
129+
130+
filter_by_domain = Keyword.get(opts, :filter_by_domain)
109131

110-
filtered_ids = apply_domain_filter(ordered_ids, state.domains, state.opts.filter_by_domain)
132+
filtered_ids = apply_domain_filter(ordered_ids, state.domains, filter_by_domain)
111133

112134
total_entries = length(filtered_ids)
113135
total_pages = max(1, ceil(total_entries / page_size))
@@ -150,12 +172,16 @@ defmodule Plausible.Sites.Index do
150172
%State{state | pins: new_pins, ordered_ids: new_ordered_ids}
151173
end
152174

153-
@spec update_state(State.t(), :filter_by_domain, String.t() | nil) :: State.t()
154-
def update_state(%State{} = state, :filter_by_domain, value) do
155-
%State{state | opts: %{state.opts | filter_by_domain: value}}
175+
@spec sort(State.t(), [index_option()] | map()) :: State.t()
176+
177+
def sort(user, %{__struct__: _} = opts) do
178+
sort(user, Map.from_struct(opts))
179+
end
180+
181+
def sort(user, opts) when is_map(opts) do
182+
sort(user, Keyword.new(opts))
156183
end
157184

158-
@spec sort(State.t(), [list_opt()]) :: State.t()
159185
def sort(%State{} = state, opts) do
160186
sort_by = Keyword.get(opts, :sort_by, state.sort_by)
161187
sort_direction = Keyword.get(opts, :sort_direction, state.sort_direction)
@@ -246,18 +272,17 @@ defmodule Plausible.Sites.Index do
246272
pinned ++ Enum.reverse(unpinned)
247273
end
248274

249-
@spec fetch_site_ids(Auth.User.t(), [list_opt()]) :: [pos_integer()]
275+
@spec fetch_site_ids(Auth.User.t(), [index_option()]) :: [pos_integer()]
250276
def fetch_site_ids(user, opts \\ []) do
251277
team = Keyword.get(opts, :team)
252-
domain_filter = Keyword.get(opts, :filter_by_domain)
253278

254-
from(u in subquery(Sites.accessible_by(user, team)),
255-
inner_join: s in ^Site.regular(),
256-
on: u.site_id == s.id,
257-
select: s.id
279+
Repo.all(
280+
from(u in subquery(Sites.accessible_by(user, team)),
281+
inner_join: s in ^Site.regular(),
282+
on: u.site_id == s.id,
283+
select: s.id
284+
)
258285
)
259-
|> maybe_filter_by_domain_on_site(domain_filter)
260-
|> Repo.all()
261286
end
262287

263288
@spec traffic_for_site_ids([pos_integer()]) :: [{pos_integer(), non_neg_integer()}]
@@ -370,13 +395,6 @@ defmodule Plausible.Sites.Index do
370395
end)
371396
end
372397

373-
defp maybe_filter_by_domain_on_site(query, domain)
374-
when byte_size(domain) >= 1 and byte_size(domain) <= 64 do
375-
from([_u, s] in query, where: ilike(s.domain, ^"%#{domain}%"))
376-
end
377-
378-
defp maybe_filter_by_domain_on_site(query, _), do: query
379-
380398
defp cast_int(value, min: min, max: max, default: default) when is_binary(value) do
381399
case Integer.parse(value) do
382400
{int, ""} when int >= min and int <= max -> int
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
defmodule Plausible.Sites.Index.UserPreference do
2+
@moduledoc """
3+
User preference persistence schema for the sites index
4+
"""
5+
6+
use Ecto.Schema
7+
import Ecto.Changeset
8+
alias Plausible.Sites.Index
9+
10+
@primary_key false
11+
embedded_schema do
12+
field :sort_by, Ecto.Enum, values: Index.sort_by_values(), default: :traffic
13+
field :sort_direction, Ecto.Enum, values: Index.sort_direction_values(), default: :desc
14+
end
15+
16+
def changeset(struct \\ %__MODULE__{}, attrs) do
17+
cast(struct, attrs, [:sort_by, :sort_direction])
18+
end
19+
20+
def new(attrs) do
21+
attrs |> changeset() |> apply_changes()
22+
end
23+
24+
def default(), do: %__MODULE__{}
25+
end

lib/plausible/teams/memberships.ex

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -233,8 +233,7 @@ defmodule Plausible.Teams.Memberships do
233233
|> Teams.Memberships.UserPreference.changeset(%{option => value})
234234
|> Repo.insert!(
235235
conflict_target: [:team_membership_id],
236-
on_conflict:
237-
from(p in Teams.Memberships.UserPreference, update: [set: [{^option, ^value}]]),
236+
on_conflict: {:replace, [option, :updated_at]},
238237
returning: true
239238
)
240239
end

lib/plausible/teams/memberships/user_preference.ex

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,10 +8,11 @@ defmodule Plausible.Teams.Memberships.UserPreference do
88

99
@type t() :: %__MODULE__{}
1010

11-
@options [:consolidated_view_cta_dismissed]
11+
@options [:consolidated_view_cta_dismissed, :sort_index_options]
1212

1313
schema "team_membership_user_preferences" do
1414
field :consolidated_view_cta_dismissed, :boolean, default: false
15+
embeds_one :sort_index_options, Plausible.Sites.Index.UserPreference, on_replace: :update
1516

1617
belongs_to :team_membership, Plausible.Teams.Membership
1718

@@ -22,7 +23,8 @@ defmodule Plausible.Teams.Memberships.UserPreference do
2223

2324
def changeset(team_membership, attrs \\ %{}) do
2425
%__MODULE__{}
25-
|> cast(attrs, @options)
26+
|> cast(attrs, [:consolidated_view_cta_dismissed])
27+
|> cast_embed(:sort_index_options)
2628
|> put_assoc(:team_membership, team_membership)
2729
end
2830
end

0 commit comments

Comments
 (0)