Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: cursor-based pagination for comments using "Load more" button #438

Merged
merged 20 commits into from
Aug 21, 2023
Merged
10 changes: 6 additions & 4 deletions fresh.gen.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,8 +38,9 @@ import * as $32 from "./routes/submit/_middleware.tsx";
import * as $33 from "./routes/submit/index.tsx";
import * as $34 from "./routes/users/[login].tsx";
import * as $$0 from "./islands/Chart.tsx";
import * as $$1 from "./islands/PageInput.tsx";
import * as $$2 from "./islands/VoteButton.tsx";
import * as $$1 from "./islands/CommentsList.tsx";
import * as $$2 from "./islands/PageInput.tsx";
import * as $$3 from "./islands/VoteButton.tsx";

const manifest = {
routes: {
Expand Down Expand Up @@ -81,8 +82,9 @@ const manifest = {
},
islands: {
"./islands/Chart.tsx": $$0,
"./islands/PageInput.tsx": $$1,
"./islands/VoteButton.tsx": $$2,
"./islands/CommentsList.tsx": $$1,
"./islands/PageInput.tsx": $$2,
"./islands/VoteButton.tsx": $$3,
},
baseUrl: import.meta.url,
};
Expand Down
62 changes: 62 additions & 0 deletions islands/CommentsList.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
// Copyright 2023 the Deno authors. All rights reserved. MIT license.
import { useSignal } from "@preact/signals";
import { useEffect } from "preact/hooks";
import { Comment } from "@/utils/db.ts";
import UserPostedAt from "@/components/UserPostedAt.tsx";
import { LINK_STYLES } from "@/utils/constants.ts";

async function fetchComments(itemId: string, cursor: string) {
let url = `/api/items/${itemId}/comments`;
if (cursor !== "" && cursor !== undefined) url += "?cursor=" + cursor;
const resp = await fetch(url);
if (!resp.ok) throw new Error(`Request failed: GET ${url}`);
return await resp.json() as { comments: Comment[]; cursor: string };
}

function CommentSummary(props: Comment) {
return (
<div class="py-4">
<UserPostedAt {...props} />
<p>{props.text}</p>
</div>
);
}

export default function CommentsList(props: { itemId: string }) {
const commentsSig = useSignal<Comment[]>([]);
const cursorSig = useSignal("");
const isLoadingSig = useSignal(false);

async function loadMoreComments() {
isLoadingSig.value = true;
try {
const { comments, cursor } = await fetchComments(
props.itemId,
cursorSig.value,
);
commentsSig.value = [...commentsSig.value, ...comments];
cursorSig.value = cursor;
} catch (error) {
console.log(error.message);
} finally {
isLoadingSig.value = false;
}
}

useEffect(() => {
loadMoreComments();
}, []);

return (
<div>
{commentsSig.value.map((comment) => (
<CommentSummary key={comment.id} {...comment} />
))}
{cursorSig.value !== "" && !isLoadingSig.value && (
<button onClick={loadMoreComments} class={LINK_STYLES}>
Load more
</button>
)}
</div>
);
}
2 changes: 1 addition & 1 deletion main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ if (Deno.env.get("RESET_DENO_KV") === "1") {
/**
* @todo Remove at v1. This is a quick way to migrate Deno KV, as database changes are likely to occur and require adjustment.
*/
if (Deno.env.get("MIGRATE_DENO_KEY") === "1") {
if (Deno.env.get("MIGRATE_DENO_KV") === "1") {
await migrateKv();
}

Expand Down
43 changes: 4 additions & 39 deletions routes/items/[id].tsx
Original file line number Diff line number Diff line change
@@ -1,25 +1,22 @@
// Copyright 2023 the Deno authors. All rights reserved. MIT license.
import type { Handlers, RouteContext } from "$fresh/server.ts";
import ItemSummary from "@/components/ItemSummary.tsx";
import PageSelector from "@/components/PageSelector.tsx";
import { BUTTON_STYLES, INPUT_STYLES } from "@/utils/constants.ts";
import { calcLastPage, calcPageNum, PAGE_LENGTH } from "@/utils/pagination.ts";
import {
type Comment,
createComment,
createNotification,
getAreVotedBySessionId,
getCommentsByItem,
getItem,
getUserBySession,
newCommentProps,
newNotificationProps,
Notification,
} from "@/utils/db.ts";
import UserPostedAt from "@/components/UserPostedAt.tsx";
import { redirect } from "@/utils/redirect.ts";
import Head from "@/components/Head.tsx";
import { SignedInState } from "@/utils/middleware.ts";
import CommentsList from "@/islands/CommentsList.tsx";

export const handler: Handlers<unknown, SignedInState> = {
async POST(req, ctx) {
Expand Down Expand Up @@ -75,39 +72,19 @@ function CommentInput() {
);
}

function CommentSummary(comment: Comment) {
return (
<div class="py-4">
<UserPostedAt
userLogin={comment.userLogin}
createdAt={comment.createdAt}
/>
<p>{comment.text}</p>
</div>
);
}

export default async function ItemsItemPage(
_req: Request,
ctx: RouteContext<undefined, SignedInState>,
) {
const { id } = ctx.params;
const item = await getItem(id);
const itemId = ctx.params.id;
const item = await getItem(itemId);
if (item === null) return await ctx.renderNotFound();

const pageNum = calcPageNum(ctx.url);
const allComments = await getCommentsByItem(id);
const comments = allComments
.sort((a, b) => b.createdAt.getTime() - a.createdAt.getTime())
.slice((pageNum - 1) * PAGE_LENGTH, pageNum * PAGE_LENGTH);

const [isVoted] = await getAreVotedBySessionId(
[item],
ctx.state.sessionId,
);

const lastPage = calcLastPage(allComments.length, PAGE_LENGTH);

return (
<>
<Head title={item.title} href={ctx.url.href} />
Expand All @@ -117,19 +94,7 @@ export default async function ItemsItemPage(
isVoted={isVoted}
/>
<CommentInput />
<div>
{comments.map((comment) => (
<CommentSummary
{...comment}
/>
))}
</div>
{lastPage > 1 && (
<PageSelector
currentPage={calcPageNum(ctx.url)}
lastPage={lastPage}
/>
)}
<CommentsList itemId={ctx.params.id} />
</main>
</>
);
Expand Down
6 changes: 1 addition & 5 deletions utils/db.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ async function getValues<T>(
) {
const values = [];
const iter = kv.list<T>(selector, options);
for await (const { value } of iter) values.push(value);
for await (const entry of iter) values.push(entry.value);
return values;
}

Expand Down Expand Up @@ -335,10 +335,6 @@ export async function deleteComment(comment: Comment) {
if (!res.ok) throw new Error(`Failed to delete comment: ${comment}`);
}

export async function getCommentsByItem(itemId: string) {
return await getValues<Comment>({ prefix: ["comments_by_item", itemId] });
}

export function listCommentsByItem(
itemId: string,
options?: Deno.KvListOptions,
Expand Down
12 changes: 8 additions & 4 deletions utils/db_test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
// Copyright 2023 the Deno authors. All rights reserved. MIT license.
import {
collectValues,
type Comment,
compareScore,
createComment,
Expand All @@ -15,7 +16,6 @@ import {
formatDate,
getAllItems,
getAreVotedBySessionId,
getCommentsByItem,
getDatesSince,
getItem,
getItemsByUser,
Expand All @@ -30,6 +30,7 @@ import {
ifUserHasNotifications,
incrVisitsCountByDay,
type Item,
listCommentsByItem,
newCommentProps,
newItemProps,
newNotificationProps,
Expand Down Expand Up @@ -191,16 +192,19 @@ Deno.test("[db] (create/delete)Comment() + getCommentsByItem()", async () => {
const comment1 = { ...genNewComment(), itemId };
const comment2 = { ...genNewComment(), itemId };

assertEquals(await getCommentsByItem(itemId), []);
assertEquals(await collectValues(listCommentsByItem(itemId)), []);

await createComment(comment1);
await createComment(comment2);
await assertRejects(async () => await createComment(comment2));
assertArrayIncludes(await getCommentsByItem(itemId), [comment1, comment2]);
assertArrayIncludes(await collectValues(listCommentsByItem(itemId)), [
comment1,
comment2,
]);

await deleteComment(comment1);
await deleteComment(comment2);
assertEquals(await getCommentsByItem(itemId), []);
assertEquals(await collectValues(listCommentsByItem(itemId)), []);
});

Deno.test("[db] votes", async () => {
Expand Down