Skip to content

Commit 5a20d53

Browse files
xStrixUtypeofweb
andauthored
feat(app): add questions voting (#412)
* feat(app): add questions voting * feat(app): add questions voting filtration * refactor(app): refactor question voting * refactor(app): move QuestionVoting logic to hook Co-authored-by: Michał Miszczyszyn <[email protected]>
1 parent 9602215 commit 5a20d53

File tree

10 files changed

+105
-17
lines changed

10 files changed

+105
-17
lines changed

apps/api/modules/questions/questions.params.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { Prisma } from "@prisma/client";
2-
import { kv } from "../../utils";
2+
import { kv } from "../../utils.js";
33
import { GetQuestionsQuery } from "./questions.schemas";
44

55
export const getQuestionsPrismaParams = (

apps/app/src/app/(main-layout)/questions/[technology]/[page]/page.tsx

Lines changed: 8 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ import { DEFAULT_SORT_BY_QUERY, parseQuerySortBy } from "../../../../../lib/orde
77
import { parseQueryLevels } from "../../../../../lib/level";
88
import { technologies } from "../../../../../lib/technologies";
99
import { getAllQuestions } from "../../../../../services/questions.service";
10-
import { Params, SearchParams } from "../../../../../types";
10+
import { Params, QuestionFilter, SearchParams } from "../../../../../types";
1111

1212
export default async function QuestionsPage({
1313
params,
@@ -24,26 +24,28 @@ export default async function QuestionsPage({
2424
return redirect("/");
2525
}
2626

27-
const { data } = await getAllQuestions({
27+
const questionFilter: QuestionFilter = {
2828
category: params.technology,
2929
limit: PAGE_SIZE,
3030
offset: (page - 1) * PAGE_SIZE,
3131
orderBy: sortBy?.orderBy,
3232
order: sortBy?.order,
3333
level: levels?.join(","),
34-
});
34+
};
35+
36+
const { data } = await getAllQuestions(questionFilter);
3537

3638
return (
3739
<div className="flex flex-col gap-y-10">
3840
<QuestionsHeader technology={params.technology} total={data.meta.total} />
39-
{data.data.map(({ id, question, _levelId, acceptedAt, votesCount }) => (
41+
{data.data.map(({ id, question, _levelId, acceptedAt }) => (
4042
<QuestionItem
4143
key={id}
44+
id={id}
4245
title={question}
4346
level={_levelId}
4447
creationDate={new Date(acceptedAt || "")}
45-
votes={votesCount}
46-
voted={id % 2 === 0}
48+
questionFilter={questionFilter}
4749
/>
4850
))}
4951
<QuestionsPagination technology={params.technology} total={data.meta.total} />

apps/app/src/components/QuestionItem/QuestionItem.stories.ts

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,8 +7,6 @@ const meta: Meta<typeof QuestionItem> = {
77
args: {
88
title: "Lorem ipsum dolor sit amet, consectetur adipiscing elit.",
99
creationDate: new Date(),
10-
votes: 1,
11-
voted: true,
1210
},
1311
};
1412

apps/app/src/components/QuestionItem/QuestionItem.tsx

Lines changed: 11 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,20 +1,27 @@
11
import Link from "next/link";
22
import { format } from "../../utils/intl";
3+
import { QuestionFilter } from "../../types";
34
import { QuestionLevel } from "./QuestionLevel";
45
import { QuestionVoting } from "./QuestionVoting";
56
import type { Level } from "./QuestionLevel";
67

78
type QuestionItemProps = Readonly<{
9+
id: number;
810
title: string;
9-
votes: number;
10-
voted: boolean;
1111
level: Level;
1212
creationDate: Date;
13+
questionFilter: QuestionFilter;
1314
}>;
1415

15-
export const QuestionItem = ({ title, votes, voted, level, creationDate }: QuestionItemProps) => (
16+
export const QuestionItem = ({
17+
id,
18+
title,
19+
level,
20+
creationDate,
21+
questionFilter,
22+
}: QuestionItemProps) => (
1623
<article className="flex bg-white p-5 text-sm text-neutral-500 shadow-md dark:bg-white-dark dark:text-neutral-200">
17-
<QuestionVoting votes={votes} voted={voted} />
24+
<QuestionVoting questionId={id} questionFilter={questionFilter} />
1825
<h3 className="grow">{title}</h3>
1926
<div className="ml-4 flex min-w-max flex-col items-end">
2027
<QuestionLevel level={level} />

apps/app/src/components/QuestionItem/QuestionVoting.tsx

Lines changed: 29 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,39 @@
1+
"use client";
2+
13
import { twMerge } from "tailwind-merge";
4+
import { useDevFAQRouter } from "../../hooks/useDevFAQRouter";
25
import { pluralize } from "../../utils/intl";
6+
import { QuestionFilter } from "../../types";
7+
import { useQuestionVoting } from "../../hooks/useQuestionVoting";
8+
import { useGetQuestionVotes } from "../../hooks/useGetQuestionVotes";
39

410
type QuestionVotingProps = Readonly<{
5-
votes: number;
6-
voted: boolean;
11+
questionId: number;
12+
questionFilter: QuestionFilter;
713
}>;
814

915
const votesPluralize = pluralize("głos", "głosy", "głosów");
1016

11-
export const QuestionVoting = ({ votes, voted }: QuestionVotingProps) => {
17+
export const QuestionVoting = ({ questionId, questionFilter }: QuestionVotingProps) => {
18+
const { votes, voted, refetch } = useGetQuestionVotes({ questionId, questionFilter });
19+
const { upvote, downvote } = useQuestionVoting();
20+
const { requireLoggedIn } = useDevFAQRouter();
21+
22+
const handleClick = () => {
23+
const mutation = !voted ? upvote : downvote;
24+
25+
mutation.mutate(
26+
{
27+
id: questionId,
28+
},
29+
{
30+
onSuccess: () => {
31+
void refetch();
32+
},
33+
},
34+
);
35+
};
36+
1237
return (
1338
<button
1439
className={twMerge(
@@ -19,6 +44,7 @@ export const QuestionVoting = ({ votes, voted }: QuestionVotingProps) => {
1944
aria-label={`To pytanie ma ${votes} ${votesPluralize(votes)}. Kliknij, aby ${
2045
voted ? "usunąć" : "dodać"
2146
} swój głos.`}
47+
onClick={requireLoggedIn(handleClick)}
2248
>
2349
<svg
2450
xmlns="http://www.w3.org/2000/svg"

apps/app/src/hooks/useDevFAQRouter.ts

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,11 @@
11
import { usePathname, useSearchParams, useRouter } from "next/navigation";
2+
import { useUser } from "./useUser";
23

34
export const useDevFAQRouter = () => {
45
const router = useRouter();
56
const searchParams = useSearchParams();
67
const pathname = usePathname();
8+
const { userData } = useUser();
79

810
const mergeQueryParams = (data: Record<string, string>) => {
911
const params = { ...Object.fromEntries(searchParams.entries()), ...data };
@@ -14,5 +16,13 @@ export const useDevFAQRouter = () => {
1416
}
1517
};
1618

17-
return { mergeQueryParams };
19+
const requireLoggedIn = <T>(callback: (...args: T[]) => unknown) => {
20+
if (!userData) {
21+
return () => router.push(`/login?previousPath=${pathname || "/"}`);
22+
}
23+
24+
return callback;
25+
};
26+
27+
return { mergeQueryParams, requireLoggedIn };
1828
};
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
import { useQuery } from "@tanstack/react-query";
2+
import { getQuestionsVotes } from "../services/questions.service";
3+
import { QuestionFilter } from "../types";
4+
import { useUser } from "./useUser";
5+
6+
export const useGetQuestionVotes = ({
7+
questionId,
8+
questionFilter,
9+
}: {
10+
questionId: number;
11+
questionFilter: QuestionFilter;
12+
}) => {
13+
const { data, refetch } = useQuery({
14+
queryKey: ["votes", questionFilter],
15+
queryFn: () => getQuestionsVotes(questionFilter),
16+
});
17+
const { userData } = useUser();
18+
19+
const question = data?.data.data.find(({ id }) => id === questionId);
20+
const votes = question ? question.votesCount : 0;
21+
const voted = userData && question ? question.currentUserVotedOn : false;
22+
23+
return { votes, voted, refetch };
24+
};
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
import { useMutation } from "@tanstack/react-query";
2+
import { downvoteQuestion, upvoteQuestion } from "../services/questions.service";
3+
4+
export const useQuestionVoting = () => {
5+
const upvote = useMutation(upvoteQuestion);
6+
const downvote = useMutation(downvoteQuestion);
7+
8+
return { upvote, downvote };
9+
};
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,9 @@
11
import { fetcher } from "../lib/fetcher";
22

33
export const getAllQuestions = fetcher.path("/questions").method("get").create();
4+
5+
export const getQuestionsVotes = fetcher.path("/questions/votes").method("get").create();
6+
7+
export const upvoteQuestion = fetcher.path("/questions/{id}/votes").method("post").create();
8+
9+
export const downvoteQuestion = fetcher.path("/questions/{id}/votes").method("delete").create();

apps/app/src/types.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,14 @@
11
import { paths } from "openapi-types";
22

3+
type ExcludeUndefined<T> = Exclude<T, undefined>;
4+
35
export type UserData =
46
paths["/auth/me"]["get"]["responses"][200]["content"]["application/json"]["data"];
57

8+
export type QuestionFilter = ExcludeUndefined<
9+
ExcludeUndefined<paths["/questions"]["get"]["parameters"]>["query"]
10+
>;
11+
612
export type Params<T extends string> = {
713
readonly [K in T]: string;
814
};

0 commit comments

Comments
 (0)