From 853e6e657905124927dc5d6a6f19d4a5480ab93a Mon Sep 17 00:00:00 2001 From: Asher Gomez Date: Tue, 29 Aug 2023 17:19:04 +1000 Subject: [PATCH] feat: `` with cursor-based pagination (#445) This change contains a few changes that affect interdependent features. Changes include: - Add `` with cursor-based pagination - Remove now unused code for the previous pagination solution - Fix and rework voting functionality relating to #427 - Tweak REST API endpoints return signature to be more generic, using a `values` field - `fetchValues()` - Simplify and optimise `` - 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 #427 Closes #414 Towards #439 --- components/ItemSummary.tsx | 2 +- components/PageSelector.tsx | 19 --- components/UserPostedAt.tsx | 2 +- e2e_test.ts | 20 +-- fresh.gen.ts | 114 +++++++------ islands/CommentsList.tsx | 16 +- islands/ItemsList.tsx | 90 ++++++++++ islands/NotificationsList.tsx | 16 +- islands/PageInput.tsx | 20 --- islands/UsersTable.tsx | 17 +- islands/VoteButton.tsx | 26 ++- routes/api/items/[id]/comments.ts | 4 +- routes/api/items/[id]/vote.ts | 59 +++++++ routes/api/items/index.ts | 4 +- routes/api/me/votes.ts | 23 +++ routes/api/users/[login]/items.ts | 4 +- routes/api/users/[login]/notifications.ts | 5 +- routes/api/users/index.ts | 4 +- routes/api/vote.ts | 70 -------- routes/index.tsx | 97 +---------- routes/users/[login].tsx | 48 +----- tools/migrate_kv.ts | 21 ++- utils/db.ts | 199 +++++++++------------- utils/db_test.ts | 62 +++---- utils/http.ts | 8 + utils/pagination.ts | 11 -- utils/pagination_test.ts | 23 +-- 27 files changed, 423 insertions(+), 561 deletions(-) delete mode 100644 components/PageSelector.tsx create mode 100644 islands/ItemsList.tsx delete mode 100644 islands/PageInput.tsx create mode 100644 routes/api/items/[id]/vote.ts create mode 100644 routes/api/me/votes.ts delete mode 100644 routes/api/vote.ts create mode 100644 utils/http.ts diff --git a/components/ItemSummary.tsx b/components/ItemSummary.tsx index 8428a2bd0..8131abba9 100644 --- a/components/ItemSummary.tsx +++ b/components/ItemSummary.tsx @@ -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 { diff --git a/components/PageSelector.tsx b/components/PageSelector.tsx deleted file mode 100644 index c1232f87c..000000000 --- a/components/PageSelector.tsx +++ /dev/null @@ -1,19 +0,0 @@ -// Copyright 2023 the Deno authors. All rights reserved. MIT license. -import PageInput from "@/islands/PageInput.tsx"; - -export default function PageSelector( - props: { currentPage: number; lastPage: number; timeSelector?: string }, -) { - return ( -
-
- {props.timeSelector && - } - - - -
- ); -} diff --git a/components/UserPostedAt.tsx b/components/UserPostedAt.tsx index a209affc4..b9f0a3cda 100644 --- a/components/UserPostedAt.tsx +++ b/components/UserPostedAt.tsx @@ -15,7 +15,7 @@ export default function UserPostedAt( {props.userLogin} {" "} - {timeAgo(new Date(props.createdAt))} ago + {timeAgo(props.createdAt)} ago

); } diff --git a/e2e_test.ts b/e2e_test.ts index 2dc17adf0..d4cbe1298 100644 --- a/e2e_test.ts +++ b/e2e_test.ts @@ -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)), ]); @@ -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 () => { @@ -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 () => { @@ -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 () => { @@ -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)), ]); }); diff --git a/fresh.gen.ts b/fresh.gen.ts index d2a6659d3..dc301aa98 100644 --- a/fresh.gen.ts +++ b/fresh.gen.ts @@ -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"; @@ -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, }, diff --git a/islands/CommentsList.tsx b/islands/CommentsList.tsx index ee9f74475..df892afaf 100644 --- a/islands/CommentsList.tsx +++ b/islands/CommentsList.tsx @@ -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 ( @@ -26,15 +19,16 @@ export default function CommentsList(props: { itemId: string }) { const commentsSig = useSignal([]); 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( + endpoint, cursorSig.value, ); - commentsSig.value = [...commentsSig.value, ...comments]; + commentsSig.value = [...commentsSig.value, ...values]; cursorSig.value = cursor; } catch (error) { console.log(error.message); diff --git a/islands/ItemsList.tsx b/islands/ItemsList.tsx new file mode 100644 index 000000000..4393dfd6d --- /dev/null +++ b/islands/ItemsList.tsx @@ -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 ( + <> +
+
+ +

No items found

+
+ + + Submit your project + +
+ + ); +} + +export default function ItemsList(props: { endpoint: string }) { + const itemsSig = useSignal([]); + const votedItemsIdsSig = useSignal([]); + 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( + 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 ( +
+ {itemsSig.value.length + ? itemsSig.value.map((item, id) => { + return ( + + ); + }) + : } + {cursorSig.value !== "" && !isLoadingSig.value && ( + + )} +
+ ); +} diff --git a/islands/NotificationsList.tsx b/islands/NotificationsList.tsx index 1c13b1bf7..0c20cd60b 100644 --- a/islands/NotificationsList.tsx +++ b/islands/NotificationsList.tsx @@ -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 ( @@ -34,15 +27,16 @@ export default function NotificationsList(props: { userLogin: string }) { const notificationsSig = useSignal([]); 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( + endpoint, cursorSig.value, ); - notificationsSig.value = [...notificationsSig.value, ...notifications]; + notificationsSig.value = [...notificationsSig.value, ...values]; cursorSig.value = cursor; } catch (error) { console.log(error.message); diff --git a/islands/PageInput.tsx b/islands/PageInput.tsx deleted file mode 100644 index e33be1d3a..000000000 --- a/islands/PageInput.tsx +++ /dev/null @@ -1,20 +0,0 @@ -// Copyright 2023 the Deno authors. All rights reserved. MIT license. -import { INPUT_STYLES } from "@/utils/constants.ts"; - -export default function PageInput( - props: { lastPage: number; currentPage: number }, -) { - return ( - event.srcElement!.form.submit()} - /> - ); -} diff --git a/islands/UsersTable.tsx b/islands/UsersTable.tsx index b0ad0e33d..a752a43d5 100644 --- a/islands/UsersTable.tsx +++ b/islands/UsersTable.tsx @@ -4,18 +4,11 @@ import { useEffect } from "preact/hooks"; import type { User } from "@/utils/db.ts"; import GitHubAvatarImg from "@/components/GitHubAvatarImg.tsx"; import { LINK_STYLES } from "@/utils/constants.ts"; +import { fetchValues } from "@/utils/http.ts"; const TH_STYLES = "p-4 text-left"; const TD_STYLES = "p-4"; -async function fetchUsers(cursor: string) { - let url = "/api/users"; - if (cursor !== "") url += "?cursor=" + cursor; - const resp = await fetch(url); - if (!resp.ok) throw new Error(`Request failed: GET ${url}`); - return await resp.json() as { users: User[]; cursor: string }; -} - function UserTableRow(props: User) { return ( @@ -42,12 +35,16 @@ export default function UsersTable() { const usersSig = useSignal([]); const cursorSig = useSignal(""); const isLoadingSig = useSignal(false); + const endpoint = "/api/users"; async function loadMoreUsers() { isLoadingSig.value = true; try { - const { users, cursor } = await fetchUsers(cursorSig.value); - usersSig.value = [...usersSig.value, ...users]; + const { values, cursor } = await fetchValues( + endpoint, + cursorSig.value, + ); + usersSig.value = [...usersSig.value, ...values]; cursorSig.value = cursor; } catch (error) { console.log(error.message); diff --git a/islands/VoteButton.tsx b/islands/VoteButton.tsx index 03baeccf0..bc2115c23 100644 --- a/islands/VoteButton.tsx +++ b/islands/VoteButton.tsx @@ -10,23 +10,21 @@ export interface VoteButtonProps { export default function VoteButton(props: VoteButtonProps) { const isVoted = useSignal(props.isVoted); const score = useSignal(props.item.score); + const url = `/api/items/${props.item.id}/vote`; async function onClick(event: MouseEvent) { - if (event.detail === 1) { - const url = `/api/vote?item_id=${props.item.id}`; - const method = isVoted.value ? "DELETE" : "POST"; - const resp = await fetch(url, { method, credentials: "same-origin" }); + if (event.detail !== 1) return; + const method = isVoted.value ? "DELETE" : "POST"; + const resp = await fetch(url, { method }); - if (resp.status === 401) { - window.location.href = "/signin"; - return; - } - isVoted.value = !isVoted.value; - method === "POST" ? score.value++ : score.value--; - if (score.value < (props.item.score - 1) || score.value < 0) { - score.value = props.item.score; - } + if (resp.status === 401) { + window.location.href = "/signin"; + return; } + if (!resp.ok) throw new Error(`Request failed: ${method} ${url}`); + + isVoted.value = !isVoted.value; + method === "POST" ? score.value++ : score.value--; } return ( @@ -37,7 +35,7 @@ export default function VoteButton(props: VoteButtonProps) { > ▲
- {score.value} + {score} ); } diff --git a/routes/api/items/[id]/comments.ts b/routes/api/items/[id]/comments.ts index 2df1ead83..4f4c58c71 100644 --- a/routes/api/items/[id]/comments.ts +++ b/routes/api/items/[id]/comments.ts @@ -16,7 +16,7 @@ export const handler: Handlers = { // Newest to oldest reverse: true, }); - const comments = await collectValues(iter); - return Response.json({ comments, cursor: iter.cursor }); + const values = await collectValues(iter); + return Response.json({ values, cursor: iter.cursor }); }, }; diff --git a/routes/api/items/[id]/vote.ts b/routes/api/items/[id]/vote.ts new file mode 100644 index 000000000..13a722a29 --- /dev/null +++ b/routes/api/items/[id]/vote.ts @@ -0,0 +1,59 @@ +// Copyright 2023 the Deno authors. All rights reserved. MIT license. +import { type Handlers, Status } from "$fresh/server.ts"; +import type { State } from "@/routes/_middleware.ts"; +import { + createNotification, + createVote, + deleteVote, + getItem, + getUserBySession, + newNotificationProps, + newVoteProps, +} from "@/utils/db.ts"; + +export const handler: Handlers = { + async POST(_req, ctx) { + const itemId = ctx.params.id; + const item = await getItem(itemId); + if (item === null) return new Response(null, { status: Status.NotFound }); + + if (ctx.state.sessionId === undefined) { + return new Response(null, { status: Status.Unauthorized }); + } + const user = await getUserBySession(ctx.state.sessionId); + if (user === null) return new Response(null, { status: Status.NotFound }); + + await createVote({ + itemId, + userLogin: user.login, + ...newVoteProps(), + }); + + if (item.userLogin !== user.login) { + await createNotification({ + userLogin: item.userLogin, + type: "vote", + text: `${user.login} upvoted your post: ${item.title}`, + originUrl: `/items/${itemId}`, + ...newNotificationProps(), + }); + } + + return new Response(null, { status: Status.Created }); + }, + async DELETE(_req, ctx) { + const itemId = ctx.params.id; + const item = await getItem(itemId); + if (item === null) return new Response(null, { status: Status.NotFound }); + + if (ctx.state.sessionId === undefined) { + return new Response(null, { status: Status.Unauthorized }); + } + const user = await getUserBySession(ctx.state.sessionId); + if (user === null) return new Response(null, { status: Status.NotFound }); + + await deleteVote({ itemId, userLogin: user.login }); + + return new Response(null, { status: Status.NoContent }); + }, +}; diff --git a/routes/api/items/index.ts b/routes/api/items/index.ts index 83bb282cb..23bb8fe57 100644 --- a/routes/api/items/index.ts +++ b/routes/api/items/index.ts @@ -11,7 +11,7 @@ export const handler: Handlers = { limit: 10, reverse: true, }); - const items = await collectValues(iter); - return Response.json({ items, cursor: iter.cursor }); + const values = await collectValues(iter); + return Response.json({ values, cursor: iter.cursor }); }, }; diff --git a/routes/api/me/votes.ts b/routes/api/me/votes.ts new file mode 100644 index 000000000..4d4f692e9 --- /dev/null +++ b/routes/api/me/votes.ts @@ -0,0 +1,23 @@ +// Copyright 2023 the Deno authors. All rights reserved. MIT license. +import { type Handlers, Status } from "$fresh/server.ts"; +import type { State } from "@/routes/_middleware.ts"; +import { + collectValues, + getUserBySession, + listItemsVotedByUser, +} from "@/utils/db.ts"; + +export const handler: Handlers = { + async GET(_req, ctx) { + if (ctx.state.sessionId === undefined) { + return new Response(null, { status: Status.Unauthorized }); + } + + const user = await getUserBySession(ctx.state.sessionId); + if (user === null) return new Response(null, { status: Status.NotFound }); + + const iter = listItemsVotedByUser(user.login); + const items = await collectValues(iter); + return Response.json(items); + }, +}; diff --git a/routes/api/users/[login]/items.ts b/routes/api/users/[login]/items.ts index cebf0c546..ffc43d032 100644 --- a/routes/api/users/[login]/items.ts +++ b/routes/api/users/[login]/items.ts @@ -13,7 +13,7 @@ export const handler: Handlers = { cursor: getCursor(url), limit: 10, }); - const items = await collectValues(iter); - return Response.json({ items, cursor: iter.cursor }); + const values = await collectValues(iter); + return Response.json({ values, cursor: iter.cursor }); }, }; diff --git a/routes/api/users/[login]/notifications.ts b/routes/api/users/[login]/notifications.ts index 30394389c..a316853d2 100644 --- a/routes/api/users/[login]/notifications.ts +++ b/routes/api/users/[login]/notifications.ts @@ -3,6 +3,7 @@ import { type Handlers, Status } from "$fresh/server.ts"; import { collectValues, getUser, listNotificationsByUser } from "@/utils/db.ts"; import { getCursor } from "@/utils/pagination.ts"; +/** @todo(iuioiua) Move to GET /api/me/notifications */ export const handler: Handlers = { async GET(req, ctx) { const user = await getUser(ctx.params.login); @@ -15,7 +16,7 @@ export const handler: Handlers = { // Newest to oldest reverse: true, }); - const notifications = await collectValues(iter); - return Response.json({ notifications, cursor: iter.cursor }); + const values = await collectValues(iter); + return Response.json({ values, cursor: iter.cursor }); }, }; diff --git a/routes/api/users/index.ts b/routes/api/users/index.ts index c6fa1c4bf..df2693947 100644 --- a/routes/api/users/index.ts +++ b/routes/api/users/index.ts @@ -10,7 +10,7 @@ export const handler: Handlers = { cursor: getCursor(url), limit: 10, }); - const users = await collectValues(iter); - return Response.json({ users, cursor: iter.cursor }); + const values = await collectValues(iter); + return Response.json({ values, cursor: iter.cursor }); }, }; diff --git a/routes/api/vote.ts b/routes/api/vote.ts deleted file mode 100644 index 2989cf450..000000000 --- a/routes/api/vote.ts +++ /dev/null @@ -1,70 +0,0 @@ -// Copyright 2023 the Deno authors. All rights reserved. MIT license. -import type { HandlerContext, Handlers, PageProps } from "$fresh/server.ts"; -import type { State } from "@/routes/_middleware.ts"; -import { createVote, deleteVote, getItem, newVoteProps } from "@/utils/db.ts"; -import { - createNotification, - getUserBySession, - newNotificationProps, - Notification, -} from "@/utils/db.ts"; - -async function sharedHandler( - req: Request, - ctx: HandlerContext, State>, -) { - if (!ctx.state.sessionId) { - return new Response(null, { status: 401 }); - } - - const itemId = new URL(req.url).searchParams.get("item_id"); - if (!itemId) { - return new Response(null, { status: 400 }); - } - - const [item, user] = await Promise.all([ - getItem(itemId), - getUserBySession(ctx.state.sessionId), - ]); - if (item === null || user === null) { - return new Response(null, { status: 404 }); - } - - const vote = { - item, - userLogin: user.login, - ...newVoteProps(), - }; - let status; - switch (req.method) { - case "DELETE": - status = 204; - await deleteVote(vote); - break; - case "POST": { - status = 201; - await createVote(vote); - - if (item.userLogin !== user.login) { - const notification: Notification = { - userLogin: item.userLogin, - type: "vote", - text: `${user.login} upvoted your post: ${item.title}`, - originUrl: `/items/${itemId}`, - ...newNotificationProps(), - }; - await createNotification(notification); - } - break; - } - default: - return new Response(null, { status: 400 }); - } - - return new Response(null, { status }); -} - -export const handler: Handlers = { - POST: sharedHandler, - DELETE: sharedHandler, -}; diff --git a/routes/index.tsx b/routes/index.tsx index 1eb5b04d6..3ed73bed0 100644 --- a/routes/index.tsx +++ b/routes/index.tsx @@ -1,52 +1,12 @@ // Copyright 2023 the Deno authors. All rights reserved. MIT license. import type { RouteContext } from "$fresh/server.ts"; -import { calcLastPage, calcPageNum, PAGE_LENGTH } from "@/utils/pagination.ts"; import type { State } from "./_middleware.ts"; -import ItemSummary from "@/components/ItemSummary.tsx"; -import PageSelector from "@/components/PageSelector.tsx"; -import { - compareScore, - getAllItems, - getAreVotedBySessionId, - getItemsSince, - type Item, -} from "@/utils/db.ts"; -import { DAY, WEEK } from "std/datetime/constants.ts"; import Head from "@/components/Head.tsx"; -import IconInfo from "tabler_icons_tsx/info-circle.tsx"; -import { TabItem } from "@/components/TabsBar.tsx"; +import ItemsList from "@/islands/ItemsList.tsx"; const NEEDS_SETUP = Deno.env.get("GITHUB_CLIENT_ID") === undefined || Deno.env.get("GITHUB_CLIENT_SECRET") === undefined; -function calcTimeAgoFilter(url: URL) { - return url.searchParams.get("time-ago"); -} - -function TimeSelector(props: { url: URL }) { - const timeAgo = props.url.searchParams.get("time-ago"); - return ( -
- {/* These links do not preserve current URL queries. E.g. if ?page=2, that'll be removed once one of these links is clicked */} - - - -
- ); -} - function SetupInstruction() { return (
@@ -80,68 +40,17 @@ function SetupInstruction() { ); } +// deno-lint-ignore require-await export default async function HomePage( _req: Request, ctx: RouteContext, ) { - const pageNum = calcPageNum(ctx.url); - const timeAgo = calcTimeAgoFilter(ctx.url); - let allItems: Item[]; - if (timeAgo === "week" || timeAgo === null) { - allItems = await getItemsSince(WEEK); - } else if (timeAgo === "month") { - allItems = await getItemsSince(30 * DAY); - } else { - allItems = await getAllItems(); - } - - const items = allItems - .toSorted(compareScore) - .slice((pageNum - 1) * PAGE_LENGTH, pageNum * PAGE_LENGTH); - - const areVoted = await getAreVotedBySessionId( - items, - ctx.state.sessionId, - ); - const lastPage = calcLastPage(allItems.length, PAGE_LENGTH); - return ( <>
{NEEDS_SETUP && } - - {items.length === 0 && ( - <> -
-
- -

No items found

-
- - - Submit your project - -
- - )} - - {items.map((item, index) => ( - - ))} - {lastPage > 1 && ( - - )} +
); diff --git a/routes/users/[login].tsx b/routes/users/[login].tsx index 8cebc0a47..4600315fe 100644 --- a/routes/users/[login].tsx +++ b/routes/users/[login].tsx @@ -1,23 +1,15 @@ // Copyright 2023 the Deno authors. All rights reserved. MIT license. import type { RouteContext } from "$fresh/server.ts"; import type { State } from "@/routes/_middleware.ts"; -import ItemSummary from "@/components/ItemSummary.tsx"; -import { calcLastPage, calcPageNum, PAGE_LENGTH } from "@/utils/pagination.ts"; -import PageSelector from "@/components/PageSelector.tsx"; -import { - compareScore, - getAreVotedBySessionId, - getItemsByUser, - getUser, -} from "@/utils/db.ts"; -import { pluralize } from "@/utils/display.ts"; +import { getUser } from "@/utils/db.ts"; import IconBrandGithub from "tabler_icons_tsx/brand-github.tsx"; import { LINK_STYLES } from "@/utils/constants.ts"; import Head from "@/components/Head.tsx"; import GitHubAvatarImg from "@/components/GitHubAvatarImg.tsx"; +import ItemsList from "@/islands/ItemsList.tsx"; function Profile( - props: { login: string; itemsCount: number; isSubscribed: boolean }, + props: { login: string; isSubscribed: boolean }, ) { return (
@@ -41,9 +33,6 @@ function Profile(
-

- {pluralize(props.itemsCount, "submission")} -

); @@ -57,23 +46,6 @@ export default async function UsersUserPage( const user = await getUser(login); if (user === null) return await ctx.renderNotFound(); - const pageNum = calcPageNum(ctx.url); - - const allItems = await getItemsByUser(login); - const itemsCount = allItems.length; - - const items = allItems.sort(compareScore).slice( - (pageNum - 1) * PAGE_LENGTH, - pageNum * PAGE_LENGTH, - ); - - const areVoted = await getAreVotedBySessionId( - items, - ctx.state.sessionId, - ); - - const lastPage = calcLastPage(allItems.length, PAGE_LENGTH); - return ( <> @@ -81,20 +53,8 @@ export default async function UsersUserPage( - {items.map((item, index) => ( - - ))} - {lastPage > 1 && ( - - )} + ); diff --git a/tools/migrate_kv.ts b/tools/migrate_kv.ts index f29e1a8e2..429a1acdd 100644 --- a/tools/migrate_kv.ts +++ b/tools/migrate_kv.ts @@ -1,10 +1,25 @@ // Copyright 2023 the Deno authors. All rights reserved. MIT license. -import { kv } from "@/utils/db.ts"; +import { createVote, type Item, kv } from "@/utils/db.ts"; +interface OldVote { + userLogin: string; + item: Item; + // The below property can be automatically generated upon vote creation + id: string; + createdAt: Date; +} + +/** @todo Remove previous vote data once this migration is complete */ export async function migrateKv() { const promises = []; - const iter = kv.list({ prefix: ["notifications_by_user"] }); - for await (const { key } of iter) promises.push(kv.delete(key)); + const iter = kv.list({ prefix: ["votes"] }); + for await (const { value } of iter) { + promises.push(createVote({ + itemId: value.item.id, + userLogin: value.userLogin, + createdAt: value.createdAt, + })); + } await Promise.all(promises); console.log("KV migration complete"); } diff --git a/utils/db.ts b/utils/db.ts index 380d7c99d..ea18a4ac4 100644 --- a/utils/db.ts +++ b/utils/db.ts @@ -21,16 +21,6 @@ async function getValue( return res.value; } -async function getValues( - selector: Deno.KvListSelector, - options?: Deno.KvListOptions, -) { - const values = []; - const iter = kv.list(selector, options); - for await (const entry of iter) values.push(entry.value); - return values; -} - /** * Gets many values from KV. Uses batched requests to get values in chunks of 10. */ @@ -150,10 +140,6 @@ export async function getItem(id: string) { return await getValue(["items", id]); } -export async function getItemsByUser(userLogin: string) { - return await getValues({ prefix: ["items_by_user", userLogin] }); -} - export function listItemsByUser( userLogin: string, options?: Deno.KvListOptions, @@ -165,36 +151,6 @@ export function listItemsByTime(options?: Deno.KvListOptions) { return kv.list({ prefix: ["items_by_time"] }, options); } -export async function getAllItems() { - return await getValues({ prefix: ["items"] }); -} - -/** - * Gets all items since a given number of milliseconds ago from KV. - * - * @example Since a week ago - * ```ts - * import { WEEK } from "std/datetime/constants.ts"; - * import { getItemsSince } from "@/utils/db.ts"; - * - * const itemsSinceAllTime = await getItemsSince(WEEK); - * ``` - * - * @example Since a month ago - * ```ts - * import { DAY } from "std/datetime/constants.ts"; - * import { getItemsSince } from "@/utils/db.ts"; - * - * const itemsSinceAllTime = await getItemsSince(DAY * 30); - * ``` - */ -export async function getItemsSince(msAgo: number) { - return await getValues({ - prefix: ["items_by_time"], - start: ["items_by_time", Date.now() - msAgo], - }); -} - // Notification export interface Notification { userLogin: string; @@ -349,103 +305,110 @@ export function listCommentsByItem( // Vote export interface Vote { + itemId: string; userLogin: string; - item: Item; // The below property can be automatically generated upon vote creation - id: string; createdAt: Date; } -export function newVoteProps(): Pick { +export function newVoteProps(): Pick { return { - id: crypto.randomUUID(), createdAt: new Date(), }; } export async function createVote(vote: Vote) { - vote.item.score++; - - const itemKey = ["items", vote.item.id]; - const itemsByTimeKey = [ - "items_by_time", - vote.item.createdAt.getTime(), - vote.item.id, + const itemKey = ["items", vote.itemId]; + const userKey = ["users", vote.userLogin]; + const [itemRes, userRes] = await kv.getMany<[Item, User]>([itemKey, userKey]); + const item = itemRes.value; + const user = userRes.value; + if (item === null) throw new Deno.errors.NotFound("Item not found"); + if (user === null) throw new Deno.errors.NotFound("User not found"); + + const itemVotedByUserKey = [ + "items_voted_by_user", + vote.userLogin, + vote.itemId, ]; - const itemsByUserKey = ["items_by_user", vote.item.userLogin, vote.item.id]; - const [itemRes, itemsByTimeRes, itemsByUserRes] = await kv.getMany([ - itemKey, - itemsByTimeKey, - itemsByUserKey, - ]); - assertIsEntry(itemRes); - assertIsEntry(itemsByTimeRes); - assertIsEntry(itemsByUserRes); - - const votesKey = ["votes", vote.id]; - const votesByItemKey = ["votes_by_item", vote.item.id, vote.id]; - const votesByUserKey = ["votes_by_user", vote.userLogin, vote.id]; + const userVotedForItemKey = [ + "users_voted_for_item", + vote.itemId, + vote.userLogin, + ]; + const itemByTimeKey = ["items_by_time", item.createdAt.getTime(), item.id]; + const itemByUserKey = ["items_by_user", item.userLogin, item.id]; const votesCountKey = ["votes_count", formatDate(vote.createdAt)]; + item.score++; + const res = await kv.atomic() .check(itemRes) - .check(itemsByTimeRes) - .check(itemsByUserRes) - .check({ key: votesKey, versionstamp: null }) - .check({ key: votesByItemKey, versionstamp: null }) - .check({ key: votesByUserKey, versionstamp: null }) - .set(itemKey, vote.item) - .set(itemsByTimeKey, vote.item) - .set(itemsByUserKey, vote.item) - .set(votesKey, vote) - .set(votesByItemKey, vote) - .set(votesByUserKey, vote) + .check(userRes) + /** @todo(iuioiua) Enable these checks once the migration is complete */ + // .check({ key: itemVotedByUserKey, versionstamp: null }) + // .check({ key: userVotedForItemKey, versionstamp: null }) + .set(itemKey, item) + .set(itemByTimeKey, item) + .set(itemByUserKey, item) + .set(itemVotedByUserKey, item) + .set(userVotedForItemKey, user) .sum(votesCountKey, 1n) .commit(); - if (!res.ok) throw new Error(`Failed to set vote: ${vote}`); + if (!res.ok) throw new Error("Failed to set vote", { cause: vote }); } -export async function deleteVote(vote: Vote) { - vote.item.score--; - - const itemKey = ["items", vote.item.id]; - const itemsByTimeKey = [ - "items_by_time", - vote.item.createdAt.getTime(), - vote.item.id, +export async function deleteVote(vote: Omit) { + const itemKey = ["items", vote.itemId]; + const userKey = ["users", vote.userLogin]; + const itemVotedByUserKey = [ + "items_voted_by_user", + vote.userLogin, + vote.itemId, ]; - const itemsByUserKey = ["items_by_user", vote.item.userLogin, vote.item.id]; - const [itemRes, itemsByTimeRes, itemsByUserRes] = await kv.getMany([ - itemKey, - itemsByTimeKey, - itemsByUserKey, - ]); - assertIsEntry(itemRes); - assertIsEntry(itemsByTimeRes); - assertIsEntry(itemsByUserRes); + const userVotedForItemKey = [ + "users_voted_for_item", + vote.itemId, + vote.userLogin, + ]; + const [itemRes, userRes, itemVotedByUserRes, userVotedForItemRes] = await kv + .getMany< + [Item, User, Item, User] + >([itemKey, userKey, itemVotedByUserKey, userVotedForItemKey]); + const item = itemRes.value; + const user = userRes.value; + if (item === null) throw new Deno.errors.NotFound("Item not found"); + if (user === null) throw new Deno.errors.NotFound("User not found"); + if (itemVotedByUserRes.value === null) { + throw new Deno.errors.NotFound("Item voted by user not found"); + } + if (userVotedForItemRes.value === null) { + throw new Deno.errors.NotFound("User voted for item not found"); + } + + const itemByTimeKey = ["items_by_time", item.createdAt.getTime(), item.id]; + const itemByUserKey = ["items_by_user", item.userLogin, item.id]; - const votesKey = ["votes", vote.id]; - const votesByItemKey = ["votes_by_item", vote.item.id, vote.id]; - const votesByUserKey = ["votes_by_user", vote.userLogin, vote.id]; + item.score--; const res = await kv.atomic() .check(itemRes) - .check(itemsByTimeRes) - .check(itemsByUserRes) - .set(itemKey, vote.item) - .set(itemsByTimeKey, vote.item) - .set(itemsByUserKey, vote.item) - .delete(votesKey) - .delete(votesByItemKey) - .delete(votesByUserKey) + .check(userRes) + .check(itemVotedByUserRes) + .check(userVotedForItemRes) + .set(itemKey, item) + .set(itemByTimeKey, item) + .set(itemByUserKey, item) + .delete(itemVotedByUserKey) + .delete(userVotedForItemKey) .commit(); - if (!res.ok) throw new Error(`Failed to delete vote: ${vote}`); + if (!res.ok) throw new Error("Failed to delete vote"); } -export async function getVotesByUser(userLogin: string) { - return await getValues({ prefix: ["votes_by_user", userLogin] }); +export function listItemsVotedByUser(userLogin: string) { + return kv.list({ prefix: ["items_voted_by_user", userLogin] }); } // User @@ -553,10 +516,6 @@ export async function getUserByStripeCustomer(stripeCustomerId: string) { ]); } -export async function getUsers() { - return await getValues({ prefix: ["users"] }); -} - export function listUsers(options?: Deno.KvListOptions) { return kv.list({ prefix: ["users"] }, options); } @@ -566,11 +525,11 @@ export async function getAreVotedBySessionId( sessionId?: string, ) { if (!sessionId) return []; - const sessionUser = await getUserBySession(sessionId); - if (!sessionUser) return []; - const votes = await getVotesByUser(sessionUser.login); - const votesItemsIds = votes.map((vote) => vote.item.id); - return items.map((item) => votesItemsIds.includes(item.id)); + const user = await getUserBySession(sessionId); + if (!user) return []; + const votedItems = await collectValues(listItemsVotedByUser(user.login)); + const votedItemsIds = votedItems.map((item) => item.id); + return items.map((item) => votedItemsIds.includes(item.id)); } export function compareScore(a: Item, b: Item) { diff --git a/utils/db_test.ts b/utils/db_test.ts index c573d9c7d..4f560ab26 100644 --- a/utils/db_test.ts +++ b/utils/db_test.ts @@ -14,22 +14,21 @@ import { deleteUserBySession, deleteVote, formatDate, - getAllItems, getAreVotedBySessionId, getDatesSince, getItem, - getItemsByUser, - getItemsSince, getManyMetrics, getNotification, getUser, getUserBySession, getUserByStripeCustomer, - getVotesByUser, ifUserHasNotifications, incrVisitsCountByDay, type Item, listCommentsByItem, + listItemsByTime, + listItemsByUser, + listItemsVotedByUser, listNotificationsByUser, newCommentProps, newItemProps, @@ -96,11 +95,11 @@ Deno.test("[db] getAllItems()", async () => { const item1 = genNewItem(); const item2 = genNewItem(); - assertEquals(await getAllItems(), []); + assertEquals(await collectValues(listItemsByTime()), []); await createItem(item1); await createItem(item2); - assertArrayIncludes(await getAllItems(), [item1, item2]); + assertArrayIncludes(await collectValues(listItemsByTime()), [item1, item2]); }); Deno.test("[db] (get/create/delete)Item()", async () => { @@ -124,25 +123,14 @@ Deno.test("[db] getItemsByUser()", async () => { const item1 = { ...genNewItem(), userLogin }; const item2 = { ...genNewItem(), userLogin }; - assertEquals(await getItemsByUser(userLogin), []); + assertEquals(await collectValues(listItemsByUser(userLogin)), []); await createItem(item1); await createItem(item2); - assertArrayIncludes(await getItemsByUser(userLogin), [item1, item2]); -}); - -Deno.test("[db] getItemsSince()", async () => { - const item1 = genNewItem(); - const item2 = { - ...genNewItem(), - createdAt: new Date(Date.now() - (2 * DAY)), - }; - - await createItem(item1); - await createItem(item2); - - assertArrayIncludes(await getItemsSince(DAY), [item1]); - assertArrayIncludes(await getItemsSince(3 * DAY), [item1, item2]); + assertArrayIncludes(await collectValues(listItemsByUser(userLogin)), [ + item1, + item2, + ]); }); Deno.test("[db] user", async () => { @@ -211,26 +199,28 @@ Deno.test("[db] votes", async () => { const item = genNewItem(); const user = genNewUser(); const vote = { - item, + itemId: item.id, userLogin: user.login, ...newVoteProps(), }; const dates = [vote.createdAt]; assertEquals(await getManyMetrics("votes_count", dates), [0n]); - assertEquals(await getVotesByUser(vote.userLogin), []); + assertEquals(await collectValues(listItemsVotedByUser(user.login)), []); - await assertRejects(async () => await createVote(vote)); + // await assertRejects(async () => await createVote(vote)); await createItem(item); await createUser(user); await createVote(vote); + item.score++; + assertEquals(await getManyMetrics("votes_count", dates), [1n]); - assertEquals(await getVotesByUser(vote.userLogin), [vote]); - await assertRejects(async () => await createVote(vote)); + assertEquals(await collectValues(listItemsVotedByUser(user.login)), [item]); + // await assertRejects(async () => await createVote(vote)); await deleteVote(vote); assertEquals(await getManyMetrics("votes_count", dates), [1n]); - assertEquals(await getVotesByUser(vote.userLogin), []); + assertEquals(await collectValues(listItemsVotedByUser(user.login)), []); }); Deno.test("[db] getManyMetrics()", async () => { @@ -321,16 +311,13 @@ Deno.test("[db] compareScore()", () => { }); Deno.test("[db] getAreVotedBySessionId()", async () => { - const item: Item = { - userLogin: crypto.randomUUID(), - title: crypto.randomUUID(), - url: `http://${crypto.randomUUID()}.com`, - ...newItemProps(), - score: 1, - }; - + const item = genNewItem(); const user = genNewUser(); - const vote = { ...newVoteProps(), userLogin: user.login, item }; + const vote = { + itemId: item.id, + userLogin: user.login, + ...newVoteProps(), + }; assertEquals(await getUserBySession(user.sessionId), null); assertEquals(await getItem(item.id), null); @@ -348,6 +335,7 @@ Deno.test("[db] getAreVotedBySessionId()", async () => { await createItem(item); await createUser(user); await createVote(vote); + item.score++; assertEquals(await getItem(item.id), item); assertEquals(await getUserBySession(user.sessionId), user); diff --git a/utils/http.ts b/utils/http.ts new file mode 100644 index 000000000..fb5c863b5 --- /dev/null +++ b/utils/http.ts @@ -0,0 +1,8 @@ +// Copyright 2023 the Deno authors. All rights reserved. MIT license. +export async function fetchValues(endpoint: string, cursor: string) { + let url = endpoint; + if (cursor !== "") url += "?cursor=" + cursor; + const resp = await fetch(url); + if (!resp.ok) throw new Error(`Request failed: GET ${url}`); + return await resp.json() as { values: T[]; cursor: string }; +} diff --git a/utils/pagination.ts b/utils/pagination.ts index 90e175553..96a348db2 100644 --- a/utils/pagination.ts +++ b/utils/pagination.ts @@ -1,16 +1,5 @@ // Copyright 2023 the Deno authors. All rights reserved. MIT license. -// This should not exceed 10 since denoKV Kv.getMany can't handle as input an array with more than 10 elements -export const PAGE_LENGTH = 10; - -export function calcPageNum(url: URL) { - return parseInt(url.searchParams.get("page") || "1"); -} - -export function calcLastPage(total = 0, pageLength = PAGE_LENGTH): number { - return Math.ceil(total / pageLength); -} - export function getCursor(url: URL) { return url.searchParams.get("cursor") ?? ""; } diff --git a/utils/pagination_test.ts b/utils/pagination_test.ts index e539a5b95..b767fe3cd 100644 --- a/utils/pagination_test.ts +++ b/utils/pagination_test.ts @@ -1,23 +1,8 @@ // Copyright 2023 the Deno authors. All rights reserved. MIT license. -import { calcLastPage, calcPageNum } from "./pagination.ts"; import { assertEquals } from "std/testing/asserts.ts"; +import { getCursor } from "./pagination.ts"; -Deno.test("[pagination] calcPageNum()", () => { - assertEquals(calcPageNum(new URL("https://saaskit.deno.dev/")), 1); - assertEquals(calcPageNum(new URL("https://saaskit.deno.dev/?page=2")), 2); - assertEquals( - calcPageNum(new URL("https://saaskit.deno.dev/?time-ago=month")), - 1, - ); - assertEquals( - calcPageNum(new URL("https://saaskit.deno.dev/?time-ago=month&page=3")), - 3, - ); -}); - -Deno.test("[pagination] calcLastPage()", () => { - assertEquals(calcLastPage(1, 10), 1); - assertEquals(calcLastPage(15, 10), 2); - assertEquals(calcLastPage(11, 20), 1); - assertEquals(calcLastPage(50, 20), 3); +Deno.test("[pagaintion] getCursor()", () => { + assertEquals(getCursor(new URL("http://example.com")), ""); + assertEquals(getCursor(new URL("http://example.com?cursor=here")), "here"); });