Implement event-based pagination and sorting
mainon_paginate and on_sort attributes on the components instead of the path attribute. You must then handle these events using the handle_event/3 callback in your LiveView.repository·main·Indexed 19 days ago
https://github.com/woylie/flop_phoenixPhoenix components for pagination, sortable tables, and filter forms, designed to work with the Flop and Ecto libraries. It provides Flop.Phoenix.table and Flop.Phoenix.pagination components, as well as Flop.Phoenix.filter_fields for creating filter forms. The library supports page-based and cursor-based pagination, LiveView streams, and event-based sorting and pagination.
on_paginate and on_sort attributes on the components instead of the path attribute. You must then handle these events using the handle_event/3 callback in your LiveView.Flop.Meta implements Phoenix.HTML.FormData, allowing it to be used directly with Phoenix forms.
To render a filter form:
meta struct to a form using Phoenix.Component.to_form/1.Flop.Phoenix.filter_fields/1 component inside a <.form> block. This component generates necessary hidden inputs and provides a :let binding containing field and label details for each field.<.input />) inside the filter_fields block to render the actual visible inputs.phx-change="update-filter") in your LiveView to update the URL or state.# Example of a custom filter form component
def filter_form(%{meta: meta} = assigns) do
assigns = assign(assigns, form: Phoenix.Component.to_form(meta), meta: nil)
~H"""
<.form
for={@form}
id={@id}
phx-target={@target}
phx-change={@on_change}
phx-submit={@on_change}
>
<.filter_fields :let={i} form={@form} fields={@fields}>
<.input
field={i.field}
label={i.label}
type={i.type}
phx-debounce={120}
{i.rest}
/>
</.filter_fields>
<button name="reset">reset</button>
</.form>
"""
end
# Usage in HEEx
<.filter_form
fields={[:name, :email]}
meta={@meta}
id="user-filter-form"
/>
# LiveView event handler
@impl true
def handle_event("update-filter", params, socket) do
params = Map.delete(params, "_target")
{:noreply, push_patch(socket, to: ~p"/pets?#{params}")}
endThe Flop.Phoenix.pagination component does not include default styles. You can style it by passing a class attribute to the component, which will be applied to the wrapping <nav> element.
To style the component effectively, you can target the following internal elements:
<nav> element (targeted via the class you provided).<ul>) that is a direct descendant of the <nav> element, containing list items (<li>) for each page.<span> inside an <li> (e.g., li > span).<a> or <button> elements used for the 'previous' and 'next' controls, as well as the page numbers. Note that Flop.Phoenix uses <a> if a path is provided, otherwise it uses <button>.[aria-current="page"] attribute.[disabled] attribute (for buttons) or [aria-disabled="true"] attribute (for links).<Flop.Phoenix.pagination class="pagination" ...>
<%!-- ... --%>
</Flop.Phoenix.pagination>To use LiveView streams with the table component:
handle_params/3, use stream/3 to assign your data to the socket (e.g., stream(:pets, pets, reset: true)).@streams.pets to the items attribute of Flop.Phoenix.table.:col slot, match on the stream tuple format {{id, item}} using the :let attribute.# In LiveView
def handle_params(params, _, socket) do
{pets, meta} = Pets.list_pets(params)
{:noreply, socket |> assign(:meta, meta) |> stream(:pets, pets, reset: true)}
end
# In HEEx
<Flop.Phoenix.table items={@streams.pets} meta={@meta} path={~p"/pets"}>
<:col :let={{_, pet}} label="Name" field={:name}>{pet.name}</:col>
<:col :let={{_, pet}} label="Age" field={:age}>{pet.age}</:col>
</Flop.Phoenix.table>To add infinite scroll to a Flop-powered view, follow these steps:
InfiniteScroll hook using IntersectionObserver. The hook should observe an element identified by a data-anchor-id attribute. When the observer detects intersection, it calls this.pushEvent("load-more", {}).Hooks object is passed to your LiveSocket configuration in app.js.div that has phx-hook="InfiniteScroll" and a data-anchor-id matching the ID of your anchor element.handle_event("load-more", ...) in your LiveView. This function should check if socket.assigns.meta.end_cursor exists, and if so, use push_patch to update the URL with the after parameter.// 1. JavaScript Hook
Hooks.InfiniteScroll = {
mounted() {
observer = new IntersectionObserver(
(entries) => {
entries.forEach((entry) => {
if (entry.isIntersecting) {
this.pushEvent("load-more", {});
}
});
},
{ root: null, rootMargin: "0px", threshold: 1.0 }
);
const anchorId = this.el.dataset.anchorId;
const anchor = document.getElementById(anchorId);
if (anchor) observer.observe(anchor);
},
};
// 2. LiveSocket setup
let liveSocket = new LiveSocket("/live", Socket, {
hooks: Hooks,
});// 4. LiveView handle_event
def handle_event("load-more", _, socket) do
if end_cursor = socket.assigns.meta.end_cursor do
{:noreply, push_patch(socket, to: ~p"/pets?after=#{end_cursor}")}
else
{:noreply, socket}
}
end<%!-- 3. Template --%
<div id="main-content" phx-hook="InfiniteScroll" data-anchor-id="load-more-anchor">
<Flop.Phoenix.table ... />
<p :if={@meta.has_next_page?} id="load-more-anchor">
<.link patch={~p"/pets?after=#{@meta.end_cursor}"}>Load More</.link>
</p>
</div>Add flop_phoenix to your mix.exs dependencies to use Phoenix components for pagination, sortable tables, and filter forms with Flop and Ecto.
def deps do
[
{:flop_phoenix, "~> 0.26.1"}
]
endTo allow users to change the number of items displayed per page, you can implement a custom component that sends a phx-click event to your LiveView. This event should update the page_size and reset the limit in your Flop metadata, then navigate to the updated path using push_patch.
Define a component (e.g., page_size_links/1) that iterates over a list of desired sizes and highlights the currently active size.
Pass the current page size from your Flop metadata to the component:
<.page_size_links current_size={@meta.page_size} />
In your LiveView, catch the set-page-size event, update the flop struct within your metadata, and use Flop.Phoenix.build_path/2 to generate the new URL for patching.
# 1. Component Definition
attr :current_size, :integer, required: true
def page_size_links(assigns) do
~H"""
<ul class="page-size-links">
<li
:for={page_size <- [10, 20, 40, 60]}
class={page_size == @current_size && "is-active"}
>
<.link phx-click="set-page-size" phx-value-size={page_size}>
{page_size}
</.link>
</li>
</ul>
"""
end
# 2. Rendering in Template
<.page_size_links current_size={@meta.page_size} />
# 3. Event Handler in LiveView
def handle_event("set-page-size", %{"size" => page_size}, socket) do
flop = %{socket.assigns.meta.flop | page_size: page_size, limit: nil}
path = Flop.Phoenix.build_path(~p"/pets", flop)
{:noreply, push_patch(socket, to: path)}
endIf you want to render visible inputs for meta parameters (like page_size) instead of relying on the hidden inputs generated by filter_fields, you can manually add them to your form component. Ensure the name attribute matches the expected parameter name (e.g., name="page_size").
<.form
for={@form}
id={@id}
phx-target={@target}
phx-change={@on_change}
phx-submit={@on_change}
>
<%!-- ... --%>
<label for="filter-form-page-size">Page size</label>
<input
id="filter-form-page-size"
type="text"
name="page_size"
value={@meta.page_size}
/>
<button name="reset">reset</button>
</.form>The Flop.Phoenix.pagination component always renders markup in this order:
<ul>) containing page number links/buttons.To change the visual order without changing the HTML structure, use the CSS order property on the flex items within the <nav> element.
Example: Page numbers first, then Previous/Next
> a:first-child,
> button:first-child {
order: 2;
}
> a:last-of-type,
> button:last-of-type {
order: 3;
}
> ul {
order: 1;
}Example: Previous, then Page numbers, then Next
> a:first-child,
> button:first-child {
order: 1;
}
> a:last-of-type,
> button:last-of-type {
order: 3;
}
> ul {
order: 2;
}/* To put the page number links/buttons first and the previous/next links/buttons last */
> a:first-child,
> button:first-child {
order: 2;
}
> a:last-of-type,
> button:last-of-type {
order: 3;
}
> ul {
order: 1;
}To implement a 'Load More' button, use Flop's cursor pagination.
Flop.Schema and set default_pagination_type: :first (or another cursor-based type) to ensure a response with cursors is returned even without parameters. Set a default_limit and a sortable column (e.g., id).Flop.validate_and_run! with default_pagination_type: :first and pagination_types: [:first]. This ensures the function returns cursors.handle_params, call the list function and assign the meta struct to the socket. Use stream to manage the items.patch to navigate to the base URL with the after parameter set to @meta.end_cursor. Only render this link if @meta.has_next_page? is true.# 1. Schema configuration
@derive {Flop.Schema,
filterable: [:name],
sortable: [:id],
default_order: %{order_by: [:id], order_directions: [:desc]},
default_limit: 50}
# 2. List function
def list_pets(params) do
Flop.validate_and_run!(Pet, params,
for: Pet,
default_pagination_type: :first,
pagination_types: [:first],
replace_invalid_params: true,
filtering: false,
ordering: false
)
end
# 3. LiveView handle_params
@impl true
def handle_params(params, _url, socket) do
{pets, meta} = Pets.list_pets(params)
{:noreply, socket |> stream(:pets, pets) |> assign(:meta, meta)}
end<%!-- 4. Template link --%
<p :if={@meta.has_next_page?}>
<.link patch={~p"/pets?after=#{@meta.end_cursor}"}>Load More</.link>
</p>In your LiveView's handle_params/3 callback, call your context function with the incoming params. Assign both the returned data and the meta struct to the socket.
defmodule MyAppWeb.PetLive.Index do
use MyAppWeb, :live_view
alias MyApp.Pets
@impl Phoenix.LiveView
def handle_params(params, _, socket) do
{pets, meta} = Pets.list_pets(params)
{:noreply, assign(socket, pets: pets, meta: meta)}
end
endIn your context module, define a function that performs a list query using Flop.validate_and_run!/3. It is recommended to use the replace_invalid_params: true option to allow Flop to ignore invalid parameters instead of raising an error.
defmodule MyApp.Pets do
alias MyApp.Pet
def list_pets(params) do
Flop.validate_and_run!(Pet, params, for: Pet, replace_invalid_params: true)
end
end