analytics/lib/plausible_web/live/sites.ex

784 lines
27 KiB
Elixir
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

defmodule PlausibleWeb.Live.Sites do
@moduledoc """
LiveView for sites index.
"""
use PlausibleWeb, :live_view
import PlausibleWeb.Live.Components.Pagination
require Logger
alias Plausible.Sites
alias Plausible.Teams
def mount(params, _session, socket) do
uri =
("/sites?" <> URI.encode_query(Map.take(params, ["filter_text"])))
|> URI.new!()
socket =
socket
|> assign(:uri, uri)
|> assign(
:team_invitations,
Teams.Invitations.all(socket.assigns.current_user)
)
|> assign(:filter_text, String.trim(params["filter_text"] || ""))
{:ok, socket}
end
def handle_params(params, _uri, socket) do
socket =
socket
|> assign(:params, params)
|> load_sites()
|> assign_new(:has_sites?, fn %{current_user: current_user} ->
Teams.Users.has_sites?(current_user, include_pending?: true)
end)
|> assign_new(:needs_to_upgrade, fn %{
current_user: current_user,
current_team: current_team
} ->
current_team &&
Teams.Users.owns_sites?(current_user, include_pending?: true, only_team: current_team) &&
Teams.Billing.check_needs_to_upgrade(current_team)
end)
{:noreply, socket}
end
def render(assigns) do
~H"""
<.flash_messages flash={@flash} />
<div
x-ref="invitation_data"
x-data={"{selectedInvitation: null, invitationOpen: false, invitations: #{Enum.map(@invitations, &({&1.invitation.invitation_id, &1})) |> Enum.into(%{}) |> Jason.encode!}}"}
x-on:keydown.escape.window="invitationOpen = false"
class="container pt-6"
>
<PlausibleWeb.Live.Components.Visitors.gradient_defs />
<.upgrade_nag_screen :if={
@needs_to_upgrade == {:needs_to_upgrade, :no_active_trial_or_subscription}
} />
<div class="group mt-6 pb-5 border-b border-gray-200 dark:border-gray-500 flex items-center justify-between">
<h2 class="text-2xl font-bold leading-7 text-gray-900 dark:text-gray-100 sm:text-3xl sm:leading-9 sm:truncate shrink-0">
{Teams.name(@current_team)}
<.unstyled_link
:if={Teams.setup?(@current_team)}
data-test-id="team-settings-link"
href={Routes.settings_path(@socket, :team_general)}
>
<Heroicons.cog_6_tooth class="hidden group-hover:inline size-5 dark:text-gray-100 text-gray-900" />
</.unstyled_link>
</h2>
</div>
<PlausibleWeb.Team.Notice.team_invitations team_invitations={@team_invitations} />
<div class="border-t border-gray-200 pt-4 sm:flex sm:items-center sm:justify-between">
<.search_form :if={@has_sites?} filter_text={@filter_text} uri={@uri} />
<p :if={not @has_sites?} class="dark:text-gray-100">
You don't have any sites yet.
</p>
<div class="mt-4 flex sm:ml-4 sm:mt-0">
<a href={"/sites/new?flow=#{PlausibleWeb.Flows.provisioning()}"} class="button">
+ Add Website
</a>
</div>
</div>
<p :if={@filter_text != "" and @sites.entries == []} class="mt-4 dark:text-gray-100 text-center">
No sites found. Please search for something else.
</p>
<p
:if={
@has_sites? and not Teams.setup?(@current_team) and @sites.entries == [] and
@filter_text == ""
}
class="mt-4 dark:text-gray-100 text-center"
>
You currently have no personal sites. Are you looking for your teams sites?
<.styled_link href={Routes.auth_path(@socket, :select_team)}>
Go to your team &rarr;
</.styled_link>
</p>
<div :if={@has_sites?}>
<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.entry_type in ["pinned_site", "site"]}
site={site}
hourly_stats={@hourly_stats[site.domain]}
/>
<.invitation
:if={site.entry_type == "invitation"}
site={site}
invitation={hd(site.invitations)}
hourly_stats={@hourly_stats[site.domain]}
/>
<% end %>
</ul>
<.pagination
:if={@sites.total_pages > 1}
id="sites-pagination"
uri={@uri}
page_number={@sites.page_number}
total_pages={@sites.total_pages}
>
Total of <span class="font-medium">{@sites.total_entries}</span> sites
</.pagination>
<.invitation_modal :if={Enum.any?(@sites.entries, &(&1.entry_type == "invitation"))} />
</div>
</div>
"""
end
def upgrade_nag_screen(assigns) do
~H"""
<div class="rounded-md bg-yellow-100 p-4">
<div class="flex">
<div class="shrink-0">
<svg
class="h-5 w-5 text-yellow-400"
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 20 20"
fill="currentColor"
aria-hidden="true"
>
<path
fill-rule="evenodd"
d="M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z"
clip-rule="evenodd"
/>
</svg>
</div>
<div class="ml-3">
<h3 class="text-sm font-medium text-yellow-800">
Payment required
</h3>
<div class="mt-2 text-sm text-yellow-700">
<p>
To access the sites you own, you need to subscribe to a monthly or yearly payment plan.
<.styled_link href={Routes.settings_path(PlausibleWeb.Endpoint, :subscription)}>
Upgrade now →
</.styled_link>
</p>
</div>
</div>
</div>
</div>
"""
end
attr(:site, Plausible.Site, required: true)
attr(:invitation, :map, required: true)
attr(:hourly_stats, :map, required: true)
def invitation(assigns) 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}']"}
>
<div class="col-span-1 bg-white dark:bg-gray-800 rounded-md shadow-sm p-4 group-hover:shadow-lg cursor-pointer transition duration-100">
<div class="w-full flex items-center justify-between space-x-4">
<img
src={"/favicon/sources/#{@site.domain}"}
onerror="this.onerror=null; this.src='/favicon/sources/placeholder';"
class="size-[1.15rem] shrink-0"
/>
<div class="flex-1 truncate -mt-px">
<h3 class="text-gray-900 font-medium text-lg truncate dark:text-gray-100">
{@site.domain}
</h3>
</div>
<span class="inline-flex items-center px-2 py-0.5 rounded-sm text-xs font-medium bg-green-100 text-green-800">
Pending invitation
</span>
</div>
<.site_stats hourly_stats={@hourly_stats} />
</div>
</li>
"""
end
attr(:site, Plausible.Site, required: true)
attr(:hourly_stats, :map, required: true)
def site(assigns) do
~H"""
<li
class="group relative"
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
)
}
>
<.unstyled_link href={"/#{URI.encode_www_form(@site.domain)}"}>
<div class="col-span-1 bg-white dark:bg-gray-800 rounded-md shadow-sm p-4 group-hover:shadow-lg cursor-pointer transition duration-100">
<div class="w-full flex items-center justify-between space-x-4">
<.favicon domain={@site.domain} />
<div class="flex-1 -mt-px w-full">
<h3
class="text-gray-900 font-medium text-lg truncate dark:text-gray-100"
style="width: calc(100% - 4rem)"
>
{@site.domain}
</h3>
</div>
</div>
<.site_stats hourly_stats={@hourly_stats} />
</div>
</.unstyled_link>
<div class="absolute right-0 top-2">
<.ellipsis_menu site={@site} />
</div>
</li>
"""
end
def ellipsis_menu(assigns) do
~H"""
<.dropdown>
<:button class="size-10 rounded-md hover:cursor-pointer text-gray-400 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-100">
<Heroicons.ellipsis_vertical class="absolute top-3 right-3 size-5 transition-colors duration-150" />
</:button>
<:menu class="!mt-0 mr-4 min-w-40">
<!-- adjust position because click area is much bigger than icon. Default positioning from click area looks weird -->
<.dropdown_item
:if={List.first(@site.memberships).role != :viewer}
href={"/#{URI.encode_www_form(@site.domain)}/settings/general"}
class="group/item !flex items-center gap-x-2"
>
<Heroicons.cog_6_tooth class="size-5 text-gray-600 dark:text-gray-400 group-hover/item:text-gray-900 dark:group-hover/item:text-gray-100 transition-colors duration-150" />
<span>Settings</span>
</.dropdown_item>
<.dropdown_item
href="#"
x-on:click.prevent
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}
class="group/item !flex items-center gap-x-2"
>
<.icon_pin
:if={@site.pinned_at}
filled={true}
class="size-[1.15rem] text-indigo-600 dark:text-indigo-500 group-hover/item:text-indigo-700 dark:group-hover/item:text-indigo-400 transition-colors duration-150"
/>
<span :if={@site.pinned_at}>Unpin site</span>
<.icon_pin
:if={!@site.pinned_at}
class="size-5 text-gray-600 dark:text-gray-400 group-hover/item:text-gray-900 dark:group-hover/item:text-gray-100 transition-colors duration-150"
/>
<span :if={!@site.pinned_at}>Pin site</span>
</.dropdown_item>
<.dropdown_item
:if={Application.get_env(:plausible, :environment) == "dev"}
href={Routes.site_path(PlausibleWeb.Endpoint, :delete_site, @site.domain)}
method="delete"
class="group/item !flex items-center gap-x-2"
>
<Heroicons.trash class="size-5 text-red-500" />
<span class="text-red-500">[DEV ONLY] Quick delete</span>
</.dropdown_item>
</:menu>
</.dropdown>
"""
end
attr(:rest, :global)
attr(:filled, :boolean, default: false)
def icon_pin(assigns) do
~H"""
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill={if @filled, do: "currentColor", else: "none"}
stroke="currentColor"
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="1.5"
{@rest}
>
<path d="m4 20 4.5-4.5-.196.196M14.314 21.005l-5.657-5.657L3 9.69l1.228-1.228a3 3 0 0 1 3.579-.501l.58.322 7.34-5.664 5.658 5.657-5.665 7.34.323.581a3 3 0 0 1-.501 3.578l-1.228 1.229Z" />
</svg>
"""
end
attr(:hourly_stats, :any, required: true)
def site_stats(assigns) do
~H"""
<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] 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={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} />
<div class="flex justify-between items-center">
<p>
<span class="text-gray-800 dark:text-gray-200">
<b>{PlausibleWeb.StatsView.large_number_format(@hourly_stats.visitors)}</b>
visitor<span :if={@hourly_stats.visitors != 1}>s</span> in last 24h
</span>
</p>
<.percentage_change change={@hourly_stats.change} />
</div>
</span>
</div>
</div>
"""
end
attr(:change, :integer, required: true)
# Related React component: <ChangeArrow />
def percentage_change(assigns) do
~H"""
<p class="dark:text-gray-100">
<span :if={@change == 0} class="font-semibold">〰</span>
<svg
:if={@change > 0}
xmlns="http://www.w3.org/2000/svg"
fill="currentColor"
viewBox="0 0 24 24"
class="text-green-500 h-3 w-3 inline-block stroke-[1px] stroke-current"
>
<path
fill-rule="evenodd"
d="M8.25 3.75H19.5a.75.75 0 01.75.75v11.25a.75.75 0 01-1.5 0V6.31L5.03 20.03a.75.75 0 01-1.06-1.06L17.69 5.25H8.25a.75.75 0 010-1.5z"
clip-rule="evenodd"
>
</path>
</svg>
<svg
:if={@change < 0}
xmlns="http://www.w3.org/2000/svg"
fill="currentColor"
viewBox="0 0 24 24"
class="text-red-400 h-3 w-3 inline-block stroke-[1px] stroke-current"
>
<path
fill-rule="evenodd"
d="M3.97 3.97a.75.75 0 011.06 0l13.72 13.72V8.25a.75.75 0 011.5 0V19.5a.75.75 0 01-.75.75H8.25a.75.75 0 010-1.5h9.44L3.97 5.03a.75.75 0 010-1.06z"
clip-rule="evenodd"
>
</path>
</svg>
{PlausibleWeb.TextHelpers.number_format(abs(@change))}%
</p>
"""
end
def invitation_modal(assigns) do
~H"""
<div
x-cloak
x-show="invitationOpen"
class="fixed z-10 inset-0 overflow-y-auto"
aria-labelledby="modal-title"
role="dialog"
aria-modal="true"
>
<div class="flex items-end justify-center min-h-screen pt-4 px-4 pb-20 text-center sm:block sm:p-0">
<div
x-show="invitationOpen"
x-transition:enter="transition ease-out duration-300"
x-transition:enter-start="opacity-0"
x-transition:enter-end="opacity-100"
x-transition:leave="transition ease-in duration-200"
x-transition:leave-start="opacity-100"
x-transition:leave-end="opacity-0"
class="fixed inset-0 bg-gray-500/75 dark:bg-gray-800/75 transition-opacity"
aria-hidden="true"
x-on:click="invitationOpen = false"
>
</div>
<!-- This element is to trick the browser into centering the modal contents. -->
<span class="hidden sm:inline-block sm:align-middle sm:h-screen" aria-hidden="true">
&#8203;
</span>
<div
x-show="invitationOpen"
x-transition:enter="transition ease-out duration-300"
x-transition:enter-start="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"
x-transition:enter-end="opacity-100 translate-y-0 sm:scale-100"
x-transition:leave="transition ease-in duration-200"
x-transition:leave-start="opacity-100 translate-y-0 sm:scale-100"
x-transition:leave-end="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"
class="relative inline-block align-bottom bg-white dark:bg-gray-900 rounded-lg text-left overflow-hidden shadow-xl transform transition-all sm:my-8 sm:align-middle sm:max-w-lg sm:w-full"
>
<div class="bg-white dark:bg-gray-850 px-4 pt-5 pb-4 sm:p-6 sm:pb-4">
<div class="hidden sm:block absolute top-0 right-0 pt-4 pr-4">
<button
x-on:click="invitationOpen = false"
class="bg-white dark:bg-gray-800 rounded-md text-gray-400 dark:text-gray-500 hover:text-gray-500 dark:hover:text-gray-400 focus:outline-hidden focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500"
>
<span class="sr-only">Close</span>
<Heroicons.x_mark class="h-6 w-6" />
</button>
</div>
<div class="sm:flex sm:items-start">
<div class="mx-auto shrink-0 flex items-center justify-center h-12 w-12 rounded-full bg-green-100 sm:mx-0 sm:h-10 sm:w-10">
<Heroicons.user_group class="h-6 w-6" />
</div>
<div class="mt-3 text-center sm:mt-0 sm:ml-4 sm:text-left">
<h3
class="text-lg leading-6 font-medium text-gray-900 dark:text-gray-100"
id="modal-title"
>
Invitation for
<span x-text="selectedInvitation && selectedInvitation.invitation.site.domain">
</span>
</h3>
<div class="mt-2">
<p class="text-sm text-gray-500 dark:text-gray-200">
You've been invited to the
<span x-text="selectedInvitation && selectedInvitation.invitation.site.domain">
</span>
analytics dashboard as <b
class="capitalize"
x-text="selectedInvitation && selectedInvitation.invitation.role"
>Admin</b>.
</p>
<div
x-show="selectedInvitation && !(selectedInvitation.exceeded_limits || selectedInvitation.no_plan) && selectedInvitation.invitation.role === 'owner'"
class="mt-2 text-sm text-gray-500 dark:text-gray-200"
>
If you accept the ownership transfer, you will be responsible for billing going forward.
</div>
</div>
</div>
</div>
<.notice
x-show="selectedInvitation && selectedInvitation.missing_features"
title="Missing features"
class="mt-4 shadow-xs dark:shadow-none"
>
<p>
The site uses <span x-text="selectedInvitation && selectedInvitation.missing_features"></span>,
which your current subscription does not support. After accepting ownership of this site,
you will not be able to access them unless you <.styled_link
class="inline-block"
href={Routes.billing_path(PlausibleWeb.Endpoint, :choose_plan)}
>
upgrade to a suitable plan
</.styled_link>.
</p>
</.notice>
<.notice
x-show="selectedInvitation && selectedInvitation.exceeded_limits"
title="Unable to accept site ownership"
class="mt-4 shadow-xs dark:shadow-none"
>
<p>
Owning this site would exceed your <span x-text="selectedInvitation && selectedInvitation.exceeded_limits"></span>. Please check your usage in
<.styled_link
class="inline-block"
href={Routes.settings_path(PlausibleWeb.Endpoint, :subscription)}
>
account settings
</.styled_link>
and upgrade your subscription to accept the site ownership.
</p>
</.notice>
<.notice
x-show="selectedInvitation && selectedInvitation.no_plan"
title="No subscription"
class="mt-4 shadow-xs dark:shadow-none"
>
You are unable to accept the ownership of this site because your account does not have a subscription. To become the owner of this site, you should upgrade to a suitable plan.
</.notice>
</div>
<div class="bg-gray-50 dark:bg-gray-850 px-4 py-3 sm:px-6 sm:flex sm:flex-row-reverse">
<.button
x-show="selectedInvitation && !(selectedInvitation.exceeded_limits || selectedInvitation.no_plan)"
class="sm:ml-3 w-full sm:w-auto sm:text-sm"
data-method="post"
data-csrf={Plug.CSRFProtection.get_csrf_token()}
x-bind:data-to="selectedInvitation && ('/sites/invitations/' + selectedInvitation.invitation.invitation_id + '/accept')"
>
Accept &amp; Continue
</.button>
<.button_link
x-show="selectedInvitation && (selectedInvitation.exceeded_limits || selectedInvitation.no_plan)"
href={Routes.billing_path(PlausibleWeb.Endpoint, :choose_plan)}
class="sm:ml-3 w-full sm:w-auto sm:text-sm"
>
Upgrade
</.button_link>
<.button_link
href="#"
theme="bright"
data-method="post"
data-csrf={Plug.CSRFProtection.get_csrf_token()}
x-bind:data-to="selectedInvitation && ('/sites/invitations/' + selectedInvitation.invitation.invitation_id + '/reject')"
>
Reject
</.button_link>
</div>
</div>
</div>
</div>
"""
end
attr(:filter_text, :string, default: "")
attr(:uri, URI, required: true)
def search_form(assigns) do
~H"""
<.filter_bar filter_text={@filter_text} placeholder="Search Sites"></.filter_bar>
"""
end
def favicon(assigns) do
src = "/favicon/sources/#{assigns.domain}"
assigns = assign(assigns, :src, src)
~H"""
<img src={@src} class="w-4 h-4 shrink-0 mt-px" />
"""
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.current_user, site) do
{:ok, preference} ->
flash_message =
if preference.pinned_at do
"Site pinned"
else
"Site unpinned"
end
socket
|> put_live_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_live_flash(:error, flash_message)
|> push_event("js-exec", %{
to: "#site-card-#{hash_domain(site.domain)}",
attr: "data-pin-failed"
})
end
{:noreply, socket}
else
Sentry.capture_message("Attempting to toggle pin for invalid domain.",
extra: %{domain: domain, user: socket.assigns.current_user.id}
)
{:noreply, socket}
end
end
def handle_event(
"filter",
%{"filter-text" => filter_text},
%{assigns: %{filter_text: filter_text}} = socket
) do
{:noreply, socket}
end
def handle_event("filter", %{"filter-text" => filter_text}, socket) do
socket =
socket
|> reset_pagination()
|> set_filter_text(filter_text)
{:noreply, socket}
end
def handle_event("reset-filter-text", _params, socket) do
socket =
socket
|> reset_pagination()
|> set_filter_text("")
{:noreply, socket}
end
defp loading(sites) do
sites.entries
|> Enum.into(%{}, fn site ->
{site.domain, :loading}
end)
end
defp load_sites(%{assigns: assigns} = socket) do
sites =
Sites.list_with_invitations(assigns.current_user, assigns.params,
filter_by_domain: assigns.filter_text,
team: assigns.current_team
)
hourly_stats =
if connected?(socket) do
try do
Plausible.Stats.Clickhouse.last_24h_visitors_hourly_intervals(sites.entries)
catch
kind, value ->
Logger.error(
"Could not render 24h visitors hourly intervals: #{inspect(kind)} #{inspect(value)}"
)
loading(sites)
end
else
loading(sites)
end
invitations = extract_invitations(sites.entries, assigns.current_team)
assign(
socket,
sites: sites,
invitations: invitations,
hourly_stats: hourly_stats
)
end
defp extract_invitations(sites, team) do
sites
|> Enum.filter(&(&1.entry_type == "invitation"))
|> Enum.flat_map(& &1.invitations)
|> Enum.map(&check_limits(&1, team))
end
on_ee do
defp check_limits(%{role: :owner, site: site} = invitation, team) do
case ensure_can_take_ownership(site, team) do
:ok ->
check_features(invitation, team)
{:error, :no_plan} ->
%{invitation: invitation, no_plan: true}
{:error, {:over_plan_limits, limits}} ->
limits = PlausibleWeb.TextHelpers.pretty_list(limits)
%{invitation: invitation, exceeded_limits: limits}
end
end
end
defp check_limits(invitation, _), do: %{invitation: invitation}
defdelegate ensure_can_take_ownership(site, team), to: Teams.Invitations
def check_features(%{role: :owner, site: site} = invitation, team) do
case check_feature_access(site, team) do
:ok ->
%{invitation: invitation}
{:error, {:missing_features, features}} ->
feature_names =
features
|> Enum.map(& &1.display_name())
|> PlausibleWeb.TextHelpers.pretty_list()
%{invitation: invitation, missing_features: feature_names}
end
end
defp check_feature_access(site, new_team) do
missing_features =
Teams.Billing.features_usage(nil, [site.id])
|> Enum.filter(&(&1.check_availability(new_team) != :ok))
if missing_features == [] do
:ok
else
{:error, {:missing_features, missing_features}}
end
end
defp set_filter_text(socket, filter_text) do
filter_text = String.trim(filter_text)
uri = socket.assigns.uri
uri_params =
uri.query
|> URI.decode_query()
|> Map.put("filter_text", filter_text)
|> URI.encode_query()
uri = %{uri | query: uri_params}
socket
|> assign(:filter_text, filter_text)
|> assign(:uri, uri)
|> push_patch(to: URI.to_string(uri), replace: true)
end
defp reset_pagination(socket) do
pagination_fields = ["page"]
uri = socket.assigns.uri
uri_params =
uri.query
|> URI.decode_query()
|> Map.drop(pagination_fields)
|> URI.encode_query()
assign(socket,
uri: %{uri | query: uri_params},
params: Map.drop(socket.assigns.params, pagination_fields)
)
end
defp hash_domain(domain) do
:sha |> :crypto.hash(domain) |> Base.encode16()
end
end