Skip to content

Commit

Permalink
feat(client): implement forum answer upvote downvote mechanism and in…
Browse files Browse the repository at this point in the history
…tegration
  • Loading branch information
umitcan07 committed Nov 20, 2024
1 parent 03801b3 commit 3b3710f
Show file tree
Hide file tree
Showing 9 changed files with 277 additions and 26 deletions.
6 changes: 2 additions & 4 deletions backend/core/serializers/serializers.py
Original file line number Diff line number Diff line change
Expand Up @@ -57,8 +57,8 @@ class ForumAnswerSerializer(serializers.ModelSerializer):
is_downvoted = serializers.SerializerMethodField()
class Meta:
model = ForumAnswer
fields = ('id', 'answer', 'author', 'created_at', 'is_my_answer', 'is_upvoted', 'is_downvoted', 'upvotes_count', 'downvotes_count')
read_only_fields = ('author', 'created_at', 'upvotes_count', 'downvotes_count', 'is_my_answer', 'is_upvoted', 'is_downvoted')
fields = ('id', 'answer', 'author', 'created_at', 'is_my_answer', 'is_upvoted', 'is_downvoted', 'upvotes_count', 'downvotes_count', 'forum_question')
read_only_fields = ('author', 'created_at', 'upvotes_count', 'downvotes_count', 'is_my_answer', 'is_upvoted', 'is_downvoted', 'forum_question')

def get_is_my_answer(self, obj):
user = self.context['request'].user
Expand All @@ -85,8 +85,6 @@ def get_upvotes_count(self, obj):

def get_downvotes_count(self, obj):
return obj.downvotes.count()



def create(self, validated_data):
return ForumAnswer.objects.create(**validated_data)
Expand Down
77 changes: 62 additions & 15 deletions client/src/components/forum-answer-card.tsx
Original file line number Diff line number Diff line change
@@ -1,15 +1,23 @@
import { Button, Separator } from "@ariakit/react";
import { RiArrowDownLine, RiArrowUpLine } from "@remixicon/react";
import { Answer } from "../types/forum";
import { getRelativeTime } from "../utils";
import { useFetcher } from "react-router-dom";
import {
downvoteForumAnswerAction,
upvoteForumAnswerAction,
} from "../routes/Forum/Forum.data";
import { Answer } from "../routes/Forum/Forum.schema";
import { getNumberDifference, getRelativeTime } from "../utils";
import { Avatar } from "./avatar";
import { toggleButtonClass } from "./button";

type ForumAnswerCardProps = {
answer: Answer;
key: string;
};

export const ForumAnswerCard = ({ answer, key }: ForumAnswerCardProps) => {
const upvoteFetcher = useFetcher<typeof upvoteForumAnswerAction>();
const downvoteFetcher = useFetcher<typeof downvoteForumAnswerAction>();
return (
<div
key={key}
Expand Down Expand Up @@ -40,23 +48,62 @@ export const ForumAnswerCard = ({ answer, key }: ForumAnswerCardProps) => {
<Separator className="w-full border-slate-200" />
<div className="flex w-full flex-row justify-end">
<div className="flex flex-row items-center gap-2">
<Button
aria-label="Upvote"
className="flex size-8 items-center justify-center rounded-2 bg-slate-100"
<upvoteFetcher.Form
method="POST"
action={`/forum/${answer.forum_question}/upvoteAnswer`}
>
<RiArrowUpLine />
</Button>
<input
type="hidden"
name="answer_id"
value={answer.id}
/>
<input
type="hidden"
name="is_upvoted"
value={answer.is_upvoted || 0}
/>
<Button
type="submit"
aria-label="Upvote"
className={toggleButtonClass({
intent: "upvote",
state: answer.is_upvoted ? "on" : "off",
})}
>
<RiArrowUpLine size={16} />
</Button>
</upvoteFetcher.Form>
<p className="w-6 text-center text-sm text-slate-900">
{answer.upvotes_count && answer.downvotes_count
? answer.upvotes_count - answer.downvotes_count
: 0}
{getNumberDifference(
answer.upvotes_count,
answer.downvotes_count,
)}
</p>
<Button
aria-label="Downvote"
className="flex size-8 items-center justify-center rounded-2 border border-slate-200"
<upvoteFetcher.Form
method="POST"
action={`/forum/${answer.forum_question}/downvoteAnswer`}
>
<RiArrowDownLine />
</Button>
<input
type="hidden"
name="answer_id"
value={answer.id}
/>
<input
type="hidden"
name="is_downvoted"
value={answer.is_downvoted || 0}
/>
<Button
type="submit"
aria-label="Upvote"
className={toggleButtonClass({
intent: "downvote",
state: answer.is_downvoted ? "on" : "off",
})}
>
<RiArrowDownLine size={16} />
</Button>
</upvoteFetcher.Form>
</div>
</div>
</div>
Expand Down
9 changes: 6 additions & 3 deletions client/src/components/forum-card.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,14 +11,14 @@ import {
upvoteForumAction,
} from "../routes/Forum/Forum.data";
import { ForumQuestion } from "../routes/Forum/Forum.schema";
import { pluralize } from "../utils";
import { getNumberDifference, pluralize } from "../utils";
import { Avatar } from "./avatar";
import { toggleButtonClass } from "./button";
type ForumCardProps = {
question: ForumQuestion;
};

export const ForumCard = ({ question }: ForumCardProps) => {
export const ForumQuestionCard = ({ question }: ForumCardProps) => {
const upvoteFetcher = useFetcher<typeof upvoteForumAction>();
const downvoteFetcher = useFetcher<typeof downvoteForumAction>();
const bookmarkFetcher = useFetcher<typeof bookmarkForumAction>();
Expand Down Expand Up @@ -117,7 +117,10 @@ export const ForumCard = ({ question }: ForumCardProps) => {
</Button>
</upvoteFetcher.Form>
<p className="text-slate- w-6 text-center text-sm">
{question.upvotes_count - question.downvotes_count}
{getNumberDifference(
question.upvotes_count,
question.downvotes_count,
)}
</p>
<downvoteFetcher.Form
method="POST"
Expand Down
10 changes: 10 additions & 0 deletions client/src/router.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,8 +17,10 @@ import {
answerForumAction,
bookmarkForumAction,
downvoteForumAction,
downvoteForumAnswerAction,
forumLoader,
upvoteForumAction,
upvoteForumAnswerAction,
} from "./routes/Forum/Forum.data";
import { forumQuestionLoader } from "./routes/Forum/Question.data";
import { homeLoader } from "./routes/Home/Home.data";
Expand Down Expand Up @@ -83,6 +85,14 @@ export const routes: RouteObject[] = [
path: "answer",
action: answerForumAction,
},
{
path: "upvoteAnswer",
action: upvoteForumAnswerAction,
},
{
path: "downvoteAnswer",
action: downvoteForumAnswerAction,
},
],
},
{
Expand Down
167 changes: 167 additions & 0 deletions client/src/routes/Forum/Forum.data.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,9 @@ import { USER } from "../../constants";
import { useToastStore } from "../../store";
import { logger } from "../../utils";
import {
forumAnswerDownvoteSchema,
forumAnswerSchema,
forumAnswerUpvoteSchema,
forumBookmarkSchema,
forumDownvoteSchema,
forumSchema,
Expand Down Expand Up @@ -240,3 +242,168 @@ export const answerForumAction = (async ({ request, params }) => {
throw new Error("Failed to process answer action");
}
}) satisfies ActionFunction;

export const upvoteForumAnswerAction = (async ({ request }) => {
console.log("Processing upvote/downvote action...");
const user = sessionStorage.getObject(USER) || localStorage.getObject(USER);

if (!user) {
useToastStore.getState().add({
id: "not-logged-in",
type: "info",
data: {
message: "Log in to vote",
description: "You need to log in to vote on answers.",
},
});
return redirect("/login");
}

const formData = await request.formData();
const answerId = formData.get("answer_id");
const isUpvoted = formData.get("is_upvoted");

if (!answerId) {
throw new Error("Answer ID is required to process vote action.");
}

try {
let response;

if (Number(isUpvoted)) {
// DELETE request to remove upvote
response = await apiClient.delete(
`/forum-answer-upvote/${isUpvoted}/`,
);

useToastStore.getState().add({
id: `upvote-delete-success-${answerId}`,
type: "info",
data: {
message: "Upvote removed",
description: "Your upvote has been removed.",
},
});
} else {
// POST request to create upvote
response = await apiClient.post(`/forum-answer-upvote/`, {
forum_answer: Number(answerId),
});

const { issues, success } = safeParse(
forumAnswerUpvoteSchema,
response.data,
);

if (!success) {
logger.error("Response validation failed", issues);
throw new Error("Invalid response from upvote creation.");
}

useToastStore.getState().add({
id: `upvote-success-${answerId}`,
type: "success",
data: {
message: "Upvote created successfully",
description: "Your upvote has been posted.",
},
});
}

return response;
} catch (error) {
logger.error("Error in upvoteForumAnswerAction", error);
useToastStore.getState().add({
id: `vote-error-${answerId}`,
type: "error",
data: {
message: "Failed to process vote",
description: "Something went wrong while processing your vote.",
},
});
throw new Error("Failed to process vote action.");
}
}) satisfies ActionFunction;

export const downvoteForumAnswerAction = (async ({ request }) => {
console.log("Processing downvote action...");
const user = sessionStorage.getObject(USER) || localStorage.getObject(USER);

if (!user) {
useToastStore.getState().add({
id: "not-logged-in",
type: "info",
data: {
message: "Log in to vote",
description: "You need to log in to vote on answers.",
},
});
return redirect("/login");
}

const formData = await request.formData();
const answerId = formData.get("answer_id");
const isDownvoted = formData.get("is_downvoted");

if (!answerId) {
throw new Error("Answer ID is required to process downvote action.");
}

try {
let response;

if (Number(isDownvoted)) {
// DELETE request to remove downvote
response = await apiClient.delete(
`/forum-answer-downvote/${isDownvoted}/`,
);

useToastStore.getState().add({
id: `downvote-delete-success-${answerId}`,
type: "info",
data: {
message: "Downvote removed",
description: "Your downvote has been removed.",
},
});
} else {
// POST request to create downvote
response = await apiClient.post(`/forum-answer-downvote/`, {
forum_answer: Number(answerId),
});

const { issues, success } = safeParse(
forumAnswerDownvoteSchema,
response.data,
);

if (!success) {
logger.error("Response validation failed", issues);
throw new Error("Invalid response from downvote creation.");
}

useToastStore.getState().add({
id: `downvote-success-${answerId}`,
type: "success",
data: {
message: "Downvote created successfully",
description: "Your downvote has been recorded.",
},
});
}

return response;
} catch (error) {
logger.error("Error in downvoteForumAnswerAction", error);
useToastStore.getState().add({
id: `vote-error-${answerId}`,
type: "error",
data: {
message: "Failed to process vote",
description:
"Something went wrong while processing your downvote.",
},
});
throw new Error("Failed to process downvote action.");
}
}) satisfies ActionFunction;
15 changes: 15 additions & 0 deletions client/src/routes/Forum/Forum.schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ export const answerSchema = object({
is_downvoted: nullable(number()),
upvotes_count: nullable(number()),
downvotes_count: nullable(number()),
forum_question: nullable(number()),
});

export const forumQuestionSchema = object({
Expand Down Expand Up @@ -76,5 +77,19 @@ export const forumAnswerSchema = object({
answer: string(),
});

export const forumAnswerUpvoteSchema = object({
id: number(),
user: number(),
forum_answer: number(),
created_at: string(), // ISO date string
});

export const forumAnswerDownvoteSchema = object({
id: number(),
user: number(),
forum_answer: number(),
created_at: string(), // ISO date string
});

export type Tag = InferInput<typeof tagSchema>;
export type ForumQuestion = InferInput<typeof forumQuestionSchema>;
Loading

0 comments on commit 3b3710f

Please sign in to comment.