defmodule Scrivener.PhoenixView do
@moduledoc ~S"""
The module which provides the helper to generate links for a Scrivener pagination.
"""
alias Scrivener.Phoenix.Gap
alias Scrivener.Phoenix.Page
import Scrivener.Phoenix.Gettext
@default_left 0
@default_right 0
@default_window 4
@default_outer_window 0
@default_live nil
@default_inverted false
@default_param_name :page
@default_merge_params false
@default_display_if_single false
@default_template Scrivener.Phoenix.Template.Bootstrap4
defp defaults do
[
left: @default_left,
right: @default_right,
window: @default_window,
outer_window: @default_outer_window,
live: @default_live,
inverted: @default_inverted, # NOTE: would be great if it was an option handled by (and passed from - part of %Scriver.Page{}) Scrivener
display_if_single: @default_display_if_single,
param_name: @default_param_name,
merge_params: @default_merge_params,
template: @default_template,
labels: %{
first: dgettext("scrivener_phoenix", "First"),
prev: dgettext("scrivener_phoenix", "Prev"),
next: dgettext("scrivener_phoenix", "Next"),
last: dgettext("scrivener_phoenix", "Last"),
},
symbols: %{
first: "«",
prev: "‹",
next: "›",
last: "»",
#gap: "…",
},
]
end
@typep conn_or_socket_or_endpoint :: Plug.Conn.t | Phoenix.LiveView.Socket.t | module
#@typep options :: %{optional(atom) => any}
@type options :: %{
left: non_neg_integer,
right: non_neg_integer,
window: non_neg_integer,
outer_window: non_neg_integer,
live: boolean | nil,
inverted: boolean,
display_if_single: boolean,
param_name: atom | String.t,
merge_params: boolean | [atom | String.t],
template: module,
labels: %{
first: String.t,
prev: String.t,
next: String.t,
last: String.t,
},
symbols: %{
first: String.t,
prev: String.t,
next: String.t,
last: String.t,
},
}
@spec do_paginate(conn :: conn_or_socket_or_endpoint, page :: Scrivener.Page.t, fun :: function, arguments :: list, options :: %{optional(atom) => any}) :: Phoenix.HTML.safe
# skip pagination if:
# - there is zero entry (total)
# - there only is a single page and display_if_single = false
defp do_paginate(_conn, %Scrivener.Page{total_entries: 0}, _fun, _arguments, _options), do: nil
defp do_paginate(_conn, %Scrivener.Page{total_pages: 1}, _fun, _arguments, %{display_if_single: false}), do: nil
defp do_paginate(conn, page = %Scrivener.Page{}, fun, arguments, options = %{})
when is_function(fun)
do
map = %{
first: options.inverted,
prev: options.inverted,
next: !options.inverted,
last: !options.inverted,
}
options = %{options | labels: options.labels
|> Enum.reduce(options.labels, fn {k, v}, acc ->
label =
[v]
|> List.insert_at(bool_to_int(map[k]), options.symbols[k])
|> Enum.join(" ")
|> String.trim()
Map.put(acc, k, label)
end)
}
left_window_plus_one = range_as_list(1, options.left + 1)
right_window_plus_one = range_as_list(page.total_pages - options.right, page.total_pages)
inside_window_plus_each_sides = range_as_list(page.page_number - options.window - 1, page.page_number + options.window + 1)
first_page = Page.create(1, url(conn, fun, arguments, 1, options))
last_page = Page.create(page.total_pages, url(conn, fun, arguments, page.total_pages, options))
prev_page = if has_prev?(page) do
Page.create(page.page_number - 1, url(conn, fun, arguments, page.page_number - 1, options))
end
next_page = if has_next?(page) do
Page.create(page.page_number + 1, url(conn, fun, arguments, page.page_number + 1, options))
end
window_pages =
left_window_plus_one
|> Kernel.++(right_window_plus_one)
|> Kernel.++(inside_window_plus_each_sides)
|> Enum.sort()
|> Enum.uniq()
|> Enum.reject(&(&1 < 1 or &1 > page.total_pages))
|> Enum.map(
fn page_number ->
Page.create(page_number, url(conn, fun, arguments, page_number, options))
end
)
|> add_gap(page, options)
|> reverse_links_if_not_inversed(options)
[]
|> prepend_right_links(page, first_page, prev_page, next_page, last_page, options)
|> append_pages(window_pages, page, options)
|> Enum.reverse()
|> prepend_left_links(page, first_page, prev_page, next_page, last_page, options)
|> options.template.wrap()
end
defp auto_set_live_option(options = %{live: nil}, cse)
when is_map(options)
do
Map.put(options, :live, is_struct(cse) and cse.__struct__ == Phoenix.LiveView.Socket)
end
defp auto_set_live_option(options, _cse), do: options
@doc """
Generates the whole HTML to navigate between pages.
Options:
* left (default: `#{inspect(@default_left)}`): display the *left* first pages
* right (default: `#{inspect(@default_right)}`): display the *right* last pages
* window (default: `#{inspect(@default_window)}`): display *window* pages before and after the current page (eg, if 7 is the current page and window is 2, you'd get: `5 6 7 8 9`)
* outer_window (default: `#{inspect(@default_outer_window)}`), equivalent to left = right = outer_window: display the *outer_window* first and last pages (eg valued to 2:
`« First ‹ Prev 1 2 ... 5 6 7 8 9 ... 19 20 Next › Last »` as opposed to left = 1 and right = 3: `« First ‹ Prev 1 ... 5 6 7 8 9 ... 18 19 20 Next › Last »`)
* live (default: `#{inspect(@default_live)}`):
+ `true` to generate links with `Phoenix.LiveView.Helpers.live_patch/2` instead of `Phoenix.HTML.Link.link/2`
+ `nil` to set it automatically to `true` when `paginate/5` is called with a `%Phoenix.LiveView.Socket{}` as its first parameter else `false`
* inverted (default: `#{inspect(@default_inverted)}`): `true` to first (left side) link last pages instead of first
* display_if_single (default: `#{inspect(@default_display_if_single)}`): `true` to force a pagination to be displayed when there only is a single page of result(s)
* param_name (default: `#{inspect(@default_param_name)}`): the name of the parameter generated in URL (query string) to propagate the page number
* merge_params (default: `#{inspect(@default_merge_params)}`): `true` to copy the entire query string between requests, `false` to ignore it or a list of the parameter names to only reproduce
* template (default: `#{inspect(@default_template)}`): the module which implements `Scrivener.Phoenix.Template` to use to render links to pages
* symbols (default: `%{first: "«", prev: "‹", next: "›", last: "»"}`): the symbols to add before or after the label for the first, previous, next and last page (`nil` or `""` for none)
* labels (default: `%{first: dgettext("scrivener_phoenix", "First"), prev: dgettext("scrivener_phoenix", "Prev"), next: dgettext("scrivener_phoenix", "Next"), last: dgettext("scrivener_phoenix", "Last")}`):
the texts used by links to describe the first, previous, next and last page
"""
@spec paginate(conn :: conn_or_socket_or_endpoint, spage :: Scrivener.Page.t, fun :: function, arguments :: list, options :: Keyword.t) :: Phoenix.HTML.safe
def paginate(conn, page = %Scrivener.Page{}, fun, arguments \\ [], options \\ [])
when is_function(fun)
do
# if length(arguments) > arity(fun)
# the page (its number) is part of route parameters
# else
# it has to be integrated to the query string
# fi
# WARNING: usage of the query string implies to use the route with an arity + 1 because Phoenix create routes as:
# def blog_page_path(conn, action, pageno, options \\ [])
# defaults() < config (Application) < options
options =
defaults()
|> Keyword.merge(Application.get_all_env(:scrivener_phoenix))
|> Keyword.merge(options)
|> Enum.into(%{})
|> adjust_symbols_if_needed()
|> auto_set_live_option(conn)
do_paginate(conn, page, fun, arguments, options)
end
defp adjust_symbols_if_needed(options = %{inverted: true}) do
%{options | symbols: %{first: options.symbols.last, prev: options.symbols.next, next: options.symbols.prev, last: options.symbols.first}}
end
defp adjust_symbols_if_needed(options), do: options
defp prepend_right_links(links, page, first, prev, _next, _last, options = %{inverted: true}) do
links
|> maybe_prepend(&options.template.prev_page/2, prev, options)
|> maybe_prepend(&options.template.first_page/3, first, page, options)
end
defp prepend_right_links(links, page, _first, _prev, next, last, options) do
links
|> maybe_prepend(&options.template.next_page/2, next, options)
|> maybe_prepend(&options.template.last_page/3, last, page, options)
end
defp prepend_left_links(links, page, _first, _prev, next, last, options = %{inverted: true}) do
links
|> maybe_prepend(&options.template.next_page/2, next, options)
|> maybe_prepend(&options.template.last_page/3, last, page, options)
end
defp prepend_left_links(links, page, first, prev, _next, _last, options) do
links
|> maybe_prepend(&options.template.prev_page/2, prev, options)
|> maybe_prepend(&options.template.first_page/3, first, page, options)
end
defp reverse_links_if_not_inversed(links, %{inverted: true}), do: links
defp reverse_links_if_not_inversed(links, _options) do
links
|> Enum.reverse()
end
defp prepend_to_list_if_not_nil(nil, list), do: list
defp prepend_to_list_if_not_nil(value, list) do
[value | list]
end
defp maybe_prepend(links, fun, page, options) do
page
|> fun.(options)
|> prepend_to_list_if_not_nil(links)
end
defp maybe_prepend(links, fun, page, spage, options) do
page
|> fun.(spage, options)
|> prepend_to_list_if_not_nil(links)
end
defp append_pages(links, pages, spage, options) do
result =
pages
|> Enum.reverse()
|> Enum.map(
fn page ->
options.template.page(page, spage, options)
end
)
Enum.concat(links, result)
end
@spec has_prev?(page :: Scrivener.Page.t) :: boolean
def has_prev?(page = %Scrivener.Page{}) do
page.page_number > 1
end
@spec has_next?(page :: Scrivener.Page.t) :: boolean
def has_next?(page = %Scrivener.Page{}) do
page.page_number < page.total_pages
end
defp was_truncated([%Gap{} | _ ]), do: true
defp was_truncated(_), do: false
defp do_add_gap([], acc, _page = %Scrivener.Page{}, _options = %{}) do
acc
end
defp do_add_gap([hd | tl], acc, page = %Scrivener.Page{}, options = %{}) do
import Scrivener.Phoenix.Page
acc = cond do
left_outer?(hd, options) || right_outer?(hd, page, options) || inside_window?(hd, page, options) ->
[hd | acc]
!was_truncated(acc) ->
[%Gap{} | acc]
true ->
acc
end
do_add_gap(tl, acc, page, options)
end
def add_gap(pages, page = %Scrivener.Page{}, options = %{}) do
do_add_gap(pages, [], page, options)
end
@spec range_as_list(l :: integer, h :: integer) :: [integer]
defp range_as_list(l, h) do
l
|> Range.new(h)
|> Enum.to_list()
end
@spec map_to_keyword(map :: map) :: Keyword.t
defp map_to_keyword(map = %{}) do
map
|> Enum.into([])
end
defp bool_to_int(true), do: 1
defp bool_to_int(false), do: 0
@doc false # public for testing
def url(conn, fun, helper_arguments, page_number, options) do
{:arity, arity} = :erlang.fun_info(fun, :arity)
arguments = handle_arguments(conn, arity, helper_arguments, page_number, options)
apply(fun, arguments)
end
@spec filter_params(params :: map, options :: options) :: map
defp filter_params(params, %{merge_params: true}) do
params
end
defp filter_params(params, %{merge_params: which})
when is_list(which)
do
Map.take(params, which |> Enum.map(&to_string/1))
end
@spec query_params(conn_or_socket_or_endpoint :: conn_or_socket_or_endpoint, options :: options) :: map
defp query_params(%Plug.Conn{}, %{merge_params: false}) do
%{}
end
defp query_params(conn = %Plug.Conn{}, options) do
conn = Plug.Conn.fetch_query_params(conn)
conn.query_params
|> filter_params(options)
end
defp query_params(%Phoenix.LiveView.Socket{}, _options) do
%{}
end
defp query_params(endpoint, _options)
when is_atom(endpoint)
do
%{}
end
# if length(helper_arguments) > arity(fun) then integrate page_number as helper's arguments
defp handle_arguments(conn, arity, helper_arguments, page_number, options)
when arity == length(helper_arguments) + 3 # 3 for (not counted) conn + additionnal parameters (query string) + page (as part of URL's path)
do
new_query_params =
conn
|> query_params(options)
|> Map.delete(to_string(options.param_name))
|> map_to_keyword()
[conn | helper_arguments] ++ [page_number, new_query_params]
end
# else integrate page_number as query string
defp handle_arguments(conn, arity, helper_arguments, page_number, options)
when arity == length(helper_arguments) + 2 # 2 for (not counted) conn + additionnal parameters (query string)
do
new_query_params =
conn
|> query_params(options)
|> Map.put(options.param_name, page_number)
|> map_to_keyword()
[conn | helper_arguments] ++ [new_query_params]
end
end