Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
225 changes: 225 additions & 0 deletions src/features/instance/databases/components/TablePagination.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,225 @@
'use client';

import { TextLoadingSkeleton } from '@/components/TextLoadingSkeleton';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { addCommasToNumbers } from '@/lib/addCommasToNumbers';
import { cn } from '@/lib/cn';
import { ChevronLeftIcon, ChevronRightIcon } from 'lucide-react';
import { ComponentProps, Dispatch, FormEvent, SetStateAction, useState } from 'react';

const PAGE_SIZE_OPTIONS = [20, 50, 100, 250];

interface TablePaginationProps {
pageIndex: number;
pageSize: number;
totalPages?: number;
totalRecords?: number;
setPageIndex: Dispatch<SetStateAction<number>>;
setPageSize: Dispatch<SetStateAction<number>>;
}

export function TablePagination(
{ pageIndex, pageSize, totalPages, totalRecords, setPageIndex, setPageSize }: TablePaginationProps,
) {
const isLoading = totalPages === undefined || totalRecords === undefined;
const pageCount = totalPages && totalPages > 0 ? totalPages : 1;
const currentPage = pageIndex + 1;

const canPrevious = pageIndex > 0;
const canNext = !isLoading && currentPage < pageCount;

const goToPage = (page: number) => {
setPageIndex(Math.max(0, Math.min(page, pageCount) - 1));
};

const changePageSize = (size: number) => {
setPageSize(size);
setPageIndex(0);
};

const items = getPaginationItems(currentPage, pageCount);

return (
<div className="@container border-t border-border">
<div className="flex items-center gap-3 px-1 py-4">
{/* Summary — record count is the essential, kept at every width */}
<div className="flex items-center gap-2 whitespace-nowrap text-sm text-muted-foreground">
<span className="hidden @min-[400px]:inline">
Page {addCommasToNumbers(currentPage)} of {isLoading ? '…' : addCommasToNumbers(pageCount)}
</span>
<span aria-hidden className="hidden text-border @min-[400px]:inline">
</span>
<span>
{isLoading
? <TextLoadingSkeleton />
: <>{addCommasToNumbers(totalRecords)} {totalRecords === 1 ? 'record' : 'records'}</>}
</span>
</div>

<div className="grow" />

{/* Navigation — back/forward is the essential, kept at every width */}
<nav className="flex items-center divide-x divide-border overflow-hidden rounded-lg border border-border">
<PaginationButton
onClick={() => goToPage(currentPage - 1)}
disabled={!canPrevious}
aria-label="Previous page"
>
<ChevronLeftIcon />
<span className="hidden @min-[480px]:inline">Previous</span>
</PaginationButton>

{/* Numbered pages — first to drop as width shrinks */}
<div className="hidden divide-x divide-border @min-[620px]:flex">
{items.map((item, index) =>
item === 'ellipsis'
? (
<span
key={`ellipsis-${index}`}
className="flex h-9 min-w-9 items-center justify-center px-1 text-sm text-muted-foreground"
>
</span>
)
: (
<PaginationButton
key={item}
onClick={() => goToPage(item)}
isActive={item === currentPage}
aria-label={`Page ${item}`}
aria-current={item === currentPage ? 'page' : undefined}
>
{addCommasToNumbers(item)}
</PaginationButton>
)
)}
</div>

<PaginationButton onClick={() => goToPage(currentPage + 1)} disabled={!canNext} aria-label="Next page">
<span className="hidden @min-[480px]:inline">Next</span>
<ChevronRightIcon />
</PaginationButton>
</nav>

{/* Keep the nav centered only while the "Go to" field is shown; otherwise pin it right */}
<div className="hidden grow @min-[840px]:block" />

{/* Rows per page — extra control, only shown when there is ample room */}
<PageSizeSelect pageSize={pageSize} onChange={changePageSize} />

{/* Go to page — last to appear, first to be dropped */}
<GoToPage pageCount={pageCount} disabled={isLoading || pageCount <= 1} onGo={goToPage} />
</div>
</div>
);
}

function PaginationButton({
isActive,
className,
...props
}: ComponentProps<'button'> & { isActive?: boolean }) {
return (
<button
type="button"
className={cn(
`flex h-9 min-w-9 items-center justify-center gap-1 px-3 text-sm whitespace-nowrap
transition-colors hover:bg-accent hover:text-foreground
disabled:pointer-events-none disabled:opacity-40
[&_svg]:size-4 [&_svg]:shrink-0`,
isActive && 'bg-muted font-semibold text-foreground',
className,
)}
{...props}
/>
);
}

function GoToPage({ pageCount, disabled, onGo }: {
pageCount: number;
disabled?: boolean;
onGo: (page: number) => void;
}) {
const [value, setValue] = useState('');

const submit = (event: FormEvent) => {
event.preventDefault();
const page = Number(value);
if (Number.isFinite(page) && page >= 1) {
onGo(Math.min(page, pageCount));
}
setValue('');
};

return (
<form onSubmit={submit} className="hidden items-center gap-2 @min-[840px]:flex">
<label htmlFor="pagination-go-to" className="text-sm whitespace-nowrap text-muted-foreground">
Go to
</label>
<input
id="pagination-go-to"
type="number"
min={1}
max={pageCount}
inputMode="numeric"
placeholder="Page"
disabled={disabled}
value={value}
onChange={(event) => setValue(event.target.value)}
className="h-9 w-20 rounded-md border border-input bg-white px-3 text-sm text-foreground
placeholder:text-muted-foreground focus-visible:ring-1 focus-visible:ring-purple focus-visible:outline-none
disabled:pointer-events-none disabled:opacity-50 dark:bg-grey-700 dark:text-white"
/>
</form>
);
}

function PageSizeSelect({ pageSize, onChange }: { pageSize: number; onChange: (size: number) => void }) {
return (
<div className="hidden items-center gap-2 @min-[960px]:flex">
<span className="text-sm whitespace-nowrap text-muted-foreground">Rows</span>
<Select value={String(pageSize)} onValueChange={(value) => onChange(Number(value))}>
<SelectTrigger aria-label="Rows per page" className="h-9 w-20">
<SelectValue />
</SelectTrigger>
<SelectContent side="top">
{PAGE_SIZE_OPTIONS.map((size) => (
<SelectItem key={size} value={String(size)}>
{addCommasToNumbers(size)}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
);
}

/**
* Builds the list of page numbers to render, inserting `'ellipsis'` markers where pages are
* collapsed. Always shows the first and last page plus a window around the current page.
*/
function getPaginationItems(current: number, total: number, sibling = 1): (number | 'ellipsis')[] {
const totalSlots = sibling * 2 + 5;
if (total <= totalSlots) {
return range(1, total);
}

const leftSibling = Math.max(current - sibling, 1);
const rightSibling = Math.min(current + sibling, total);
const showLeftEllipsis = leftSibling > 2;
const showRightEllipsis = rightSibling < total - 1;
const edgeCount = 3 + 2 * sibling;

if (!showLeftEllipsis && showRightEllipsis) {
return [...range(1, edgeCount), 'ellipsis', total];
}
if (showLeftEllipsis && !showRightEllipsis) {
return [1, 'ellipsis', ...range(total - edgeCount + 1, total)];
}
return [1, 'ellipsis', ...range(leftSibling, rightSibling), 'ellipsis', total];
}

function range(start: number, end: number): number[] {
return Array.from({ length: end - start + 1 }, (_, index) => start + index);
}
97 changes: 10 additions & 87 deletions src/features/instance/databases/components/TableView.tsx
Original file line number Diff line number Diff line change
@@ -1,11 +1,7 @@
'use client';

import { LoadingSubtle } from '@/components/LoadingSubtle';
import { TextLoadingSkeleton } from '@/components/TextLoadingSkeleton';
import { Button } from '@/components/ui/button';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { Table, TableBody, TableCell, TableHeader, TableHeadSortable, TableRow } from '@/components/ui/table';
import { addCommasToNumbers } from '@/lib/addCommasToNumbers';
import { cn } from '@/lib/cn';
import {
Cell,
Expand All @@ -17,11 +13,11 @@ import {
useReactTable,
VisibilityState,
} from '@tanstack/react-table';
import { ArrowLeftIcon, ArrowRightIcon } from 'lucide-react';
import { Dispatch, SetStateAction, useCallback, useMemo } from 'react';
import { Dispatch, SetStateAction, useMemo } from 'react';
import { UseFormReturn } from 'react-hook-form';
import { z } from 'zod';
import { ColumnFilters, ColumnFiltersSchema } from './ColumnFilters';
import { TablePagination } from './TablePagination';

interface BrowseDataTableProps<TData, TValue> {
applyFilters: () => void;
Expand Down Expand Up @@ -78,13 +74,6 @@ export function TableView<TData, TValue>({
getPaginationRowModel: getPaginationRowModel(),
});

const previousPage = useCallback(() => {
setPageIndex(pageIndex - 1);
}, [pageIndex, setPageIndex]);
const nextPage = useCallback(() => {
setPageIndex(pageIndex + 1);
}, [pageIndex, setPageIndex]);

return (
<>
<Table containerClassName="rounded-md bg-card dark:bg-black-dark grow overflow-visible">
Expand Down Expand Up @@ -125,78 +114,14 @@ export function TableView<TData, TValue>({
)}
</TableBody>
</Table>
<div className="flex items-center justify-end py-4 space-x-2 pr-4">
<Button
variant="defaultOutline"
size="sm"
onClick={previousPage}
className="select-none"
disabled={pageIndex === 0}
>
<ArrowLeftIcon />
Previous
</Button>

<div className="grow"></div>

<div className="text-center">
<dt className="font-medium text-gray-500 text-sm/6 dark:text-gray-400">Records</dt>
<dd className="font-semibold tracking-tight">
{totalRecords === undefined
? <TextLoadingSkeleton />
: addCommasToNumbers(totalRecords)}
</dd>
</div>
{totalRecords !== undefined && totalRecords > 0 && (
<>
<div>
<Select
defaultValue={pageSize.toString()}
onValueChange={(value) => {
setPageSize(Number(value));
}}
>
<SelectTrigger className="h-10 w-20">
<SelectValue />
</SelectTrigger>
<SelectContent side="top">
{[20, 50, 100, 250].map((pageSize) => (
<SelectItem key={pageSize} value={`${pageSize}`}>
{pageSize}
</SelectItem>
))}
</SelectContent>
</Select>
</div>

{totalPages !== undefined && totalPages > 1 && (
<>
<div className="text-center">
<dt className="font-medium text-gray-500 text-sm/6 dark:text-gray-400">Pages</dt>
<dd className="font-semibold tracking-tight">{addCommasToNumbers(totalPages)}</dd>
</div>
<div className="text-center">
<dt className="font-medium text-gray-500 text-sm/6 dark:text-gray-400">Page</dt>
<dd className="font-semibold tracking-tight">{addCommasToNumbers(pageIndex + 1)}</dd>
</div>
</>
)}
</>
)}

<div className="grow"></div>

<Button
variant="defaultOutline"
size="sm"
onClick={nextPage}
className="select-none"
disabled={totalPages === undefined || pageIndex === totalPages - 1}
>
Next
<ArrowRightIcon />
</Button>
</div>
<TablePagination
pageIndex={pageIndex}
pageSize={pageSize}
totalPages={totalPages}
totalRecords={totalRecords}
setPageIndex={setPageIndex}
setPageSize={setPageSize}
/>
</>
);
}
Expand All @@ -209,8 +134,6 @@ function TableBodyRow<TData>(
const isExpired = original && original.message === 'This entry has expired';
const visibleCells = row.getVisibleCells();

console.log('primaryKey, visibleCells', primaryKey, visibleCells);

if (isExpired) {
if (visibleCells[0]?.column?.id === primaryKey) {
return [
Expand Down