- PHP 8.3
- Laravel 11 with Spatie Laravel Query Builder
- Next.js 15 with TanStack Table
public function index(): Response|AnonymousResourceCollection
{
$page = (int)request()->query("page", 1);
$perPage = (int)request()->query("limit", 15);
$transactions = (new ListUsersUseCase($this->userRepository))
->execute($page, $perPage);
if ($transactions->isEmpty()) {
return response()->noContent();
}
return UserListResource::collection($transactions);
}
class ListUsersUseCase
{
private UserRepository $userRepository;
public function __construct(UserRepository $userRepository)
{
$this->userRepository = $userRepository;
}
public function execute(int $page = 1, int $perPage = 15, array $columns = array('*'))
{
return $this->userRepository->paginate($page, $perPage, $columns);
}
}
class UserEloquentRepository implements UserRepository
{
public function paginate(int $page = 1, int $perPage = 15, array $columns = array('*')): LengthAwarePaginator
{
return QueryBuilder::for(UserModel::class)
->allowedSorts(['name', 'email'])
->defaultSort('name')
->paginate($perPage, $columns, 'page', $page);
}
}
export interface DataTablePaginator {
current_page: number;
from?: number;
last_page?: number;
total_pages?: number;
path?: string;
per_page: number;
to?: number;
total: number;
total_items?: number;
links: PaginatorLink[];
}
export interface PaginatorLink {
url?: string;
label: string;
active: boolean;
}
Note: param page is the current page number, and limit is per page records.
export interface PaginatedUsers {
users: IUser[] | [];
paginator: DataTablePaginator | null;
}
export class UserHttpGateway implements UserGateway {
async paginate(
page: number,
perPage: number,
filters?: string
): Promise<PaginatedUsers> {
const data = await api(
`users?page=${page}&limit=${perPage}` +
(filters ? "&" + filters : ""),
{
method: "GET",
}
);
const users = data.data.map(
(data: any) =>
new User({
uuid: data.uuid,
name: data.name,
email: data.email,
createdAt: data.createdAt,
})
);
const paginator: DataTablePaginator = { ...data.meta };
return {
users: users,
paginator: paginator,
};
}
}
Note: The component below receives the DataTablePaginator as a property, which I declare as a state on the listing page. The most important point is the handlePageChange function that updates the internal state of the table when the user switches the page. Another detail but no less important is that the table pagination starts at 0, while in the back-end it starts at 1. Therefore, we have some adjustments in the state and in the handlePageChange function.
"use client";
import { useState } from "react";
import {
ColumnDef,
flexRender,
getCoreRowModel,
useReactTable,
PaginationState,
Updater,
} from "@tanstack/react-table";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import { Button } from "@/components/ui/button";
import { DataTablePaginator } from "@/lib/data-table-paginator";
interface UserDataTableProps<TData, TValue> {
columns: ColumnDef<TData, TValue>[];
data: TData[];
dataPaginator: DataTablePaginator;
onPaginationChanged: (currentPage: number, perPage: number) => void;
}
export function UserDataTable<TData, TValue>({
data,
columns,
dataPaginator,
onPaginationChanged,
}: UserDataTableProps<TData, TValue>) {
const [pagination, setPagination] = useState<PaginationState>({
pageIndex: dataPaginator.current_page - 1,
pageSize: dataPaginator.per_page,
});
const table = useReactTable({
data,
columns,
getCoreRowModel: getCoreRowModel(),
state: {
pagination,
},
onPaginationChange: handlePageChange,
manualPagination: true, //we're doing manual "server-side" pagination
rowCount: dataPaginator.total,
});
async function handlePageChange(updaterOrValue: Updater<any>) {
await setPagination(updaterOrValue);
const currentPage = table.getState().pagination.pageIndex + 1;
const perPage = table.getState().pagination.pageSize;
onPaginationChanged(currentPage, perPage);
}
return (
<div className="rounded-md border">
<Table>
<TableHeader>
{table.getHeaderGroups().map((headerGroup) => (
<TableRow key={headerGroup.id}>
{headerGroup.headers.map((header) => {
return (
<TableHead key={header.id}>
{header.isPlaceholder
? null
: flexRender(
header.column.columnDef.header,
header.getContext()
)}
</TableHead>
);
})}
</TableRow>
))}
</TableHeader>
<TableBody>
{table.getRowModel().rows?.length ? (
table.getRowModel().rows.map((row) => (
<TableRow
key={row.id}
data-state={row.getIsSelected() && "selected"}
>
{row.getVisibleCells().map((cell) => (
<TableCell key={cell.id}>
{flexRender(cell.column.columnDef.cell, cell.getContext())}
</TableCell>
))}
</TableRow>
))
) : (
<TableRow>
<TableCell colSpan={columns.length} className="h-24 text-center">
No results.
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
<div className="mt-2 w-full grid grid-cols-2 items-center justify-between space-x-2 py-3 px-3 text-xs">
<div className="flex items-center justify-start">
<span className="flex items-center gap-1">
<div>Página</div>
<strong>
{table.getState().pagination.pageIndex + 1} de{" "}
{table.getPageCount().toLocaleString()}
</strong>
</span>
<span className="flex items-center gap-1 ml-1">
| Ir para:
<input
type="number"
min="1"
max={table.getPageCount()}
defaultValue={table.getState().pagination.pageIndex + 1}
onChange={(e) => {
const page = e.target.value ? Number(e.target.value) - 1 : 0;
table.setPageIndex(page);
}}
className="border p-1 rounded w-16"
/>
</span>
</div>
<div className="flex items-center justify-end">
<Button
variant="outline"
size="icon"
onClick={() => table.firstPage()}
disabled={!table.getCanPreviousPage()}
>
{"<<"}
</Button>
<Button
variant="outline"
size="icon"
onClick={() => table.previousPage()}
disabled={!table.getCanPreviousPage()}
>
{"<"}
</Button>
<Button
variant="outline"
size="icon"
onClick={() => table.nextPage()}
disabled={!table.getCanNextPage()}
>
{">"}
</Button>
<Button
variant="outline"
size="icon"
onClick={() => table.lastPage()}
disabled={!table.getCanNextPage()}
>
{">>"}
</Button>
</div>
</div>
<div className="flex items-center justify-end text-xs px-3 pb-1">
Exibindo {table.getRowModel().rows.length.toLocaleString()} de{" "}
{dataPaginator.total.toLocaleString()} linhas
</div>
</div>
);
}
export const columns: ColumnDef<IUser>[] = [
{
header: "Name",
accessorKey: "name",
},
{
header: "Email",
accessorKey: "email",
},
{
header: "Created at",
accessorKey: "createdAt",
cell: ({ row }) => {
return (
<div>
{moment(row.getValue("createdAt")).format("DD/MM/YY HH:mm:ss")}
</div>
);
},
},
]
Note: The function handlePaginationChange is the callback of DataTable onPageChange event. This function update the userPaginator causing useEffect hook to make a new back-end request displaying the records on the new page.
"use client";
....
export default function UserListPage() {
const [users, setUsers] = useState<IUser[]>([]);
const [userPaginator, setUserPaginator] =
useState<DataTablePaginator>({
current_page: 1,
from: 1,
last_page: 1,
total_pages: 1,
path: "",
per_page: 10,
to: 1,
total: 0,
total_items: 0,
links: [],
});
const fetchUsers = async () => {
try {
const listUsersUseCase = new ListUsersUseCase(
new UserHttpGateway()
);
const { users, paginator } =
await listUsersUseCase.execute(
userPaginator.current_page,
userPaginator.per_page
);
if (paginator) {
setUsers(users);
setUserPaginator(paginator);
} else {
setUsers([]);
}
} catch (e: any) {
toast({
variant: "destructive",
title: "Error",
description: e.message,
});
}
};
useEffect(() => {
fetchUsers();
}, [userPaginator.current_page, userPaginator.per_page]);
const handlePaginationChange = (currentPage: number, perPage: number) => {
setUserPaginator((prevState) => ({
...prevState,
current_page: currentPage,
per_page: perPage,
}));
};
return (
<div>
<h1>Users list</h1>
<div className="container mx-auto py-10">
<Suspense fallback={<Loading />}>
<UserDataTable
columns={columns}
data={users}
dataPaginator={userPaginator}
onPaginationChanged={handlePaginationChange}
/>
</Suspense>
</div>
</div>
);
}