Skip to content

Commit

Permalink
feat: <ItemsList /> with cursor-based pagination (denoland#445)
Browse files Browse the repository at this point in the history
This change contains a few changes that affect interdependent features.
Changes include:
- Add `<ItemsList />` with cursor-based pagination
- Remove now unused code for the previous pagination solution
- Fix and rework voting functionality relating to denoland#427
- Tweak REST API endpoints return signature to be more generic, using a
`values` field
- `fetchValues()`
- Simplify and optimise `<VoteButton />`
- New `DELETE/GET /api/items/[id]/vote` endpoint
- New `GET /api/me/votes` endpoint
- Other various cleanups

Note: The migration script has been tested successfully locally.

Closes denoland#427
Closes denoland#414
Towards denoland#439
  • Loading branch information
iuioiua authored Aug 29, 2023
1 parent 0f30692 commit 853e6e6
Show file tree
Hide file tree
Showing 27 changed files with 423 additions and 561 deletions.
2 changes: 1 addition & 1 deletion components/ItemSummary.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
// Copyright 2023 the Deno authors. All rights reserved. MIT license.
import VoteButton from "@/islands/VoteButton.tsx";
import type { Item, User } from "@/utils/db.ts";
import type { Item } from "@/utils/db.ts";
import UserPostedAt from "./UserPostedAt.tsx";

export interface ItemSummaryProps {
Expand Down
19 changes: 0 additions & 19 deletions components/PageSelector.tsx

This file was deleted.

2 changes: 1 addition & 1 deletion components/UserPostedAt.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ export default function UserPostedAt(
<a class="hover:underline" href={`/users/${props.userLogin}`}>
{props.userLogin}
</a>{" "}
{timeAgo(new Date(props.createdAt))} ago
{timeAgo(props.createdAt)} ago
</p>
);
}
20 changes: 10 additions & 10 deletions e2e_test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -196,9 +196,9 @@ Deno.test("[http]", async (test) => {
const req = new Request("http://localhost/api/items");
const resp = await handler(req);

const { items } = await resp.json();
const { values } = await resp.json();
assertResponseJson(resp);
assertArrayIncludes(items, [
assertArrayIncludes(values, [
JSON.parse(JSON.stringify(item1)),
JSON.parse(JSON.stringify(item2)),
]);
Expand Down Expand Up @@ -231,9 +231,9 @@ Deno.test("[http]", async (test) => {
await createItem(item);
await createComment(comment);
const resp2 = await handler(req);
const { comments } = await resp2.json();
const { values } = await resp2.json();
assertResponseJson(resp2);
assertEquals(comments, JSON.parse(JSON.stringify(comments)));
assertEquals(values, [JSON.parse(JSON.stringify(comment))]);
});

await test.step("GET /api/users", async () => {
Expand All @@ -245,9 +245,9 @@ Deno.test("[http]", async (test) => {
const req = new Request("http://localhost/api/users");
const resp = await handler(req);

const { users } = await resp.json();
const { values } = await resp.json();
assertResponseJson(resp);
assertArrayIncludes(users, [user1, user2]);
assertArrayIncludes(values, [user1, user2]);
});

await test.step("GET /api/users/[login]", async () => {
Expand Down Expand Up @@ -278,9 +278,9 @@ Deno.test("[http]", async (test) => {
await createItem(item);

const resp2 = await handler(req);
const { items } = await resp2.json();
const { values } = await resp2.json();
assertResponseJson(resp2);
assertArrayIncludes(items, [JSON.parse(JSON.stringify(item))]);
assertArrayIncludes(values, [JSON.parse(JSON.stringify(item))]);
});

await test.step("GET /api/users/[login]/notifications", async () => {
Expand All @@ -300,9 +300,9 @@ Deno.test("[http]", async (test) => {
await createNotification(notification);

const resp2 = await handler(req);
const { notifications } = await resp2.json();
const { values } = await resp2.json();
assertResponseJson(resp2);
assertArrayIncludes(notifications, [
assertArrayIncludes(values, [
JSON.parse(JSON.stringify(notification)),
]);
});
Expand Down
114 changes: 58 additions & 56 deletions fresh.gen.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,36 +12,37 @@ import * as $6 from "./routes/account/manage.ts";
import * as $7 from "./routes/account/upgrade.ts";
import * as $8 from "./routes/api/items/[id]/comments.ts";
import * as $9 from "./routes/api/items/[id]/index.ts";
import * as $10 from "./routes/api/items/index.ts";
import * as $11 from "./routes/api/stripe-webhooks.ts";
import * as $12 from "./routes/api/users/[login]/index.ts";
import * as $13 from "./routes/api/users/[login]/items.ts";
import * as $14 from "./routes/api/users/[login]/notifications.ts";
import * as $15 from "./routes/api/users/index.ts";
import * as $16 from "./routes/api/vote.ts";
import * as $17 from "./routes/blog/[slug].tsx";
import * as $18 from "./routes/blog/index.tsx";
import * as $19 from "./routes/callback.ts";
import * as $20 from "./routes/dashboard/_middleware.ts";
import * as $21 from "./routes/dashboard/index.tsx";
import * as $22 from "./routes/dashboard/stats.tsx";
import * as $23 from "./routes/dashboard/users.tsx";
import * as $24 from "./routes/feed.ts";
import * as $25 from "./routes/index.tsx";
import * as $26 from "./routes/items/[id].tsx";
import * as $27 from "./routes/notifications/[id].ts";
import * as $28 from "./routes/notifications/_middleware.ts";
import * as $29 from "./routes/notifications/index.tsx";
import * as $30 from "./routes/pricing.tsx";
import * as $31 from "./routes/signin.ts";
import * as $32 from "./routes/signout.ts";
import * as $33 from "./routes/submit/_middleware.tsx";
import * as $34 from "./routes/submit/index.tsx";
import * as $35 from "./routes/users/[login].tsx";
import * as $10 from "./routes/api/items/[id]/vote.ts";
import * as $11 from "./routes/api/items/index.ts";
import * as $12 from "./routes/api/me/votes.ts";
import * as $13 from "./routes/api/stripe-webhooks.ts";
import * as $14 from "./routes/api/users/[login]/index.ts";
import * as $15 from "./routes/api/users/[login]/items.ts";
import * as $16 from "./routes/api/users/[login]/notifications.ts";
import * as $17 from "./routes/api/users/index.ts";
import * as $18 from "./routes/blog/[slug].tsx";
import * as $19 from "./routes/blog/index.tsx";
import * as $20 from "./routes/callback.ts";
import * as $21 from "./routes/dashboard/_middleware.ts";
import * as $22 from "./routes/dashboard/index.tsx";
import * as $23 from "./routes/dashboard/stats.tsx";
import * as $24 from "./routes/dashboard/users.tsx";
import * as $25 from "./routes/feed.ts";
import * as $26 from "./routes/index.tsx";
import * as $27 from "./routes/items/[id].tsx";
import * as $28 from "./routes/notifications/[id].ts";
import * as $29 from "./routes/notifications/_middleware.ts";
import * as $30 from "./routes/notifications/index.tsx";
import * as $31 from "./routes/pricing.tsx";
import * as $32 from "./routes/signin.ts";
import * as $33 from "./routes/signout.ts";
import * as $34 from "./routes/submit/_middleware.tsx";
import * as $35 from "./routes/submit/index.tsx";
import * as $36 from "./routes/users/[login].tsx";
import * as $$0 from "./islands/Chart.tsx";
import * as $$1 from "./islands/CommentsList.tsx";
import * as $$2 from "./islands/NotificationsList.tsx";
import * as $$3 from "./islands/PageInput.tsx";
import * as $$2 from "./islands/ItemsList.tsx";
import * as $$3 from "./islands/NotificationsList.tsx";
import * as $$4 from "./islands/UsersTable.tsx";
import * as $$5 from "./islands/VoteButton.tsx";

Expand All @@ -57,38 +58,39 @@ const manifest = {
"./routes/account/upgrade.ts": $7,
"./routes/api/items/[id]/comments.ts": $8,
"./routes/api/items/[id]/index.ts": $9,
"./routes/api/items/index.ts": $10,
"./routes/api/stripe-webhooks.ts": $11,
"./routes/api/users/[login]/index.ts": $12,
"./routes/api/users/[login]/items.ts": $13,
"./routes/api/users/[login]/notifications.ts": $14,
"./routes/api/users/index.ts": $15,
"./routes/api/vote.ts": $16,
"./routes/blog/[slug].tsx": $17,
"./routes/blog/index.tsx": $18,
"./routes/callback.ts": $19,
"./routes/dashboard/_middleware.ts": $20,
"./routes/dashboard/index.tsx": $21,
"./routes/dashboard/stats.tsx": $22,
"./routes/dashboard/users.tsx": $23,
"./routes/feed.ts": $24,
"./routes/index.tsx": $25,
"./routes/items/[id].tsx": $26,
"./routes/notifications/[id].ts": $27,
"./routes/notifications/_middleware.ts": $28,
"./routes/notifications/index.tsx": $29,
"./routes/pricing.tsx": $30,
"./routes/signin.ts": $31,
"./routes/signout.ts": $32,
"./routes/submit/_middleware.tsx": $33,
"./routes/submit/index.tsx": $34,
"./routes/users/[login].tsx": $35,
"./routes/api/items/[id]/vote.ts": $10,
"./routes/api/items/index.ts": $11,
"./routes/api/me/votes.ts": $12,
"./routes/api/stripe-webhooks.ts": $13,
"./routes/api/users/[login]/index.ts": $14,
"./routes/api/users/[login]/items.ts": $15,
"./routes/api/users/[login]/notifications.ts": $16,
"./routes/api/users/index.ts": $17,
"./routes/blog/[slug].tsx": $18,
"./routes/blog/index.tsx": $19,
"./routes/callback.ts": $20,
"./routes/dashboard/_middleware.ts": $21,
"./routes/dashboard/index.tsx": $22,
"./routes/dashboard/stats.tsx": $23,
"./routes/dashboard/users.tsx": $24,
"./routes/feed.ts": $25,
"./routes/index.tsx": $26,
"./routes/items/[id].tsx": $27,
"./routes/notifications/[id].ts": $28,
"./routes/notifications/_middleware.ts": $29,
"./routes/notifications/index.tsx": $30,
"./routes/pricing.tsx": $31,
"./routes/signin.ts": $32,
"./routes/signout.ts": $33,
"./routes/submit/_middleware.tsx": $34,
"./routes/submit/index.tsx": $35,
"./routes/users/[login].tsx": $36,
},
islands: {
"./islands/Chart.tsx": $$0,
"./islands/CommentsList.tsx": $$1,
"./islands/NotificationsList.tsx": $$2,
"./islands/PageInput.tsx": $$3,
"./islands/ItemsList.tsx": $$2,
"./islands/NotificationsList.tsx": $$3,
"./islands/UsersTable.tsx": $$4,
"./islands/VoteButton.tsx": $$5,
},
Expand Down
16 changes: 5 additions & 11 deletions islands/CommentsList.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,14 +4,7 @@ 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 };
}
import { fetchValues } from "@/utils/http.ts";

function CommentSummary(props: Comment) {
return (
Expand All @@ -26,15 +19,16 @@ export default function CommentsList(props: { itemId: string }) {
const commentsSig = useSignal<Comment[]>([]);
const cursorSig = useSignal("");
const isLoadingSig = useSignal(false);
const endpoint = `/api/items/${props.itemId}/comments`;

async function loadMoreComments() {
isLoadingSig.value = true;
try {
const { comments, cursor } = await fetchComments(
props.itemId,
const { values, cursor } = await fetchValues<Comment>(
endpoint,
cursorSig.value,
);
commentsSig.value = [...commentsSig.value, ...comments];
commentsSig.value = [...commentsSig.value, ...values];
cursorSig.value = cursor;
} catch (error) {
console.log(error.message);
Expand Down
90 changes: 90 additions & 0 deletions islands/ItemsList.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
// Copyright 2023 the Deno authors. All rights reserved. MIT license.
import { useComputed, useSignal } from "@preact/signals";
import { useEffect } from "preact/hooks";
import type { Item } from "@/utils/db.ts";
import { LINK_STYLES } from "@/utils/constants.ts";
import IconInfo from "tabler_icons_tsx/info-circle.tsx";
import ItemSummary from "@/components/ItemSummary.tsx";
import { fetchValues } from "@/utils/http.ts";

async function fetchVotedItems() {
const url = "/api/me/votes";
const resp = await fetch(url);
if (!resp.ok) throw new Error(`Request failed: GET ${url}`);
return await resp.json() as Item[];
}

function EmptyItemsList() {
return (
<>
<div class="flex flex-col justify-center items-center gap-2">
<div class="flex flex-col items-center gap-2 pt-16">
<IconInfo class="w-10 h-10 text-gray-400 dark:text-gray-600" />
<p class="text-center font-medium">No items found</p>
</div>

<a
href="/submit"
class="inline-flex items-center justify-center gap-2 rounded-md px-3 py-2 text-primary hover:underline"
>
Submit your project
</a>
</div>
</>
);
}

export default function ItemsList(props: { endpoint: string }) {
const itemsSig = useSignal<Item[]>([]);
const votedItemsIdsSig = useSignal<string[]>([]);
const cursorSig = useSignal("");
const isLoadingSig = useSignal(false);
const itemsAreVotedSig = useComputed(() =>
itemsSig.value.map((item) => votedItemsIdsSig.value.includes(item.id))
);

async function loadMoreItems() {
isLoadingSig.value = true;
try {
const { values, cursor } = await fetchValues<Item>(
props.endpoint,
cursorSig.value,
);
itemsSig.value = [...itemsSig.value, ...values];
cursorSig.value = cursor;
} catch (error) {
console.error(error.message);
} finally {
isLoadingSig.value = false;
}
}

useEffect(() => {
fetchVotedItems()
.then((votedItems) =>
votedItemsIdsSig.value = votedItems.map(({ id }) => id)
)
.then(() => loadMoreItems());
}, []);

return (
<div>
{itemsSig.value.length
? itemsSig.value.map((item, id) => {
return (
<ItemSummary
key={item.id}
item={item}
isVoted={itemsAreVotedSig.value[id]}
/>
);
})
: <EmptyItemsList />}
{cursorSig.value !== "" && !isLoadingSig.value && (
<button onClick={loadMoreItems} class={LINK_STYLES}>
Load more
</button>
)}
</div>
);
}
16 changes: 5 additions & 11 deletions islands/NotificationsList.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,14 +4,7 @@ import { useEffect } from "preact/hooks";
import type { Notification } from "@/utils/db.ts";
import { LINK_STYLES } from "@/utils/constants.ts";
import { timeAgo } from "@/utils/display.ts";

async function fetchNotifications(userLogin: string, cursor: string) {
let url = `/api/users/${userLogin}/notifications`;
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 { notifications: Notification[]; cursor: string };
}
import { fetchValues } from "@/utils/http.ts";

function NotificationSummary(props: Notification) {
return (
Expand All @@ -34,15 +27,16 @@ export default function NotificationsList(props: { userLogin: string }) {
const notificationsSig = useSignal<Notification[]>([]);
const cursorSig = useSignal("");
const isLoadingSig = useSignal(false);
const endpoint = `/api/users/${props.userLogin}/notifications`;

async function loadMoreNotifications() {
isLoadingSig.value = true;
try {
const { notifications, cursor } = await fetchNotifications(
props.userLogin,
const { values, cursor } = await fetchValues<Notification>(
endpoint,
cursorSig.value,
);
notificationsSig.value = [...notificationsSig.value, ...notifications];
notificationsSig.value = [...notificationsSig.value, ...values];
cursorSig.value = cursor;
} catch (error) {
console.log(error.message);
Expand Down
Loading

0 comments on commit 853e6e6

Please sign in to comment.