mirror of
https://github.com/vercel/next-learn.git
synced 2026-06-11 09:51:47 +00:00
add pagination and search bar to the invoice table (#146)
* add pagination and search bar to the invoice table * make table component server * fix eslint * eslint * eslint * Updates pagination url to not use state or router and moves pagination component up above the table * fix eslint typing error * resolve github convos * update all 'q' to be 'query' --------- Co-authored-by: Michael Novotny <manovotny@gmail.com>
This commit is contained in:
@@ -1,9 +1,16 @@
|
||||
import InvoicesTable from '@/app/ui/invoices/table';
|
||||
|
||||
export default function Page() {
|
||||
export default function Page({
|
||||
searchParams,
|
||||
}: {
|
||||
searchParams: {
|
||||
query: string;
|
||||
page: string;
|
||||
};
|
||||
}) {
|
||||
return (
|
||||
<div>
|
||||
<InvoicesTable />
|
||||
<InvoicesTable searchParams={searchParams} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -94,6 +94,55 @@ export const invoices: Invoice[] = [
|
||||
status: 'paid',
|
||||
date: '2023-06-01',
|
||||
},
|
||||
{
|
||||
id: 9,
|
||||
customerId: 3,
|
||||
amount: 1250,
|
||||
status: 'paid',
|
||||
date: '2023-06-02',
|
||||
},
|
||||
{
|
||||
id: 10,
|
||||
customerId: 1,
|
||||
amount: 8945,
|
||||
status: 'paid',
|
||||
date: '2023-06-01',
|
||||
},
|
||||
{
|
||||
id: 11,
|
||||
customerId: 2,
|
||||
amount: 500,
|
||||
status: 'paid',
|
||||
date: '2023-08-01',
|
||||
},
|
||||
{
|
||||
id: 12,
|
||||
customerId: 3,
|
||||
amount: 8945,
|
||||
status: 'paid',
|
||||
date: '2023-06-01',
|
||||
},
|
||||
{
|
||||
id: 13,
|
||||
customerId: 3,
|
||||
amount: 8945,
|
||||
status: 'paid',
|
||||
date: '2023-06-01',
|
||||
},
|
||||
{
|
||||
id: 14,
|
||||
customerId: 4,
|
||||
amount: 8945,
|
||||
status: 'paid',
|
||||
date: '2023-10-01',
|
||||
},
|
||||
{
|
||||
id: 15,
|
||||
customerId: 3,
|
||||
amount: 1000,
|
||||
status: 'paid',
|
||||
date: '2022-06-12',
|
||||
},
|
||||
];
|
||||
|
||||
export const revenue: Revenue[] = [
|
||||
|
||||
71
dashboard/15-final/app/ui/invoices/pagination.tsx
Normal file
71
dashboard/15-final/app/ui/invoices/pagination.tsx
Normal file
@@ -0,0 +1,71 @@
|
||||
'use client';
|
||||
|
||||
import { ChevronLeftIcon, ChevronRightIcon } from '@heroicons/react/24/outline';
|
||||
import { usePathname, useSearchParams } from 'next/navigation';
|
||||
import clsx from 'clsx';
|
||||
import Link from 'next/link';
|
||||
|
||||
export default function PaginationButtons({
|
||||
totalPages,
|
||||
currentPage,
|
||||
}: {
|
||||
totalPages: number;
|
||||
currentPage: number;
|
||||
}) {
|
||||
const pathname = usePathname();
|
||||
const searchParams = useSearchParams();
|
||||
const pageNumbers = Array.from({ length: totalPages }, (_, i) => i + 1);
|
||||
|
||||
const createPageUrl = (pageNumber: number) => {
|
||||
const newSearchParams = new URLSearchParams(searchParams.toString());
|
||||
newSearchParams.set('page', pageNumber.toString());
|
||||
return `${pathname}?${newSearchParams.toString()}`;
|
||||
};
|
||||
|
||||
const PreviousPageTag = currentPage === 1 ? 'p' : Link;
|
||||
const NextPageTag = currentPage === totalPages ? 'p' : Link;
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-end">
|
||||
<PreviousPageTag
|
||||
href={createPageUrl(currentPage - 1)}
|
||||
className={clsx(
|
||||
'flex h-8 w-8 items-center justify-center rounded-l-md border border-gray-300',
|
||||
{
|
||||
'text-gray-300': currentPage === 1,
|
||||
},
|
||||
)}
|
||||
>
|
||||
<ChevronLeftIcon className="w-4" />
|
||||
</PreviousPageTag>
|
||||
{pageNumbers.map((page) => {
|
||||
const PageTag = page === currentPage ? 'p' : Link;
|
||||
return (
|
||||
<PageTag
|
||||
key={page}
|
||||
href={createPageUrl(page)}
|
||||
className={clsx(
|
||||
'flex h-8 w-8 items-center justify-center border-y border-r border-gray-300 text-sm',
|
||||
{
|
||||
'border-blue-600 bg-blue-600 text-white': currentPage === page,
|
||||
},
|
||||
)}
|
||||
>
|
||||
{page}
|
||||
</PageTag>
|
||||
);
|
||||
})}
|
||||
<NextPageTag
|
||||
href={createPageUrl(currentPage + 1)}
|
||||
className={clsx(
|
||||
'flex h-8 w-8 items-center justify-center rounded-r-md border border-l-0 border-gray-300',
|
||||
{
|
||||
'text-gray-300': currentPage === totalPages,
|
||||
},
|
||||
)}
|
||||
>
|
||||
<ChevronRightIcon className="w-4" />
|
||||
</NextPageTag>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
70
dashboard/15-final/app/ui/invoices/table-search.tsx
Normal file
70
dashboard/15-final/app/ui/invoices/table-search.tsx
Normal file
@@ -0,0 +1,70 @@
|
||||
'use client';
|
||||
|
||||
import { MagnifyingGlassIcon } from '@heroicons/react/24/outline';
|
||||
import { usePathname, useRouter, useSearchParams } from 'next/navigation';
|
||||
import { useTransition } from 'react';
|
||||
|
||||
export default function TableSearch() {
|
||||
const { replace } = useRouter();
|
||||
const searchParams = useSearchParams()!;
|
||||
const pathname = usePathname();
|
||||
const [isPending, startTransition] = useTransition();
|
||||
|
||||
function handleSearch(term: string) {
|
||||
const params = new URLSearchParams(searchParams);
|
||||
if (term) {
|
||||
params.set('query', term);
|
||||
} else {
|
||||
params.delete('query');
|
||||
}
|
||||
|
||||
startTransition(() => {
|
||||
replace(`${pathname}?${params.toString()}`);
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="relative max-w-md flex-grow">
|
||||
<label htmlFor="search" className="sr-only">
|
||||
Search
|
||||
</label>
|
||||
<div className="relative flex items-center px-2 py-2">
|
||||
<MagnifyingGlassIcon className="h-5 text-gray-400" />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search..."
|
||||
onChange={(e) => handleSearch(e.target.value)}
|
||||
className="absolute inset-0 w-full rounded-md border border-gray-300 bg-transparent p-2 pl-8 text-sm"
|
||||
/>
|
||||
</div>
|
||||
{isPending && <LoadingIcon />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function LoadingIcon() {
|
||||
return (
|
||||
<div className="absolute bottom-0 right-0 top-0 flex items-center justify-center">
|
||||
<svg
|
||||
className="-ml-1 mr-3 h-5 w-5 animate-spin text-gray-700"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<circle
|
||||
className="opacity-25"
|
||||
cx="12"
|
||||
cy="12"
|
||||
r="10"
|
||||
stroke="currentColor"
|
||||
stroke-width="4"
|
||||
/>
|
||||
<path
|
||||
className="opacity-75"
|
||||
fill="currentColor"
|
||||
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -8,6 +8,10 @@ import {
|
||||
CheckCircleIcon,
|
||||
} from '@heroicons/react/24/outline';
|
||||
import DeleteInvoice from '@/app/ui/invoices/delete-invoice-button';
|
||||
import TableSearch from './table-search';
|
||||
import PaginationButtons from './pagination';
|
||||
|
||||
const ITEMS_PER_PAGE = 10;
|
||||
|
||||
function renderInvoiceStatus(status: string) {
|
||||
if (status === 'pending') {
|
||||
@@ -27,12 +31,47 @@ function renderInvoiceStatus(status: string) {
|
||||
}
|
||||
}
|
||||
|
||||
export default function InvoicesTable() {
|
||||
export default function InvoicesTable({
|
||||
searchParams,
|
||||
}: {
|
||||
searchParams: {
|
||||
query: string;
|
||||
page: string;
|
||||
};
|
||||
}) {
|
||||
const searchTerm = searchParams.query ?? '';
|
||||
const currentPage = parseInt(searchParams.page ?? '1');
|
||||
|
||||
const filteredInvoices = invoices.filter((invoice) => {
|
||||
const customer = getCustomerById(invoice.customerId);
|
||||
|
||||
const invoiceMatches = Object.values(invoice).some(
|
||||
(value) =>
|
||||
value?.toString().toLowerCase().includes(searchTerm.toLowerCase()),
|
||||
);
|
||||
|
||||
const customerMatches =
|
||||
customer &&
|
||||
Object.values(customer).some(
|
||||
(value) =>
|
||||
value?.toString().toLowerCase().includes(searchTerm.toLowerCase()),
|
||||
);
|
||||
|
||||
return invoiceMatches || customerMatches;
|
||||
});
|
||||
|
||||
const paginatedInvoices = filteredInvoices.slice(
|
||||
(currentPage - 1) * ITEMS_PER_PAGE,
|
||||
currentPage * ITEMS_PER_PAGE,
|
||||
);
|
||||
|
||||
function getCustomerById(customerId: number): Customer | null {
|
||||
const customer = customers.find((customer) => customer.id === customerId);
|
||||
return customer ? customer : null;
|
||||
}
|
||||
|
||||
const totalPages = Math.ceil(filteredInvoices.length / ITEMS_PER_PAGE);
|
||||
|
||||
return (
|
||||
<div className="w-full">
|
||||
<div className="flex w-full items-center justify-between">
|
||||
@@ -44,7 +83,11 @@ export default function InvoicesTable() {
|
||||
Add Invoice
|
||||
</Link>
|
||||
</div>
|
||||
<div className="mt-8">
|
||||
<div className="mt-8 flex items-center justify-between">
|
||||
<TableSearch />
|
||||
<PaginationButtons totalPages={totalPages} currentPage={currentPage} />
|
||||
</div>
|
||||
<div className="mt-4">
|
||||
<div className="overflow-x-auto">
|
||||
<div className="overflow-hidden rounded-md border">
|
||||
<table className="min-w-full divide-y divide-gray-300">
|
||||
@@ -75,7 +118,7 @@ export default function InvoicesTable() {
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-200 text-gray-500">
|
||||
{invoices.map((invoice) => (
|
||||
{paginatedInvoices.map((invoice) => (
|
||||
<tr key={invoice.id}>
|
||||
<td className="whitespace-nowrap py-4 pl-4 pr-3 text-sm font-medium text-black sm:pl-6">
|
||||
{invoice.id}
|
||||
|
||||
Reference in New Issue
Block a user