Flop integration for the nb ecosystem, providing pagination, sorting, and filtering for Phoenix applications with Inertia.js.
- Table DSL - Declarative data tables with the "Table as Resource" pattern
- Serializers - Full Flop.Meta serialization with schema introspection
- React Components - Pagination, sorting, filtering, and full table components
- Row Actions - Per-row actions with confirmation dialogs
- Bulk Actions - Selection and batch operations
- CSV Export - Export table data to CSV
- Saved Views - User-customizable table views
Add nb_flop to your dependencies:
def deps do
[
{:nb_flop, "~> 0.1"}
]
endmix nb_flop.installmix nb_flop.install --table--ui base- Base UI (unstyled primitives from MUI team)--ui radix- Radix UI (accessibility-first primitives)--table- Install Table DSL components and routes--with-views- Include saved views support--with-exports- Include CSV export support
The Table DSL provides a "Table as Resource" pattern where the backend is the single source of truth for table configuration.
defmodule MyAppWeb.Tables.UsersTable do
use NbFlop.Table
resource MyApp.Accounts.User
repo MyApp.Repo
config do
name "users"
default_sort {:name, :asc}
default_per_page 25
per_page_options [10, 25, 50, 100]
end
columns do
text_column :name, sortable: true, searchable: true
text_column :email, sortable: true
badge_column :status, colors: %{"active" => :success, "inactive" => :danger}
numeric_column :posts_count, label: "Posts"
date_column :inserted_at, label: "Joined", sortable: true
action_column()
end
filters do
text_filter :name, clauses: [:contains, :starts_with, :equals]
set_filter :status, options: [{"active", "Active"}, {"inactive", "Inactive"}]
end
actions do
action :edit,
url: fn user -> "/users/#{user.id}/edit" end,
icon: "PencilIcon"
action :delete,
handle: fn user -> MyApp.Accounts.delete_user(user) end,
icon: "TrashIcon",
variant: :danger,
confirmation: %{
title: "Delete User",
message: "Are you sure you want to delete #{user.name}?"
}
end
bulk_actions do
bulk_action :delete,
handle: fn users -> Enum.each(users, &MyApp.Accounts.delete_user/1) end,
variant: :danger,
confirmation: %{
title: "Delete Users",
message: "Are you sure you want to delete {count} users?"
}
end
enddefmodule MyAppWeb.UserController do
use MyAppWeb, :controller
use NbInertia.Controller
def index(conn, params) do
render_inertia(conn, :users_index,
users: MyAppWeb.Tables.UsersTable.make(conn, params)
)
end
end# In router.ex
use NbFlop.Router
scope "/" do
pipe_through [:browser]
nb_flop_routes() # Adds action, bulk-action, export, and views routes
endimport { Table } from '@/components/table';
export default function UsersIndex({ users }) {
return <Table resource={users} />;
}That's it! The Table component handles sorting, pagination, filtering, row selection, and actions automatically.
columns do
text_column :name, sortable: true, searchable: true
badge_column :status, colors: %{"active" => :success, "pending" => :warning}
numeric_column :price, prefix: "$", decimals: 2
date_column :created_at, format: "MMM d, yyyy"
datetime_column :updated_at
boolean_column :active
image_column :avatar, width: 40, height: 40, rounded: true
action_column()
endfilters do
text_filter :name, clauses: [:contains, :equals, :starts_with]
set_filter :status, options: [{"active", "Active"}, {"inactive", "Inactive"}]
number_filter :price, clauses: [:equals, :gt, :lt, :between]
date_filter :created_at, clauses: [:equals, :gt, :lt, :between]
boolean_filter :active
endactions do
# URL action - navigates user
action :view, url: fn row -> "/items/#{row.id}" end
# Handle action - executes on backend
action :archive,
handle: fn row -> MyApp.archive(row) end,
disabled: fn row -> row.archived end
# With confirmation
action :delete,
handle: fn row -> MyApp.delete(row) end,
confirmation: %{
title: "Delete Item",
message: "This cannot be undone."
}
endbulk_actions do
bulk_action :export,
handle: fn rows -> {:ok, "Exported #{length(rows)} rows"} end
bulk_action :delete,
handle: fn rows -> Enum.each(rows, &MyApp.delete/1) end,
chunk_size: 100 # Process in batches
endexports do
export :csv,
format: :csv,
columns: [:name, :email, :status],
format_column: %{
status: fn val -> String.upcase(to_string(val)) end
}
endUse the :as option for multiple tables on the same page:
def index(conn, params) do
render_inertia(conn, :dashboard,
users: MyAppWeb.Tables.UsersTable.make(conn, params, as: "users"),
posts: MyAppWeb.Tables.PostsTable.make(conn, params, as: "posts")
)
endFor more control, you can use the serializers and components directly.
The installer generates these serializers to your codebase:
FlopFilterSerializer- SerializesFlop.FilterstructsFlopParamsSerializer- Serializes Flop query parametersFlopMetaSerializer- SerializesFlop.Metawith schema introspectionFilterableFieldSerializer- Serializes field metadata for frontend
Components are copied to assets/js/components/flop/:
types.ts- TypeScript type definitionsuseFlopParams.ts- Hook for state managementPagination.tsx- Page-based pagination componentCursorPagination.tsx- Cursor-based pagination componentSortableHeader.tsx- Sortable table header componentFilterForm.tsx- Filter form container with render propindex.ts- Re-exports all components
Add @derive Flop.Schema to your Ecto schemas:
defmodule MyApp.Blog.Post do
use Ecto.Schema
@derive {
Flop.Schema,
filterable: [:title, :status, :author_id],
sortable: [:title, :inserted_at, :published_at]
}
schema "posts" do
field :title, :string
field :status, Ecto.Enum, values: [:draft, :published]
field :published_at, :utc_datetime
belongs_to :author, MyApp.Accounts.User
timestamps()
end
enddefmodule MyAppWeb.PostController do
use MyAppWeb, :controller
use NbInertia.Controller
alias MyApp.Blog.Post
alias MyAppWeb.Serializers.{PostSerializer, FlopMetaSerializer}
def index(conn, params) do
case Flop.validate_and_run(Post, params, for: Post) do
{:ok, {posts, meta}} ->
render_inertia(conn, :posts_index,
posts: {PostSerializer, posts},
meta: {FlopMetaSerializer, meta, schema: Post}
)
{:error, changeset} ->
# Handle validation error
conn
|> put_flash(:error, "Invalid parameters")
|> redirect(to: ~p"/posts")
end
end
endimport { useFlopParams, Pagination, SortableHeader, flopToQueryParams } from '@/components/flop';
import { router } from '@/lib/inertia';
import { posts_path } from '@/routes';
interface PostsIndexProps {
posts: Post[];
meta: FlopMeta;
}
export default function PostsIndex({ posts, meta }: PostsIndexProps) {
const flop = useFlopParams(meta, {
onParamsChange: (params) => {
router.visit(posts_path({ query: flopToQueryParams(params) }), {
preserveState: true,
preserveScroll: true,
});
},
});
return (
<div>
<table>
<thead>
<tr>
<SortableHeader
field="title"
currentSort={flop.params.orderBy?.[0]}
currentDirection={flop.getSortDirection('title')}
onSort={flop.setSort}
>
Title
</SortableHeader>
<SortableHeader
field="inserted_at"
currentSort={flop.params.orderBy?.[0]}
currentDirection={flop.getSortDirection('inserted_at')}
onSort={flop.setSort}
>
Created
</SortableHeader>
</tr>
</thead>
<tbody>
{posts.map(post => (
<tr key={post.id}>
<td>{post.title}</td>
<td>{post.insertedAt}</td>
</tr>
))}
</tbody>
</table>
<Pagination meta={meta} onPageChange={flop.setPage} />
</div>
);
}For cursor-based pagination, use CursorPagination and cursor methods:
import { useFlopParams, CursorPagination, flopToQueryParams } from '@/components/flop';
export default function PostsIndex({ posts, meta }: PostsIndexProps) {
const flop = useFlopParams(meta, {
onParamsChange: (params) => {
router.visit(posts_path({ query: flopToQueryParams(params) }), {
preserveState: true,
preserveScroll: true,
});
},
});
return (
<div>
{/* ... table content ... */}
<CursorPagination
meta={meta}
onNext={() => flop.goToNextCursor()}
onPrevious={() => flop.goToPreviousCursor()}
/>
</div>
);
}Use the FilterForm component with render props for custom filter inputs:
import { useFlopParams, FilterForm, flopToQueryParams } from '@/components/flop';
export default function PostsIndex({ posts, meta }: PostsIndexProps) {
const flop = useFlopParams(meta, {
onParamsChange: (params) => {
router.visit(posts_path({ query: flopToQueryParams(params) }), {
preserveState: true,
preserveScroll: true,
});
},
});
return (
<div>
<FilterForm
filterableFields={meta.filterableFields}
filters={flop.params.filters ?? []}
onFilterChange={(field, op, value) => flop.setFilter(field, op, value)}
onFilterRemove={(field, op) => flop.removeFilter(field, op)}
onClearFilters={() => flop.clearFilters()}
>
{({ fields, activeFilters, setFilter, removeFilter, clearFilters }) => (
<>
<input
type="text"
placeholder="Search title..."
onChange={(e) => setFilter('title', 'ilike', `%${e.target.value}%`)}
/>
<select onChange={(e) => setFilter('status', '==', e.target.value)}>
<option value="">All</option>
<option value="published">Published</option>
<option value="draft">Draft</option>
</select>
{activeFilters.length > 0 && (
<button onClick={clearFilters}>Clear all</button>
)}
</>
)}
</FilterForm>
{/* ... rest of component ... */}
</div>
);
}The useFlopParams hook provides:
params- Current Flop parameters (orderBy, orderDirections, filters, etc.)
setPage(page)- Go to specific pagenextPage()- Go to next pagepreviousPage()- Go to previous pagegoToNextCursor()- Go to next cursor (cursor-based)goToPreviousCursor()- Go to previous cursor (cursor-based)
setSort(field, direction)- Set sort field and directiontoggleSort(field)- Toggle sort on a field (asc -> desc -> none)getSortDirection(field)- Get current sort direction for a field
setFilter(field, op, value)- Add or update a filterremoveFilter(field, op)- Remove a specific filterclearFilters()- Remove all filtersgetFilterValue(field, op)- Get value for a specific filter
Converts Flop params to URL query parameters:
import { flopToQueryParams } from '@/components/flop';
const params = flopToQueryParams({
page: 2,
pageSize: 20,
orderBy: ['title'],
orderDirections: ['asc'],
filters: [{ field: 'status', op: '==', value: 'published' }],
});
// => { page: '2', page_size: '20', order_by: 'title', order_directions: 'asc', ... }All components use CSS classes for styling. No styles are included - you style them yourself.
Pagination:
.flop-pagination- Container.flop-pagination-prev/.flop-pagination-next- Navigation buttons.flop-pagination-page- Page buttons.flop-pagination-page-active- Active page button.flop-pagination-ellipsis- Ellipsis indicator
CursorPagination:
.flop-cursor-pagination- Container.flop-cursor-pagination-prev/.flop-cursor-pagination-next- Navigation buttons
SortableHeader:
.flop-sortable-header- Table header cell.flop-sortable-header-active- Active sort column.flop-sortable-header-button- Clickable button.flop-sortable-header-label- Text label.flop-sortable-header-icon- Sort direction icon
FilterForm:
.flop-filter-form- Form container
If using nb_stack, install with the --with-flop flag:
mix igniter.install nb_stack --with-flopThis installs the complete frontend stack with Flop integration included.
All components are fully typed. Types are generated by nb_ts when you run:
mix ts.genMIT License - see LICENSE file for details.