Skip to content

Commit

Permalink
- Updated route /bookmarks with cursor-based pagination
Browse files Browse the repository at this point in the history
- Added temporary route `/bookmarks/offset` with offset pagination
  • Loading branch information
michaelschwobe committed Nov 9, 2023
1 parent 60c0ee5 commit 271e2b5
Show file tree
Hide file tree
Showing 7 changed files with 515 additions and 193 deletions.
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ TagsForDays extends traditional bookmarking with advanced organization and searc
- TODO: User: multitenancy
- TODO: User: profiles

- TODO: General: finalize pagination (cursor vs offset)
- TODO: General: performance optimizations
- TODO: General: more data (seeded, production, etc.)
- TODO: General: db writes/resets when testing
Expand Down
283 changes: 120 additions & 163 deletions app/components/pagination.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,190 +3,147 @@ import { forwardRef } from "react";
import { Button } from "~/components/ui/button";
import { Icon } from "~/components/ui/icon";
import { cn } from "~/utils/misc";
import { toOffsetPagination } from "~/utils/pagination";

export function paginateSearchParams({
defaultPerPage,
searchParams,
}: {
/** The default number of items per page. */
defaultPerPage: number;
/** The search params. **Required** */
searchParams: URLSearchParams;
}) {
const skip = Number(searchParams.get("skip")) || 0;
const take = Number(searchParams.get("take")) || defaultPerPage;
const params: Array<[key: string, value: string]> = [
...Array.from(searchParams.entries()).filter(
([key]) => key !== "skip" && key !== "take",
),
["take", String(take)],
];
return { params, skip, take };
export interface PaginationFormProps
extends Omit<
React.ComponentPropsWithoutRef<typeof Form>,
"method" | "preventScrollReset"
> {
/** Sets the content. **Required** */
children: React.ReactNode;
/** Sets the `class` attribute. */
className?: string | undefined;
}

export function paginate({
pagesMax,
skip,
take,
total,
}: {
/** The maximum number of pages to show. **Required** */
pagesMax: number;
/** The number of items to skip. **Required** */
skip: number;
/** The number of items per page. **Required** */
take: number;
/** The total number of items. **Required** */
total: number;
}) {
const pagesTotal = Math.ceil(total / take);
const pagesMaxHalved = Math.floor(pagesMax / 2);
export const PaginationForm = forwardRef<
React.ElementRef<typeof Form>,
PaginationFormProps
>(({ children, className, ...props }, forwardedRef) => {
return (
<Form
{...props}
method="GET"
className={cn(className)}
preventScrollReset
data-testid="pagination-form"
ref={forwardedRef}
>
{children}
</Form>
);
});

const currPageValue = Math.floor(skip / take) + 1;
const prevPageValue = Math.max(skip - take, 0);
const nextPageValue = Math.min(skip + take, total - take + 1);
const lastPageValue = (pagesTotal - 1) * take;
PaginationForm.displayName = "PaginationForm";

const hasPrevPage = skip > 0;
const hasNextPage = skip + take < total;
export interface ButtonCursorPaginationProps
extends Omit<React.ComponentPropsWithoutRef<"button">, "children"> {
/** Sets the `class` attribute. */
className?: string | undefined;
}

const skipPageNumbers: number[] = [];
if (pagesTotal <= pagesMax) {
for (let i = 1; i <= pagesTotal; i++) {
skipPageNumbers.push(i);
}
} else {
let startPage = currPageValue - pagesMaxHalved;
let endPage = currPageValue + pagesMaxHalved;
if (startPage < 1) {
endPage += Math.abs(startPage) + 1;
startPage = 1;
}
if (endPage > pagesTotal) {
startPage -= endPage - pagesTotal;
endPage = pagesTotal;
}
for (let i = startPage; i <= endPage; i++) {
skipPageNumbers.push(i);
}
}
const skipPages = skipPageNumbers.map((skipPageNumber) => {
const skipPageValue = (skipPageNumber - 1) * take;
const isCurrPage = skipPageNumber === currPageValue;
const isSkipPage = skipPageValue >= 0 && skipPageValue < total;
return { isCurrPage, isSkipPage, skipPageNumber, skipPageValue };
});
export const ButtonCursorPagination = forwardRef<
React.ElementRef<"button">,
ButtonCursorPaginationProps
>(({ className, ...props }, forwardedRef) => {
return (
<Button
{...props}
type="submit"
variant="filled"
className={cn(className)}
ref={forwardedRef}
>
<Icon type="plus" />
<span>Load more</span>
</Button>
);
});

return {
prevPageValue,
currPageValue,
nextPageValue,
lastPageValue,
hasPrevPage,
hasNextPage,
skipPages,
};
}
ButtonCursorPagination.displayName = "ButtonCursorPagination";

export interface PaginationProps
extends React.ComponentPropsWithoutRef<typeof Form> {
export interface ButtonGroupOffsetPaginationProps
extends Omit<React.ComponentPropsWithoutRef<"div">, "children"> {
/** Sets the `class` attribute. */
className?: string | undefined;
/** The maximum number of pages to show. */
pagesMax?: Parameters<typeof paginate>[0]["pagesMax"] | undefined;
/** Sets the hidden fields. **Required** */
params: ReturnType<typeof paginateSearchParams>["params"];
pagesMax?: number | undefined;
/** The number of items to skip. **Required** */
skip: Parameters<typeof paginate>[0]["skip"];
skip: number;
/** The number of items per page. **Required** */
take: Parameters<typeof paginate>[0]["take"];
take: number;
/** The total number of items. **Required** */
total: Parameters<typeof paginate>[0]["total"];
total: number;
}

export const Pagination = forwardRef<
React.ElementRef<typeof Form>,
PaginationProps
>(
(
{ children, className, pagesMax = 7, params, skip, take, total, ...props },
forwardedRef,
) => {
const pagination = paginate({ pagesMax, skip, take, total });
export const ButtonGroupOffsetPagination = forwardRef<
React.ElementRef<"div">,
ButtonGroupOffsetPaginationProps
>(({ className, pagesMax = 7, skip, take, total, ...props }, forwardedRef) => {
const pagination = toOffsetPagination({ pagesMax, skip, take, total });

return (
<Form
{...props}
className={cn("flex flex-wrap items-center gap-2", className)}
method="GET"
preventScrollReset
data-testid="pagination-bar"
ref={forwardedRef}
return (
<div
{...props}
className={cn("flex flex-wrap items-center gap-2", className)}
ref={forwardedRef}
>
<Button
type="submit"
name="skip"
value={0}
disabled={!pagination.hasPrevPage}
size="sm-icon"
>
{params.map(([key, value]) => (
<input key={key} type="hidden" name={key} value={value} />
))}
<Button
type="submit"
name="skip"
value="0"
disabled={!pagination.hasPrevPage}
variant="outlined"
size="sm-icon"
>
<Icon type="chevrons-left" />
<span className="sr-only">First page</span>
</Button>
<Button
type="submit"
name="skip"
value={pagination.prevPageValue}
disabled={!pagination.hasPrevPage}
variant="outlined"
size="sm-icon"
>
<Icon type="chevron-left" />
<span className="sr-only">Previous page</span>
</Button>
{pagination.skipPages.map((el) => (
<Button
key={el.skipPageNumber}
type="submit"
name="skip"
value={el.skipPageValue}
disabled={!el.isSkipPage}
variant={el.isCurrPage ? "ghost" : "outlined"}
size="sm"
>
<span className="sr-only">Page </span>
{el.skipPageNumber}
</Button>
))}
<Button
type="submit"
name="skip"
value={pagination.nextPageValue}
disabled={!pagination.hasNextPage}
variant="outlined"
size="sm-icon"
>
<span className="sr-only">Next page</span>
<Icon type="chevron-right" />
</Button>
<Icon type="chevrons-left" />
<span className="sr-only">First page</span>
</Button>
<Button
type="submit"
name="skip"
value={pagination.prevPageValue}
disabled={!pagination.hasPrevPage}
size="sm-icon"
>
<Icon type="chevron-left" />
<span className="sr-only">Previous page</span>
</Button>
{pagination.skipPages.map((el) => (
<Button
key={el.skipPageNumber}
type="submit"
name="skip"
value={pagination.lastPageValue}
disabled={!pagination.hasNextPage}
variant="outlined"
size="sm-icon"
value={el.skipPageValue}
disabled={!el.isSkipPage}
variant={el.isCurrPage ? "ghost" : undefined}
size="sm"
>
<span className="sr-only">Last page</span>
<Icon type="chevrons-right" />
<span className="sr-only">Page </span>
{el.skipPageNumber}
</Button>
</Form>
);
},
);
))}
<Button
type="submit"
name="skip"
value={pagination.nextPageValue}
disabled={!pagination.hasNextPage}
size="sm-icon"
>
<span className="sr-only">Next page</span>
<Icon type="chevron-right" />
</Button>
<Button
type="submit"
name="skip"
value={pagination.lastPageValue}
disabled={!pagination.hasNextPage}
size="sm-icon"
>
<span className="sr-only">Last page</span>
<Icon type="chevrons-right" />
</Button>
</div>
);
});

Pagination.displayName = "Pagination";
ButtonGroupOffsetPagination.displayName = "ButtonGroupOffsetPagination";
39 changes: 39 additions & 0 deletions app/models/bookmark.server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,15 +33,52 @@ export async function getBookmarkByUrl({ url }: Pick<Bookmark, "url">) {
});
}

export async function getBookmarksCount({
searchKey,
searchValue,
}: {
searchKey?: BookmarkSearchKey | null;
searchValue?: string | null;
} = {}) {
if (searchValue && searchKey === "tags") {
return prisma.bookmark.count({
where: {
tags: { some: { tag: { name: { contains: searchValue } } } },
},
});
}

if (searchValue && searchKey) {
return prisma.bookmark.count({
where: { [searchKey]: { contains: searchValue } },
});
}

return prisma.bookmark.count();
}

export async function getBookmarks({
searchKey,
searchValue,
cursorId,
skip,
take,
}: {
searchKey?: BookmarkSearchKey | null;
searchValue?: string | null;
cursorId?: Bookmark["id"] | null;
skip?: number | null;
take?: number | null;
} = {}) {
const pagination = {
...(skip ? { skip } : {}),
...(take ? { take } : {}),
...(cursorId ? { cursor: { id: cursorId } } : {}),
};

if (searchValue && searchKey === "tags") {
return prisma.bookmark.findMany({
...pagination,
select: {
id: true,
url: true,
Expand All @@ -63,6 +100,7 @@ export async function getBookmarks({

if (searchValue && searchKey) {
return prisma.bookmark.findMany({
...pagination,
select: {
id: true,
url: true,
Expand All @@ -77,6 +115,7 @@ export async function getBookmarks({
}

return prisma.bookmark.findMany({
...pagination,
select: {
id: true,
url: true,
Expand Down
Loading

0 comments on commit 271e2b5

Please sign in to comment.