defmodule Scrivener.HTML do
use Phoenix.HTML
@moduledoc """
Primary helper to use is `Scrivener.HTML.pagination_links/4`.
For custom HTML output, see `Scrivener.HTML.raw_pagination_links/2`.
"""
defmodule Default do
@doc """
Default path function when none provided. Used when automatic path function
resolution cannot be performed.
"""
def path(_conn, :index, opts) do
Enum.reduce opts, "?", fn {k, v}, s ->
"#{s}#{if(s == "?", do: "", else: "&")}#{k}=#{v}"
end
end
end
@defaults [view_style: :bootstrap]
@doc """
Generates the HTML pagination links for a given paginator returned by Scrivener.
The default options are:
#{inspect @defaults}
The `view_style` indicates which CSS framework you are using. The default is
`:bootstrap`, but you can add your own using the `Scrivener.HTML.raw_pagination_links/2` function
if desired.
An example of the output data:
iex> Scrivener.HTML.pagination_links(%Scrivener.Page{total_pages: 10, page_number: 5})
{:safe,
[""]}
In order to generate links with nested objects (such as a list of comments for a given post)
it is necessary to pass those arguments. All arguments in the `args` parameter will be directly
passed to the path helper function. Everything within `opts` which are not options will passed
as `params` to the path helper function. For example, `@post`, which has an index of paginated
`@comments` would look like the following:
Scrivener.HTML.pagination_links(@conn, @comments, [@post.id], view_style: :bootstrap, my_param: "foo")
You'll need to be sure to configure `:scrivener_html` with the `:routes_helper`
module (ex. MyApp.Routes.Helpers) in Phoenix. With that configured, the above would generate calls
to the `post_comment_path(@conn, :index, @post.id, my_param: "foo", page: page)` for each page link.
In times that it is necessary to override the automatic path function resolution, you may supply the
correct path function to use by adding an extra key in the `opts` parameter of `:path`.
For example:
Scrivener.HTML.pagination_links(@conn, @comments, [@post.id], path: &post_comment_path/4)
Be sure to supply the function which accepts query string parameters (starts at arity 3, +1 for each relation),
because the `page` parameter will always be supplied. If you supply the wrong function you will receive a
function undefined exception.
"""
def pagination_links(conn, paginator, args, opts) do
merged_opts = Dict.merge @defaults,
view_style: opts[:view_style] || Application.get_env(:scrivener_html, :view_style, :bootstrap)
path = opts[:path] || find_path_fn(paginator.entries, args)
params = Dict.drop opts, (Dict.keys(@defaults) ++ [:path])
# Ensure ordering so pattern matching is reliable
_pagination_links paginator,
view_style: merged_opts[:view_style],
path: path,
args: [conn, :index] ++ args,
params: params
end
def pagination_links(%Scrivener.Page{} = paginator), do: pagination_links(nil, paginator, [], [])
def pagination_links(%Scrivener.Page{} = paginator, opts), do: pagination_links(nil, paginator, [], opts)
def pagination_links(conn, %Scrivener.Page{} = paginator), do: pagination_links(conn, paginator, [], [])
def pagination_links(conn, paginator, [{_, _} | _] = opts), do: pagination_links(conn, paginator, [], opts)
def pagination_links(conn, paginator, [_ | _] = args), do: pagination_links(conn, paginator, args, [])
defp find_path_fn(nil, _path_args), do: &Default.path/3
# Define a different version of `find_path_fn` whenever Phoenix is available.
if Code.ensure_loaded(Phoenix.Naming) do
defp find_path_fn(entries, path_args) do
routes_helper_module = Application.get_env(:scrivener_html, :routes_helper) || raise("Scrivener.HTML: Unable to find configured routes_helper module (ex. MyApp.RoutesHelper)")
path = (path_args ++ [entries |> List.first]) |> Enum.reduce &( :"#{&2}_#{Phoenix.Naming.resource_name(&1[:__struct__])}")
{path_fn, []} = Code.eval_quoted(quote do: &unquote(routes_helper_module).unquote(path)/unquote((path_args |> Enum.count) + 3))
path_fn
end
else
defp find_path_fn(_entries, _args), do: &Default/3
end
# Bootstrap implementation
defp _pagination_links(paginator, [view_style: :bootstrap, path: path, args: args, params: params]) do
# Currently nesting content_tag's is broken...
links = raw_pagination_links(paginator)
|> Enum.map fn ({text, page_number})->
classes = []
if paginator.page_number == page_number do
classes = ["active"]
end
params_with_page = Dict.merge(params, page: page_number)
l = link("#{text}", to: apply(path, args ++ [params_with_page]), class: Enum.join(classes, " "))
content_tag(:li, l)
end
ul = content_tag(:ul, links, class: "pagination")
content_tag(:nav, ul)
end
defp _pagination_links(_paginator, [view_style: unknown, path: _path, args: _args, params: _params]) do
raise "Scrivener.HTML: Unable to render view_style #{inspect unknown}"
end
@defaults [distance: 5, next: ">>", previous: "<<", first: true, last: true]
@doc """
Returns the raw data in order to generate the proper HTML for pagination links. Data
is returned in a `{text, page_number}` format where `text` is intended to be the text
of the link and `page_number` is the page it should go to. Defaults are already supplied
and they are as follows:
#{inspect @defaults}
`distance` must be a positive non-zero integer or an exception is raised. `next` and `previous` should be
strings but can be anything you want as long as it is truthy, falsey values will remove
them from the output. `first` and `last` are only booleans, and they just include/remove
their respective link from output. An example of the data returned:
iex> Scrivener.HTML.raw_pagination_links(%{total_pages: 10, page_number: 5})
[{"<<", 4}, {1, 1}, {2, 2}, {3, 3}, {4, 4}, {5, 5}, {6, 6}, {7, 7}, {8, 8}, {9, 9}, {10, 10}, {">>", 6}]
Simply loop and pattern match over each item and transform it to your custom HTML.
"""
def raw_pagination_links(paginator, options \\ []) do
options = Dict.merge @defaults, options
page_number_list(paginator.page_number, paginator.total_pages, options[:distance])
|> add_first(paginator.page_number, options[:distance], options[:first])
|> add_previous(paginator.page_number)
|> add_last(paginator.page_number, paginator.total_pages, options[:distance], options[:last])
|> add_next(paginator.page_number, paginator.total_pages)
|> Enum.map(fn
:next -> if options[:next], do: {options[:next], paginator.page_number + 1}
:previous -> if options[:previous], do: {options[:previous], paginator.page_number - 1}
num -> {num, num}
end) |> Enum.filter(&(&1))
end
# Computing page number ranges
defp page_number_list(page, total, distance) when is_integer(distance) and distance >= 1 do
Enum.to_list((page - beginning_distance(page, distance))..(page + end_distance(page, total, distance)))
end
defp page_number_list(_page, _total, _distance) do
raise "Scrivener.HTML: Distance cannot be less than one."
end
# Beginning distance computation
defp beginning_distance(page, distance) when page - distance < 1 do
distance + (page - distance - 1)
end
defp beginning_distance(_page, distance) do
distance
end
# End distance computation
defp end_distance(page, total, distance) when page + distance >= total do
total - page
end
defp end_distance(_page, _total, distance) do
distance
end
# Adding next/prev/first/last links
defp add_previous(list, page) when page != 1 do
[:previous] ++ list
end
defp add_previous(list, _page) do
list
end
defp add_first(list, page, distance, true) when page - distance > 1 do
[1] ++ list
end
defp add_first(list, _page, _distance, _included) do
list
end
defp add_last(list, page, total, distance, true) when page + distance < total do
list ++ [total]
end
defp add_last(list, _page, _total, _distance, _included) do
list
end
defp add_next(list, page, total) when page != total do
list ++ [:next]
end
defp add_next(list, _page, _total) do
list
end
end
# Must do this until Scrivener adds @derive [Enumerable, Access]
defimpl Enumerable, for: Scrivener.Page do
def reduce(pages, acc, fun), do: Enum.reduce(pages.entries || [], acc, fun)
def member?(pages, page), do: page in pages.entries
def count(pages), do: length(pages.entries)
end
defimpl Access, for: Scrivener.Page do
def get(pages, key), do: Map.get(pages, key)
def get_and_update(pages, key, fun), do: Map.get_and_update(pages, key, fun)
end