Current section
Files
Jump to
Current section
Files
lib/live_filter/pagination.ex
defmodule LiveFilter.Pagination do
@moduledoc """
Pagination state with PostgREST-compatible limit/offset.
Provides computed properties for page number, total pages, and navigation state.
Uses strict PostgREST URL parameters (`limit`/`offset`) for compatibility.
## Example
# Parse from URL params
{pagination, remaining} = LiveFilter.pagination_from_params(params, default_limit: 25)
# Access computed properties
LiveFilter.Pagination.page(pagination) # => 3
LiveFilter.Pagination.total_pages(pagination) # => 10
LiveFilter.Pagination.has_next?(pagination) # => true
# Serialize back to URL params
LiveFilter.Params.Serializer.pagination_to_params(pagination)
# => %{"limit" => "25", "offset" => "50"}
"""
@max_offset 100_000
@type t :: %__MODULE__{
limit: pos_integer(),
offset: non_neg_integer(),
total_count: non_neg_integer() | nil,
limit_options: [pos_integer()],
max_limit: pos_integer()
}
defstruct limit: 25,
offset: 0,
total_count: nil,
limit_options: [10, 25, 50, 100],
max_limit: 100
@doc """
Creates a new pagination struct with the given options.
## Options
* `:limit` - Number of items per page (default: 25)
* `:offset` - Number of items to skip (default: 0)
* `:total_count` - Total number of items (default: nil)
* `:limit_options` - Available per-page options for UI dropdown (default: [10, 25, 50, 100])
* `:max_limit` - Maximum allowed limit value (default: 100)
## Example
iex> LiveFilter.Pagination.new(limit: 50, offset: 100)
%LiveFilter.Pagination{limit: 50, offset: 100, ...}
"""
@spec new(keyword()) :: t()
def new(opts \\ []) do
struct(__MODULE__, opts)
end
@doc """
Returns the current page number (1-indexed).
## Example
iex> pagination = %LiveFilter.Pagination{limit: 25, offset: 50}
iex> LiveFilter.Pagination.page(pagination)
3
"""
@spec page(t()) :: pos_integer()
def page(%__MODULE__{offset: offset, limit: limit}) do
div(offset, limit) + 1
end
@doc """
Returns the total number of pages, or nil if total_count is unknown.
## Example
iex> pagination = %LiveFilter.Pagination{limit: 25, total_count: 100}
iex> LiveFilter.Pagination.total_pages(pagination)
4
"""
@spec total_pages(t()) :: pos_integer() | nil
def total_pages(%__MODULE__{total_count: nil}), do: nil
def total_pages(%__MODULE__{total_count: total, limit: limit}) do
ceil(total / limit)
end
@doc """
Returns true if there is a previous page.
## Example
iex> pagination = %LiveFilter.Pagination{offset: 25}
iex> LiveFilter.Pagination.has_prev?(pagination)
true
"""
@spec has_prev?(t()) :: boolean()
def has_prev?(%__MODULE__{offset: offset}), do: offset > 0
@doc """
Returns true if there is a next page.
Returns false if total_count is nil (unknown).
## Example
iex> pagination = %LiveFilter.Pagination{limit: 25, offset: 0, total_count: 100}
iex> LiveFilter.Pagination.has_next?(pagination)
true
"""
@spec has_next?(t()) :: boolean()
def has_next?(%__MODULE__{total_count: nil}), do: false
def has_next?(%__MODULE__{offset: offset, limit: limit, total_count: total}) do
offset + limit < total
end
@doc """
Returns the 1-indexed start item number for display.
## Example
iex> pagination = %LiveFilter.Pagination{offset: 50}
iex> LiveFilter.Pagination.start_item(pagination)
51
"""
@spec start_item(t()) :: pos_integer()
def start_item(%__MODULE__{offset: offset}), do: offset + 1
@doc """
Returns the end item number for display.
Capped at total_count if known.
## Example
iex> pagination = %LiveFilter.Pagination{limit: 25, offset: 50, total_count: 60}
iex> LiveFilter.Pagination.end_item(pagination)
60
"""
@spec end_item(t()) :: pos_integer()
def end_item(%__MODULE__{offset: offset, limit: limit, total_count: nil}) do
offset + limit
end
def end_item(%__MODULE__{offset: offset, limit: limit, total_count: total}) do
min(offset + limit, total)
end
@doc """
Updates the pagination with a new total count and computes navigation state.
## Example
iex> pagination = %LiveFilter.Pagination{limit: 25, offset: 50}
iex> LiveFilter.Pagination.with_total(pagination, 127)
%LiveFilter.Pagination{limit: 25, offset: 50, total_count: 127, ...}
"""
@spec with_total(t(), non_neg_integer()) :: t()
def with_total(%__MODULE__{} = pagination, total_count) do
%{pagination | total_count: total_count}
end
@doc """
Returns pagination for the previous page, or the same pagination if already on page 1.
## Example
iex> pagination = %LiveFilter.Pagination{limit: 25, offset: 50}
iex> prev = LiveFilter.Pagination.prev_page(pagination)
iex> prev.offset
25
"""
@spec prev_page(t()) :: t()
def prev_page(%__MODULE__{offset: offset, limit: limit} = pagination) do
%{pagination | offset: max(0, offset - limit)}
end
@doc """
Returns pagination for the next page.
Does not check if a next page exists — caller should check `has_next?/1`.
## Example
iex> pagination = %LiveFilter.Pagination{limit: 25, offset: 50}
iex> next = LiveFilter.Pagination.next_page(pagination)
iex> next.offset
75
"""
@spec next_page(t()) :: t()
def next_page(%__MODULE__{offset: offset, limit: limit} = pagination) do
%{pagination | offset: offset + limit}
end
@doc """
Returns pagination for a specific page number (1-indexed).
Clamps to valid page range if total_count is known.
## Example
iex> pagination = %LiveFilter.Pagination{limit: 25, total_count: 100}
iex> page5 = LiveFilter.Pagination.go_to_page(pagination, 5)
iex> page5.offset
100 # Clamped to max valid offset
"""
@spec go_to_page(t(), pos_integer()) :: t()
def go_to_page(%__MODULE__{limit: limit} = pagination, page)
when is_integer(page) and page >= 1 do
new_offset = (page - 1) * limit
new_offset = clamp_offset(new_offset, pagination.total_count)
%{pagination | offset: new_offset}
end
defp clamp_offset(offset, nil), do: min(offset, @max_offset)
defp clamp_offset(offset, total), do: min(offset, min(@max_offset, max(0, total - 1)))
@doc """
Returns pagination with a new limit (items per page).
Resets offset to 0 to start at page 1.
## Example
iex> pagination = %LiveFilter.Pagination{limit: 25, offset: 50}
iex> new_pagination = LiveFilter.Pagination.change_limit(pagination, 50)
iex> {new_pagination.limit, new_pagination.offset}
{50, 0}
"""
@spec change_limit(t(), pos_integer()) :: t()
def change_limit(%__MODULE__{max_limit: max_limit} = pagination, new_limit)
when is_integer(new_limit) and new_limit > 0 do
clamped_limit = min(new_limit, max_limit)
%{pagination | limit: clamped_limit, offset: 0}
end
@doc """
Resets pagination to the first page (offset: 0).
## Example
iex> pagination = %LiveFilter.Pagination{limit: 25, offset: 100}
iex> reset = LiveFilter.Pagination.reset(pagination)
iex> reset.offset
0
"""
@spec reset(t()) :: t()
def reset(%__MODULE__{} = pagination) do
%{pagination | offset: 0}
end
end