Petal Pro is the full SaaS app this is built for

Auth, billing, admin, and Claude Code integration included. One purchase, unlimited projects.

Petal Pro Available to Petal Pro members

Data Table (legacy)

An advanced data table with sorting, pagination, and search functionality.
Id
Title
Views
14
Consequatur dolores tempora repudiandae sed dolorum dolor.
492
41
Consequatur repudiandae sint voluptas reprehenderit.
309
10
Consequuntur harum illum quaerat aut.
87
74
Cumque doloremque et corporis quia rem esse eum voluptas.
392
18
Cupiditate fuga quisquam minus aperiam adipisci quisquam est maiores molestias!
575
46
Deleniti ex eos iste distinctio.
762
87
Deserunt explicabo officia ut ut ut dolore reprehenderit ut rerum.
73
69
Dicta corporis deleniti sit necessitatibus velit doloremque.
519
55
Dicta dignissimos architecto at ipsam ipsam placeat!
895
29
Dolorem laboriosam et officia et dolorum numquam dignissimos voluptas.
229
11–20 of 101
Rows per page 10 20 50
<DataTable.data_table meta={@meta} items={@items} class="mt-10">
<:col field={:id} sortable filterable={[:==]} class="w-28" />
<:col :let={widget} field={:title} sortable filterable={[:ilike]}>
<div class="line-clamp-1"><%= widget.title %></div>
</:col>
<:col field={:views} sortable />
</DataTable.data_table>
defmodule MyAppWeb.WidgetsLive do
use MyAppWeb, :live_view
@data_table_opts [
default_limit: 10,
default_order: %{
order_by: [:views, :title],
order_directions: [:desc, :asc]
},
sortable: [:id, :views, :inserted_at],
filterable: [:views, :title]
]
alias PetalProWeb.DataTable
@impl true
def mount(_params, _session, socket) do
{:ok, assign(socket, current_page: :data_table)}
end
@impl true
def handle_params(params, _url, socket) do
starting_query = App.Widgets.Widget
{items, meta} = DataTable.search(starting_query, params, @data_table_opts)
{:noreply, assign(socket, items: items, meta: meta)}
end
@impl true
def handle_event("update_filters", %{"filters" => filter_params}, socket) do
query_params = DataTable.build_filter_params(socket.assigns.meta, filter_params)
{:noreply, push_patch(socket, to: ~p"/data-table?#{query_params}")}
end
end
defmodule MyApp.Widgets.Widget do
use Ecto.Schema
import Ecto.Changeset
schema "widgets" do
field(:title, :string)
field(:views, :integer)
belongs_to(:widget_category, MyApp.Widgets.WidgetCategory)
timestamps()
end
end
defmodule MyApp.Widgets.WidgetCategory do
use Ecto.Schema
import Ecto.Changeset
schema "widget_category" do
field(:name, :string)
timestamps()
end
end
defmodule MyApp.Repo.Migrations.CreateWidgets do
use Ecto.Migration
def change do
create table(:widgets) do
add(:title, :string)
add(:views, :integer)
add(:widget_category_id, :integer)
timestamps()
end
create table(:widget_category) do
add(:name, :string)
timestamps()
end
end
end
# Prerequisites:
# {:faker, "~> 0.17"},
# {:timex, "~> 3.7"},
alias App.Widgets.WidgetCategory
alias App.Widgets.Widget
alias App.Repo
# Create 10 categories
Enum.each(0..10, fn _ ->
%WidgetCategory{}
|> WidgetCategory.changeset(%{name: Faker.Lorem.word()})
|> Repo.insert!()
end)
# Fetch the categories
categories = Repo.all(WidgetCategory)
count = 100
data =
Enum.reduce(0..count, [], fn _x, acc ->
time =
Timex.shift(Timex.now(), days: -Enum.random(1..90))
|> Timex.to_naive_datetime()
|> NaiveDateTime.truncate(:second)
title = Faker.Lorem.sentence(5..10)
# Create a random news item
[
%{
title: title,
widget_category_id: Enum.random(categories).id,
views: Enum.random(1..1000),
inserted_at: time,
updated_at: time
}
| acc
]
end)
Repo.insert_all(Widget, data)

How to access

Membership $299
-> 
See plans

This comes with Petal Pro. Purchase a membership to get access to this package. It can be used with any Phoenix project. Post-expiration, you'll retain access but won't be eligible for updates from newer versions.

Schema setup

Add @derive Flop.Schema to your Ecto schema to declare which fields are sortable and filterable.

defmodule MyApp.Widgets.Widget do
use Ecto.Schema
@derive {
Flop.Schema,
filterable: [:title, :views],
sortable: [:id, :title, :views, :inserted_at]
}
schema "widgets" do
field :title, :string
field :views, :integer
timestamps()
end
end

LiveView wiring

Three callbacks to wire up:

  • mount/3 - initial setup (no query needed here)
  • handle_params/3 - runs the Flop query and assigns items + meta
  • handle_event("update_filters", ...) - patches the URL when a filter changes
defmodule MyAppWeb.WidgetsLive do
use MyAppWeb, :live_view
alias PetalProWeb.DataTable
@data_table_opts [
default_limit: 10,
default_order: %{
order_by: [:inserted_at],
order_directions: [:desc]
}
]
@impl true
def mount(_params, _session, socket) do
{:ok, socket}
end
@impl true
def handle_params(params, _url, socket) do
{items, meta} = DataTable.search(MyApp.Widgets.Widget, params, @data_table_opts)
{:noreply, assign(socket, items: items, meta: meta)}
end
@impl true
def handle_event("update_filters", %{"filters" => filter_params}, socket) do
query_params = DataTable.build_filter_params(socket.assigns.meta, filter_params)
{:noreply, push_patch(socket, to: ~p"/widgets?#{query_params}")}
end
end

Sortable columns

Add sortable to any column to make its header clickable for ascending/descending sort. The field must also be in the sortable list of your @derive Flop.Schema.

<:col field={:title} sortable />
<:col field={:views} sortable />
<:col field={:inserted_at} sortable label="Created" />

Filterable columns

Pass a list of filter operators to filterable. A filter input appears in the column header. If you pass more than one operator, the user can switch between them.

<%!-- Case-insensitive text search --%>
<:col field={:title} filterable={[:ilike]} />
<%!-- Exact match --%>
<:col field={:status} filterable={[:==]} />
<%!-- Multiple operators the user can switch between --%>
<:col field={:views} filterable={[:==, :>=, :<=]} type={:integer} />

Filter operators

Operator SQL
:== column = value
:!= column != value
:ilike column ILIKE '%value%'
:like column LIKE '%value%'
:ilike_and column ILIKE '%a%' AND column ILIKE '%b%'
:ilike_or column ILIKE '%a%' OR column ILIKE '%b%'
:empty column IS NULL
:not_empty column IS NOT NULL
:<= column <= value
:< column < value
:>= column >= value
:> column > value
:in column = ANY(values)
:not_in column NOT IN (values)
:contains value = ANY(column)

Filter input types

The type attr controls which input is rendered for the filter.

<%!-- Number input --%>
<:col field={:views} filterable={[:>=]} type={:integer} />
<%!-- Dropdown --%>
<:col
field={:status}
filterable={[:==]}
type={:select}
options={[{"Active", "active"}, {"Inactive", "inactive"}]}
prompt="All statuses"
/>
<%!-- Checkbox --%>
<:col field={:published} filterable={[:==]} type={:boolean} />

Cell renderers

Use renderer to control how a cell value is displayed.

<%!-- Default - plain string --%>
<:col field={:title} />
<%!-- Checkbox for booleans --%>
<:col field={:published} renderer={:checkbox} />
<%!-- Date with optional strftime format string --%>
<:col field={:inserted_at} renderer={:date} />
<:col field={:inserted_at} renderer={:date} date_format="%d %b %Y" />
<%!-- Datetime --%>
<:col field={:updated_at} renderer={:datetime} />
<%!-- Money (requires the `money` hex package) --%>
<:col field={:price} renderer={:money} currency="USD" />

Custom cell content

Use :let to get the row item and render whatever you want inside the cell.

<:col :let={user} field={:name} label="User" sortable>
<div class="flex items-center gap-2">
<.avatar src={user.avatar_url} size="xs" />
<span>{user.name}</span>
</div>
</:col>

Actions column

Use align_right and omit field for an actions column that doesn’t sort or filter.

<:col :let={post} label="Actions" align_right>
<div class="flex justify-end gap-2">
<.button
size="xs"
variant="outline"
link_type="live_redirect"
label="Edit"
to={~p"/posts/#{post}/edit"}
/>
<.button
size="xs"
color="danger"
variant="outline"
label="Delete"
phx-click="delete"
phx-value-id={post.id}
data-confirm="Are you sure?"
/>
</div>
</:col>

Empty state

The <:if_empty> slot renders when the query returns no results.

<DataTable.data_table meta={@meta} items={@items}>
<:if_empty>No results found</:if_empty>
<:col field={:title} sortable />
</DataTable.data_table>

Join fields

To sort or filter on a field from a joined table, declare it in @derive Flop.Schema and make sure your base query includes the named join.

# In your schema
@derive {
Flop.Schema,
filterable: [:user_email],
sortable: [:user_email],
join_fields: [user_email: {:user, :email}]
}
# In your LiveView - the query must name the join with `as:`
starting_query =
from(p in Post,
join: u in assoc(p, :user),
as: :user,
preload: [:user]
)
{items, meta} = DataTable.search(starting_query, params, @data_table_opts)
<:col :let={post} field={:user_email} label="Email" sortable filterable={[:ilike]}>
{post.user.email}
</:col>

Attributes reference

# <DataTable.data_table>
attr :meta, Flop.Meta, required: true # from DataTable.search/3 or Flop.validate_and_run/3
attr :items, :list, required: true # the list of records to display
attr :page_size_options, :list # defaults to [10, 20, 50]
attr :class, :string # CSS class on the wrapper div
# <:col>
attr :field, :atom # schema field name
attr :label, :string # column header (defaults to humanised field name)
attr :class, :string # CSS class on each <td>
attr :sortable, :boolean # enable click-to-sort on the header
attr :filterable, :list # filter operators e.g. [:ilike] or [:==, :>=, :<=]
attr :type, :atom # :integer | :float | :boolean | :select
attr :options, :list # [{label, value}] for :select type
attr :prompt, :string # placeholder for :select filter
attr :renderer, :atom # :plaintext | :checkbox | :date | :datetime | :money
attr :date_format, :string # strftime format for :date/:datetime renderer
attr :currency, :string # ISO currency code for :money renderer e.g. "USD"
attr :align_right, :boolean # right-align the column
slot :if_empty # rendered when items is []