Skip to content

Commit

Permalink
フォームデータの解析関数を追加し、投票ボタンコンポーネントをクライアントサイドに移動しました
Browse files Browse the repository at this point in the history
  • Loading branch information
ttizze committed Feb 21, 2025
1 parent c3c1eff commit 3cd6396
Show file tree
Hide file tree
Showing 12 changed files with 442 additions and 210 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ import {
} from "@/components/ui/dropdown-menu";
import { EllipsisVertical } from "lucide-react";
import { useActionState } from "react";
import { VoteButtons } from "../vote-buttons";
import { VoteButtons } from "../vote-buttons/client";
import type { VoteTarget } from "../vote-buttons/constants";
import { deleteTranslationAction } from "./action";

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ import { NavigationLink } from "@/components/navigation-link";
import { Languages, Plus } from "lucide-react";
import { useState } from "react";
import { AddAndVoteTranslations } from "./add-and-vote-translations";
import { VoteButtons } from "./vote-buttons";
import { VoteButtons } from "./vote-buttons/client";

interface TranslationSectionProps {
segmentWithTranslations: SegmentWithTranslations;
Expand Down Expand Up @@ -51,17 +51,18 @@ export function TranslationSection({
</span>
{isSelected && (
<>
<span className="flex items-center justify-end">
<span className="flex items-center justify-end gap-2">
<NavigationLink
href={`/user/${bestSegmentTranslationWithVote?.segmentTranslation.user.handle}`}
className="!no-underline mr-2"
className="!no-underline"
>
<span className="text-sm text-gray-500 text-right flex justify-end items-center">
<span className="text-sm text-gray-500 text-right flex items-center">
by:{" "}
{bestSegmentTranslationWithVote?.segmentTranslation.user.name}
</span>
</NavigationLink>
<VoteButtons
key={bestSegmentTranslationWithVote.segmentTranslation.id}
translationWithVote={bestSegmentTranslationWithVote}
voteTarget={voteTarget}
/>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@

import type { ActionResponse } from "@/app/types";
import { getCurrentUser } from "@/auth";
import { parseFormData } from "@/lib/parse-formdata";
import { revalidatePath } from "next/cache";
import { redirect } from "next/navigation";
import { z } from "zod";
Expand Down Expand Up @@ -36,11 +37,7 @@ export async function voteTranslationAction(
if (!currentUser?.id) {
return redirect("/auth/login");
}
const parsedFormData = schema.safeParse({
segmentTranslationId: formData.get("segmentTranslationId"),
isUpvote: formData.get("isUpvote"),
voteTarget: formData.get("voteTarget"),
});
const parsedFormData = await parseFormData(schema, formData);
if (!parsedFormData.success) {
return {
success: false,
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,140 @@
import type { SegmentTranslationWithVote } from "@/app/[locale]/types";
import { render, screen } from "@testing-library/react";
// VoteButtons.test.tsx
import React from "react";
import { vi } from "vitest";
import { VoteButtons } from "./client";
import type { VoteTarget } from "./constants";
const dummyVoteTarget = "example-target" as VoteTarget;

const dummyTranslationUpvote = {
segmentTranslation: { id: 1, point: 10 },
translationVote: { isUpvote: true },
} as SegmentTranslationWithVote;

const dummyTranslationDownvote = {
segmentTranslation: { id: 2, point: 5 },
translationVote: { isUpvote: false },
} as SegmentTranslationWithVote;

vi.mock("next/form", () => ({
__esModule: true,
default: function Form({
children,
...props
}: { children: React.ReactNode }) {
return <form {...props}>{children}</form>;
},
}));

describe("VoteButtons コンポーネント", () => {
afterEach(() => {
vi.restoreAllMocks();
});

test("フォームと hidden input、アップ/ダウンボタンがレンダリングされる", () => {
// useActionState の戻り値をモック
vi.spyOn(React, "useActionState").mockReturnValue([
{ data: { isUpvote: true, point: 10 } },
vi.fn(),
false, // isVoting: false
]);

render(
<VoteButtons
translationWithVote={dummyTranslationUpvote}
voteTarget={dummyVoteTarget}
/>,
);

// hidden input (voteTarget) の検証
const voteTargetInput = screen.getByDisplayValue(dummyVoteTarget);
expect(voteTargetInput).toBeInTheDocument();

// hidden input (segmentTranslationId) の検証
const segmentTranslationIdInput = screen.getByDisplayValue(
dummyTranslationUpvote.segmentTranslation.id,
);
expect(segmentTranslationIdInput).toBeInTheDocument();

// VoteButton の data-testid を用いた検証
expect(screen.getByTestId("vote-up-button")).toBeInTheDocument();
expect(screen.getByTestId("vote-down-button")).toBeInTheDocument();
});

test("アップボタンが正しい投票数とアクティブ状態のアイコンクラスを表示する", () => {
vi.spyOn(React, "useActionState").mockReturnValue([
{ data: { isUpvote: true, point: 10 } },
vi.fn(),
false,
]);

render(
<VoteButtons
translationWithVote={dummyTranslationUpvote}
voteTarget={dummyVoteTarget}
/>,
);

const upvoteButton = screen.getByTestId("vote-up-button");
// upvote ボタンは voteCount (10) を表示する
expect(upvoteButton).toHaveTextContent("10");

// ThumbsUp アイコンがレンダリングされ、アクティブ状態のクラスが含まれている
const thumbsUpIcon = upvoteButton.querySelector("svg");
expect(thumbsUpIcon).toBeInTheDocument();
// アクティブの場合、"[&>path]:fill-primary" が付与される
expect(thumbsUpIcon?.getAttribute("class") || "").toContain(
"[&>path]:fill-primary",
);
});

test("ダウンボタンがアクティブの場合、適切なアイコンクラスが付与され、voteCount は表示されない", () => {
vi.spyOn(React, "useActionState").mockReturnValue([
{ data: { isUpvote: false, point: 5 } },
vi.fn(),
false,
]);

render(
<VoteButtons
translationWithVote={dummyTranslationDownvote}
voteTarget={dummyVoteTarget}
/>,
);

const downvoteButton = screen.getByTestId("vote-down-button");
expect(downvoteButton).toBeInTheDocument();

// downvote ボタンは voteCount を表示しない(upvote のみ表示される)
expect(downvoteButton).not.toHaveTextContent("5");

// ThumbsDown アイコンの active クラスの確認
const thumbsDownIcon = downvoteButton.querySelector("svg");
expect(thumbsDownIcon).toBeInTheDocument();
expect(thumbsDownIcon?.getAttribute("class") || "").toContain(
"[&>path]:fill-primary",
);
});

test("isVoting が true の場合、全てのボタンが disabled になる", () => {
vi.spyOn(React, "useActionState").mockReturnValue([
{ data: { isUpvote: true, point: 10 } },
vi.fn(),
true, // isVoting: true
]);

render(
<VoteButtons
translationWithVote={dummyTranslationUpvote}
voteTarget={dummyVoteTarget}
/>,
);

const upvoteButton = screen.getByTestId("vote-up-button");
const downvoteButton = screen.getByTestId("vote-down-button");

expect(upvoteButton.className).toContain("disabled:pointer-events-none");
expect(downvoteButton.className).toContain("disabled:pointer-events-none");
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
"use client";
import type { SegmentTranslationWithVote } from "@/app/[locale]/types";
import { ThumbsDown, ThumbsUp } from "lucide-react";
import Form from "next/form";
import { memo } from "react";
import { useActionState } from "react";
import {
type VoteTranslationActionResponse,
voteTranslationAction,
} from "./action";
import type { VoteTarget } from "./constants";
import { VoteButton } from "./vote-button";

interface VoteButtonsProps {
translationWithVote: SegmentTranslationWithVote;
voteTarget: VoteTarget;
}

export const VoteButtons = memo(function VoteButtons({
translationWithVote,
voteTarget,
}: VoteButtonsProps) {
const [voteState, voteAction, isVoting] = useActionState<
VoteTranslationActionResponse,
FormData
>(voteTranslationAction, {
success: false,
data: {
isUpvote: translationWithVote.translationVote?.isUpvote,
point: translationWithVote.segmentTranslation.point,
},
});
return (
<span className="flex h-full justify-end items-center">
<Form action={voteAction}>
<input type="hidden" name="voteTarget" value={voteTarget} />
<input
type="hidden"
name="segmentTranslationId"
value={translationWithVote.segmentTranslation.id}
/>
<span className="flex h-8">
<VoteButton
type="upvote"
isActive={voteState.data?.isUpvote === true}
isVoting={isVoting}
voteCount={voteState.data?.point}
>
{({ iconClass }) => <ThumbsUp className={iconClass} />}
</VoteButton>
<VoteButton
type="downvote"
isActive={voteState.data?.isUpvote === false}
isVoting={isVoting}
>
{({ iconClass }) => <ThumbsDown className={iconClass} />}
</VoteButton>
</span>
</Form>
</span>
);
});
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ export async function handleVote(
voteTarget: VoteTarget,
) {
let updatedPoint = 0;
let finalIsUpvote: boolean | null = isUpvote;
if (voteTarget === VOTE_TARGET.PAGE_SEGMENT_TRANSLATION) {
updatedPoint = await prisma.$transaction(async (tx) => {
const existingVote = await tx.vote.findUnique({
Expand All @@ -28,6 +29,7 @@ export async function handleVote(
where: { id: segmentTranslationId },
data: { point: { increment: isUpvote ? -1 : 1 } },
});
finalIsUpvote = null;
} else {
// 投票内容が異なる場合は更新して point を調整
await tx.vote.update({
Expand Down Expand Up @@ -80,6 +82,7 @@ export async function handleVote(
where: { id: segmentTranslationId },
data: { point: { increment: isUpvote ? -1 : 1 } },
});
finalIsUpvote = null;
} else {
await tx.pageCommentSegmentTranslationVote.update({
where: { id: existingVote.id },
Expand Down Expand Up @@ -112,7 +115,10 @@ export async function handleVote(
});
}

return { success: true, data: { isUpvote, point: updatedPoint } };
return {
success: true,
data: { isUpvote: finalIsUpvote, point: updatedPoint },
};
}

export async function createNotificationPageSegmentTranslationVote(
Expand Down

This file was deleted.

Loading

0 comments on commit 3cd6396

Please sign in to comment.