diff --git a/js/src/__mock__/user/index.ts b/js/src/__mock__/user/index.ts index 21ba87412..b40386d92 100644 --- a/js/src/__mock__/user/index.ts +++ b/js/src/__mock__/user/index.ts @@ -1,4 +1,5 @@ import { ApiURL } from "@/lib/api/common/apiURL"; +import { QuestionDifficulty, Tag } from "@/lib/api/types/schema"; import { http, HttpResponse } from "msw"; const allUsersUrl = ApiURL.create("/api/user/all", { @@ -14,7 +15,14 @@ export const MOCK_USERS = [ nickname: null, admin: false, profileUrl: null, - tags: [], + tags: [ + { + id: "tag-1", + createdAt: "2024-01-01T00:00:00Z", + userId: "user-1", + tag: Tag.Patina, + }, + ], achievements: [], }, { @@ -86,3 +94,120 @@ export const getAllUsersErrorHandler = http.get( return HttpResponse.error(); }, ); + +const userProfileUrl = ApiURL.create("/api/user/{userId}/profile", { + method: "GET", + params: { + userId: MOCK_USERS[0].id, + }, +}); + +export const getUserProfileHandler = http.get( + userProfileUrl.url.toString(), + () => { + return HttpResponse.json({ + success: true, + message: "User profile loaded!", + payload: MOCK_USERS[0], + } satisfies ReturnType); + }, +); + +const userSubmissionsUrl = ApiURL.create("/api/user/{userId}/submissions", { + method: "GET", + params: { + userId: MOCK_USERS[0].id, + }, +}); + +export const MOCK_USER_SUBMISSIONS = [ + { + id: "submission-1", + userId: "user-1", + questionSlug: "two-sum", + questionNumber: 1, + questionTitle: "Two Sum", + questionLink: "https://leetcode.com/problems/two-sum", + questionDifficulty: QuestionDifficulty.Easy, + description: "

Given an array of integers, return indices.

", + acceptanceRate: 0.49, + pointsAwarded: 10, + code: "def twoSum(nums, target):\n pass", + runtime: "50ms", + memory: "16MB", + language: "python3", + createdAt: "2024-01-01T00:00:00Z", + submittedAt: "2024-01-01T00:00:00Z", + submissionId: null, + topics: [], + }, + { + id: "submission-2", + userId: "user-1", + questionSlug: "add-two-numbers", + questionNumber: 2, + questionTitle: "Add Two Numbers", + questionLink: "https://leetcode.com/problems/add-two-numbers", + questionDifficulty: QuestionDifficulty.Medium, + description: "

Add two numbers represented by linked lists.

", + acceptanceRate: 0.49, + pointsAwarded: 20, + code: "def addTwoNumbers(l1, l2):\n pass", + runtime: "50ms", + memory: "16MB", + language: "python3", + createdAt: "2024-01-02T00:00:00Z", + submittedAt: "2024-01-02T00:00:00Z", + submissionId: null, + topics: [], + }, +]; + +export const userSubmissionsHandler = http.get( + userSubmissionsUrl.url.toString(), + () => { + return HttpResponse.json({ + success: true, + message: "Submissions loaded!", + payload: { + hasNextPage: false, + pages: 1, + pageSize: 20, + items: MOCK_USER_SUBMISSIONS, + }, + } satisfies ReturnType); + }, +); + +export const userSubmissionsEmptyHandler = http.get( + userSubmissionsUrl.url.toString(), + () => { + return HttpResponse.json({ + success: true, + message: "Submissions loaded!", + payload: { + hasNextPage: false, + pages: 1, + pageSize: 20, + items: [], + }, + } satisfies ReturnType); + }, +); + +export const userSubmissionsFailedHandler = http.get( + userSubmissionsUrl.url.toString(), + () => { + return HttpResponse.json({ + success: false, + message: "Failed to load submissions", + } satisfies ReturnType); + }, +); + +export const userSubmissionsErrorHandler = http.get( + userSubmissionsUrl.url.toString(), + () => { + return HttpResponse.error(); + }, +); diff --git a/js/src/app/dashboard/Dashboard.page.test.tsx b/js/src/app/dashboard/Dashboard.page.test.tsx new file mode 100644 index 000000000..e69f7ea21 --- /dev/null +++ b/js/src/app/dashboard/Dashboard.page.test.tsx @@ -0,0 +1,111 @@ +import { + MOCK_USER_SUBMISSIONS, + userSubmissionsEmptyHandler, + userSubmissionsErrorHandler, + userSubmissionsFailedHandler, + userSubmissionsHandler, +} from "@/__mock__/user"; +import RecentSubmissions from "@/app/dashboard/_components/RecentSubmissions/RecentSubmissions"; +import { TestUtils, TestUtilTypes } from "@/lib/test"; +import { screen, waitFor } from "@testing-library/react"; +import { setupServer } from "msw/node"; + +describe("RecentSubmissions", () => { + const server = setupServer(userSubmissionsHandler); + + beforeAll(() => server.listen()); + afterEach(() => { + server.resetHandlers(); + }); + afterAll(() => server.close()); + + let renderProviderFn: TestUtilTypes.RenderWithAllProvidersFn | null = null; + beforeEach(() => { + renderProviderFn = TestUtils.getRenderWithAllProvidersFn(); + }); + + it("should render 'Submissions' heading after successful API call", async () => { + renderProviderFn?.(); + + await waitFor(() => { + expect(screen.getByText("Submissions")).toBeInTheDocument(); + }); + }); + + it("should render each submission title", async () => { + renderProviderFn?.(); + + await waitFor(() => { + for (const q of MOCK_USER_SUBMISSIONS) { + expect(screen.getByText(q.questionTitle)).toBeInTheDocument(); + } + }); + }); + + it("should link each submission to its submission page", async () => { + renderProviderFn?.(); + + const first = MOCK_USER_SUBMISSIONS[0]; + await waitFor(() => { + expect( + screen.getByText(first.questionTitle).closest("a"), + ).toHaveAttribute("href", `/submission/${first.id}`); + }); + }); + + it("should render difficulty and points for a submission", async () => { + renderProviderFn?.(); + + const first = MOCK_USER_SUBMISSIONS[0]; + await waitFor(() => { + expect( + screen.getByText(`+${first.pointsAwarded} points`), + ).toBeInTheDocument(); + }); + expect( + screen.getAllByText(first.questionDifficulty).length, + ).toBeGreaterThan(0); + }); + + it("should render 'View all' link to the user's profile", async () => { + renderProviderFn?.(); + + await waitFor(() => { + expect(screen.getByRole("link", { name: "View all" })).toHaveAttribute( + "href", + "/user/user-1", + ); + }); + }); + + it("should render empty message when user has no submissions", async () => { + server.use(userSubmissionsEmptyHandler); + renderProviderFn?.(); + + await waitFor(() => { + expect(screen.getByText(/no problems solved yet/i)).toBeInTheDocument(); + }); + }); + + it("should render failed message when API returns success false", async () => { + server.use(userSubmissionsFailedHandler); + renderProviderFn?.(); + + await waitFor(() => { + expect( + screen.getAllByText("Failed to load submissions").length, + ).toBeGreaterThan(0); + }); + }); + + it("should render error message when API call fails", async () => { + server.use(userSubmissionsErrorHandler); + renderProviderFn?.(); + + await waitFor(() => { + expect( + screen.getByText("Sorry, something went wrong. Please try again."), + ).toBeInTheDocument(); + }); + }); +}); diff --git a/js/src/app/user/[userId]/UserProfile.page.test.tsx b/js/src/app/user/[userId]/UserProfile.page.test.tsx index 3ceb6de35..b2feefdce 100644 --- a/js/src/app/user/[userId]/UserProfile.page.test.tsx +++ b/js/src/app/user/[userId]/UserProfile.page.test.tsx @@ -1,9 +1,17 @@ +import { getUserProfileHandler } from "@/__mock__/user"; import UserProfilePage from "@/app/user/[userId]/UserProfile.page"; import { TestUtils, TestUtilTypes } from "@/lib/test"; import { screen, waitFor } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; +import { setupServer } from "msw/node"; import { Route, Routes } from "react-router-dom"; +const mockNavigate = vi.fn(); +vi.mock("react-router-dom", async (importOriginal) => ({ + ...(await importOriginal()), + useNavigate: () => mockNavigate, +})); + class ResizeObserverMock { observe() {} unobserve() {} @@ -137,4 +145,34 @@ describe("UserProfilePage", () => { ); }); }); + + describe("should navigate to leaderboard when a tag is clicked", () => { + const server = setupServer(getUserProfileHandler); + + beforeAll(() => server.listen()); + afterEach(() => { + server.resetHandlers(); + }); + afterAll(() => server.close()); + + let renderProviderFn: TestUtilTypes.RenderWithAllProvidersFn | null = null; + beforeEach(() => { + renderProviderFn = TestUtils.getRenderWithAllProvidersFn(); + }); + + it("should navigate to leaderboard with tag query param", async () => { + const user = userEvent.setup(); + renderProviderFn?.(routes, "/user/user-1"); + + const tagBadge = await screen.findByRole("button", { + name: "Patina Network", + }); + + expect(tagBadge).toBeInTheDocument(); + + await user.click(tagBadge); + + expect(mockNavigate).toHaveBeenCalledWith("/leaderboard?patina=true"); + }); + }); }); diff --git a/js/src/app/user/[userId]/_components/UserProfile/UserTags/UserTags.tsx b/js/src/app/user/[userId]/_components/UserProfile/UserTags/UserTags.tsx index a926cbba8..006163f10 100644 --- a/js/src/app/user/[userId]/_components/UserProfile/UserTags/UserTags.tsx +++ b/js/src/app/user/[userId]/_components/UserProfile/UserTags/UserTags.tsx @@ -5,8 +5,10 @@ import Toast from "@/components/ui/toast/Toast"; import { useUserProfileQuery } from "@/lib/api/queries/user"; import { Group, Text, Stack } from "@mantine/core"; import { IconAlertTriangle } from "@tabler/icons-react"; +import { useNavigate } from "react-router-dom"; export default function UserTags({ userId }: { userId: string }) { + const navigate = useNavigate(); const { data, status } = useUserProfileQuery({ userId }); if (status === "pending") { @@ -34,7 +36,15 @@ export default function UserTags({ userId }: { userId: string }) { return ( - + + navigate(`/leaderboard?${userTag.tag.toLowerCase()}=true`) + } + /> ); diff --git a/js/src/components/ui/tags/TagList.tsx b/js/src/components/ui/tags/TagList.tsx index d317bed79..a71893c32 100644 --- a/js/src/components/ui/tags/TagList.tsx +++ b/js/src/components/ui/tags/TagList.tsx @@ -2,7 +2,14 @@ import AchievementCarousel from "@/components/ui/carousel/ItemCarousel"; import { Api } from "@/lib/api/types"; import { ApiUtils } from "@/lib/api/utils"; import { tagFF } from "@/lib/ff"; -import { Image, Tooltip, Stack, Divider, Text } from "@mantine/core"; +import { + Image, + Tooltip, + Stack, + Divider, + Text, + UnstyledButton, +} from "@mantine/core"; import { useMemo } from "react"; interface TagListProps { @@ -10,11 +17,13 @@ interface TagListProps { size?: number; gap?: string | number; expanded?: boolean; + onTagClick?: (userTag: Api<"UserTag">) => void; } interface TagBadgeProps { userTag: Api<"UserTag">; size: number; + onTagClick?: (userTag: Api<"UserTag">) => void; } export default function TagList({ @@ -22,14 +31,20 @@ export default function TagList({ size = 20, gap = "xs", expanded = false, -}: TagListProps) { + onTagClick, +}: Readonly) { const filteredTags = ApiUtils.filterUnusedTags(tags); const tagItems = useMemo( () => filteredTags.map((userTag) => ( - + )), - [filteredTags, size], + [filteredTags, size, onTagClick], ); if (!tagFF) return null; @@ -58,16 +73,27 @@ export default function TagList({ ); } -function TagBadge({ userTag, size }: TagBadgeProps) { +function TagBadge({ userTag, size, onTagClick }: Readonly) { const metadata = ApiUtils.getMetadataByTagEnum(userTag.tag); + const image = ( + {metadata.alt} + ); return ( - {metadata.alt} + {onTagClick ? + onTagClick(userTag)} + > + {image} + + : image} ); }