From 626e2951627ab7e96b49a97630aeabf69374c040 Mon Sep 17 00:00:00 2001 From: Tahmid Ahmed Date: Wed, 21 Oct 2026 10:39:09 -0400 Subject: [PATCH 1/2] DINO-1126: overhaul user submission details page to be a lot more visually pleasing --- .../SubmissionDetailsContent.module.css | 61 ++- .../SubmissionDetailsContent.test.tsx | 34 +- .../_components/SubmissionDetailsContent.tsx | 377 ++++++++++++------ .../SubmissionDetailsContentSkeleton.test.tsx | 9 - .../SubmissionDetailsContentSkeleton.tsx | 128 +++--- 5 files changed, 380 insertions(+), 229 deletions(-) diff --git a/js/src/app/submission/[submissionId]/_components/SubmissionDetailsContent.module.css b/js/src/app/submission/[submissionId]/_components/SubmissionDetailsContent.module.css index 6eb472a13..9c544ac7e 100644 --- a/js/src/app/submission/[submissionId]/_components/SubmissionDetailsContent.module.css +++ b/js/src/app/submission/[submissionId]/_components/SubmissionDetailsContent.module.css @@ -1,12 +1,65 @@ +.title { + font-size: clamp(1.375rem, 4vw, 2rem); +} + +/* Mantine reserves 24px (the control height) below the content for the + absolutely positioned toggle; add the toggle's top offset to that reservation. */ +.spoilerRoot[data-has-spoiler] { + margin-bottom: calc(24px + var(--mantine-spacing-md)); +} + +.spoilerControl { + margin-top: var(--mantine-spacing-md); +} + +.author { + display: inline-flex; + align-items: center; + gap: 6px; +} + +.stat { + padding: var(--mantine-spacing-sm) var(--mantine-spacing-md); + border: 1px solid var(--mantine-color-dark-4); + border-radius: var(--mantine-radius-md); + background-color: var(--mantine-color-dark-7); +} + .description { min-width: 0; + overflow-wrap: anywhere; + line-height: 1.6; +} + +.description img { + max-width: 100%; + height: auto; + border-radius: var(--mantine-radius-sm); +} + +.description code { + padding: 1px 5px; + border-radius: var(--mantine-radius-xs); + background-color: var(--mantine-color-dark-5); + font-size: 0.9em; } .description > pre { - background-color: rgba(51, 48, 48, 0.8); - border-radius: 1rem; - padding: 2rem; min-width: 0; + padding: var(--mantine-spacing-md); + border-left: 3px solid var(--mantine-color-patina-6); + border-radius: var(--mantine-radius-md); + background-color: var(--mantine-color-dark-8); white-space: pre-wrap; - word-wrap: break-word; + overflow-wrap: anywhere; +} + +.description > pre code { + padding: 0; + background-color: transparent; +} + +.code { + border-radius: var(--mantine-radius-md); + font-size: 0.875rem; } diff --git a/js/src/app/submission/[submissionId]/_components/SubmissionDetailsContent.test.tsx b/js/src/app/submission/[submissionId]/_components/SubmissionDetailsContent.test.tsx index 63c54af4e..5fd7d158d 100644 --- a/js/src/app/submission/[submissionId]/_components/SubmissionDetailsContent.test.tsx +++ b/js/src/app/submission/[submissionId]/_components/SubmissionDetailsContent.test.tsx @@ -8,7 +8,6 @@ import { import SubmissionDetailsContent from "@/app/submission/[submissionId]/_components/SubmissionDetailsContent"; 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"; describe("SubmissionDetailsContent API Succeeded", () => { @@ -51,7 +50,7 @@ describe("SubmissionDetailsContent API Succeeded", () => { ); await waitFor(() => { - const link = screen.getByRole("link"); + const link = screen.getByRole("link", { name: /open on leetcode/i }); expect(link).toBeInTheDocument(); expect(link).toHaveAttribute( "href", @@ -102,34 +101,17 @@ describe("SubmissionDetailsContent API Succeeded", () => { }); }); - it("should render go to profile button after successful API call", async () => { + it("should link author name to user profile after successful API call", async () => { renderProviderFn?.( , ); await waitFor(() => { - expect( - screen.getByRole("button", { name: /go to profile/i }), - ).toBeInTheDocument(); - }); - }); - - it("should navigate to user profile when go to profile button is clicked", async () => { - const user = userEvent.setup(); - renderProviderFn?.( - , - ); - - await waitFor(() => { - expect( - screen.queryByTestId("submission-skeleton-question-title"), - ).not.toBeInTheDocument(); - }); - - const profileButton = screen.getByRole("button", { - name: /go to profile/i, + expect(screen.getByRole("link", { name: "aphrodite" })).toHaveAttribute( + "href", + "/user/user-1", + ); }); - await user.click(profileButton); }); it("should render language after successful API call", async () => { @@ -148,7 +130,7 @@ describe("SubmissionDetailsContent API Succeeded", () => { ); await waitFor(() => { - expect(screen.getByText("Runtime: 50ms")).toBeInTheDocument(); + expect(screen.getByText("50ms")).toBeInTheDocument(); }); }); @@ -158,7 +140,7 @@ describe("SubmissionDetailsContent API Succeeded", () => { ); await waitFor(() => { - expect(screen.getByText("Memory: 16MB")).toBeInTheDocument(); + expect(screen.getByText("16MB")).toBeInTheDocument(); }); }); diff --git a/js/src/app/submission/[submissionId]/_components/SubmissionDetailsContent.tsx b/js/src/app/submission/[submissionId]/_components/SubmissionDetailsContent.tsx index d1d6d02f7..71836ef69 100644 --- a/js/src/app/submission/[submissionId]/_components/SubmissionDetailsContent.tsx +++ b/js/src/app/submission/[submissionId]/_components/SubmissionDetailsContent.tsx @@ -1,16 +1,46 @@ import classes from "@/app/submission/[submissionId]/_components/SubmissionDetailsContent.module.css"; import SubmissionDetailsContentSkeleton from "@/app/submission/[submissionId]/_components/SubmissionDetailsContentSkeleton"; import CodebloomCard from "@/components/ui/CodebloomCard"; +import { + langNameKey, + langNameToIcon, +} from "@/components/ui/langname-to-icon/LangNameToIcon"; import DocumentDescription from "@/components/ui/title/DocumentDescription"; import DocumentTitle from "@/components/ui/title/DocumentTitle"; import Toast from "@/components/ui/toast/Toast"; import ToastWithRedirect from "@/components/ui/toast/ToastWithRedirect"; import { useSubmissionDetailsQuery } from "@/lib/api/queries/submissions"; +import { ApiUtils } from "@/lib/api/utils"; import { capitalize } from "@/lib/helper/capitalize"; -import { Badge, Box, Button, Center, Flex, Text, Title } from "@mantine/core"; +import { theme } from "@/lib/theme"; +import { timeDiff } from "@/lib/timeDiff"; +import { + ActionIcon, + Anchor, + Badge, + Box, + Button, + CopyButton, + Flex, + Group, + SimpleGrid, + Spoiler, + Stack, + Text, + Title, + Tooltip, +} from "@mantine/core"; +import { + IconCheck, + IconCircleCheckFilled, + IconClock, + IconCopy, + IconDatabase, + IconExternalLink, + IconHourglassHigh, +} from "@tabler/icons-react"; +import { ReactNode } from "react"; import { FaDiscord } from "react-icons/fa"; -import { FiExternalLink } from "react-icons/fi"; -import { useNavigate } from "react-router-dom"; import { Link } from "react-router-dom"; import ShikiHighlighter from "react-shiki"; @@ -19,8 +49,6 @@ export default function SubmissionDetailsContent({ }: { submissionId: string; }) { - const navigate = useNavigate(); - const { data, status } = useSubmissionDetailsQuery({ submissionId }); if (status === "pending") { @@ -37,17 +65,21 @@ export default function SubmissionDetailsContent({ const { questionTitle, + questionNumber, questionLink, pointsAwarded, questionDifficulty, description, acceptanceRate, discordName, + nickname, code, runtime, memory, language, userId, + submittedAt, + topics, } = data.payload; const badgeAcceptedColor = (() => { @@ -58,10 +90,7 @@ export default function SubmissionDetailsContent({ if (ac >= 50) { return "yellow"; } - if (ac >= 30) { - return "red"; - } - return undefined; + return "red"; })(); const badgeDifficultyColor = (() => { @@ -84,132 +113,248 @@ export default function SubmissionDetailsContent({ const isProcessing = !code || !language || language === "Unknown" || !runtime || !memory; + const submittedDate = new Date(submittedAt); + const hasSubmittedDate = !Number.isNaN(submittedDate.getTime()); + const authorName = nickname ?? discordName ?? "Unknown user"; + + const stats: { label: string; value: ReactNode }[] = [ + { + label: "Difficulty", + value: {questionDifficulty}, + }, + { + label: "Acceptance", + value: ( + + {Math.round(acceptanceRate * 100)}% + + ), + }, + { + label: "Points", + value: ( + + {pointsAwarded ?? "—"} + + ), + }, + ]; + if (hasSubmittedDate) { + stats.push({ + label: "Submitted", + value: ( + + + {timeDiff(submittedDate)} + + + ), + }); + } + return ( <> - -
- - {questionTitle} - <Button - component={Link} - to={questionLink} - reloadDocument - target="_blank" - rel="noopener noreferrer" - mx={"sm"} - variant={"light"} - > - <FiExternalLink size={20} color="green" /> - </Button> - -
-
-
- - Solved by{" "} - <FaDiscord - style={{ - display: "inline", - color: "var(--mantine-color-blue-5)", - }} - />{" "} - <Title display={"inline"} c={"blue.5"} order={3}> - {discordName} - + + + + + + + + <Text span inherit c="dimmed"> + {questionNumber}. + </Text>{" "} + <span>{questionTitle}</span> + + + Solved by + + {nickname ? + + + + : } + {authorName} + + + + + + + {stats.map((stat) => ( + + + {stat.label} + + {stat.value} + + ))} + + {topics.length > 0 && ( + + {topics.map((topic) => ( + + { + ApiUtils.getTopicEnumMetadataByTopicEnum(topic.topic) + .name + } + + ))} + + )} + + + + + Problem -
-
-
- - Points: - - {pointsAwarded} -
-
-
-
- - Difficulty: - - - {questionDifficulty} - -
-
-
-
- - Acceptance Rate: - - - {Math.round(acceptanceRate * 100)}% - -
-
-
- -
- -
+
+ - + {isProcessing ? - - + <Stack align="center" gap="sm" py="xl"> + <IconHourglassHigh size={36} color={theme.colors.patina[4]} /> + <Title order={4} c="patina.4"> Data Currently Not Available - + This submission is still being processed.
Please check back later.
-
- : <> - - {capitalize(language ?? "Unknown")} - Runtime: {runtime ?? ""} - Memory: {memory ?? ""} - - - {code.trim() ?? "No code available."} - - + + : }
-
+ ); } + +function SolutionCode({ + code, + language, + parsedLanguage, + runtime, + memory, +}: { + code: string; + language: string; + parsedLanguage: string | undefined; + runtime: string; + memory: string; +}) { + const LanguageIcon = + langNameToIcon[language as langNameKey] ?? langNameToIcon.default; + + return ( + + + + + {capitalize(language)} + + + + + + Runtime + + + {runtime} + + + + + + Memory + + + {memory} + + + + {({ copied, copy }) => ( + + + {copied ? + + : } + + + )} + + + + + {code} + + + ); +} diff --git a/js/src/app/submission/[submissionId]/_components/SubmissionDetailsContentSkeleton.test.tsx b/js/src/app/submission/[submissionId]/_components/SubmissionDetailsContentSkeleton.test.tsx index 30f906e7c..a6186dfe1 100644 --- a/js/src/app/submission/[submissionId]/_components/SubmissionDetailsContentSkeleton.test.tsx +++ b/js/src/app/submission/[submissionId]/_components/SubmissionDetailsContentSkeleton.test.tsx @@ -50,15 +50,6 @@ describe("SubmissionDetailsContentSkeleton", () => { expect(info3).toBeVisible(); }); - it("should render user profile button skeleton", () => { - renderProviderFn?.(); - const element = screen.getByTestId( - "submission-skeleton-user-profile-button", - ); - expect(element).toBeInTheDocument(); - expect(element).toBeVisible(); - }); - it("should render question details skeleton", () => { renderProviderFn?.(); const element = screen.getByTestId("submission-skeleton-question-details"); diff --git a/js/src/app/submission/[submissionId]/_components/SubmissionDetailsContentSkeleton.tsx b/js/src/app/submission/[submissionId]/_components/SubmissionDetailsContentSkeleton.tsx index 22dfa5b04..d4eb5780e 100644 --- a/js/src/app/submission/[submissionId]/_components/SubmissionDetailsContentSkeleton.tsx +++ b/js/src/app/submission/[submissionId]/_components/SubmissionDetailsContentSkeleton.tsx @@ -1,95 +1,75 @@ import CodebloomCard from "@/components/ui/CodebloomCard"; -import { Box, Center, Flex, Skeleton } from "@mantine/core"; +import { Box, Flex, SimpleGrid, Skeleton, Stack } from "@mantine/core"; export default function SubmissionDetailsContentSkeleton() { return ( - -
- - - - -
- -
- - - -
-
- -
-
- -
-
- -
-
- -
+ + + + + + + + + + + + + + + + + + + - - - - - - - - - + + + + + - - - - + + + - +
); } From e68267b20fe660fa107348489baf0da839001343 Mon Sep 17 00:00:00 2001 From: Tahmid Ahmed Date: Wed, 21 Oct 2026 23:50:42 -0400 Subject: [PATCH 2/2] DINO-1126: add tests to please sonar --- js/src/__mock__/submissions/index.ts | 82 ++++++++++++++++++- .../SubmissionDetailsContent.module.css | 2 - .../SubmissionDetailsContent.test.tsx | 59 +++++++++++++ 3 files changed, 140 insertions(+), 3 deletions(-) diff --git a/js/src/__mock__/submissions/index.ts b/js/src/__mock__/submissions/index.ts index 4b1e5cdd8..89959c3e3 100644 --- a/js/src/__mock__/submissions/index.ts +++ b/js/src/__mock__/submissions/index.ts @@ -1,5 +1,9 @@ import { ApiURL } from "@/lib/api/common/apiURL"; -import { QuestionDifficulty } from "@/lib/api/types/schema"; +import { + components, + LeetcodeTopicEnum, + QuestionDifficulty, +} from "@/lib/api/types/schema"; import { http, HttpResponse } from "msw"; export const MOCK_SUBMISSION_ID = "submission-1"; @@ -80,6 +84,82 @@ export const submissionDetailsProcessingHandler = http.get( }, ); +const TWO_DAYS_MS = 2 * 24 * 60 * 60 * 1000; + +export const submissionDetailsVerifiedHandler = http.get( + submissionDetails.url.toString(), + () => { + return HttpResponse.json({ + success: true, + message: "Submission details loaded!", + payload: { + id: MOCK_SUBMISSION_ID, + userId: "user-1", + questionSlug: "two-sum", + questionNumber: 1, + questionTitle: "Two Sum", + questionLink: "https://leetcode.com/problems/two-sum", + questionDifficulty: QuestionDifficulty.Medium, + description: "

Given an array of integers, return indices.

", + acceptanceRate: 0.49, + pointsAwarded: 10, + discordName: "aphrodite", + nickname: "Aphrodite", + code: "class Solution {}", + runtime: "1ms", + memory: "44MB", + language: "java", + createdAt: "", + submittedAt: new Date(Date.now() - TWO_DAYS_MS).toISOString(), + submissionId: null, + topics: [ + { + id: "topic-1", + questionId: MOCK_SUBMISSION_ID, + topicSlug: "array", + topic: LeetcodeTopicEnum.ARRAY, + createdAt: "", + }, + ], + leetcodeUsername: null, + }, + } satisfies components["schemas"]["ApiResponderQuestionWithUserDto"]); + }, +); + +export const submissionDetailsSparseHandler = http.get( + submissionDetails.url.toString(), + () => { + return HttpResponse.json({ + success: true, + message: "Submission details loaded!", + payload: { + id: MOCK_SUBMISSION_ID, + userId: "user-1", + questionSlug: "two-sum", + questionNumber: 1, + questionTitle: "Two Sum", + questionLink: "https://leetcode.com/problems/two-sum", + questionDifficulty: QuestionDifficulty.Hard, + description: null, + acceptanceRate: 0.2, + pointsAwarded: null, + discordName: null, + nickname: null, + code: "public class Solution {}", + runtime: "3ms", + memory: "2MB", + language: "csharp", + createdAt: "", + submittedAt: "", + submissionId: null, + topics: [], + leetcodeUsername: null, + }, + } satisfies components["schemas"]["ApiResponderQuestionWithUserDto"]); + }, +); + export const submissionDetailsFailedHandler = http.get( submissionDetails.url.toString(), () => { diff --git a/js/src/app/submission/[submissionId]/_components/SubmissionDetailsContent.module.css b/js/src/app/submission/[submissionId]/_components/SubmissionDetailsContent.module.css index 9c544ac7e..b43d54c16 100644 --- a/js/src/app/submission/[submissionId]/_components/SubmissionDetailsContent.module.css +++ b/js/src/app/submission/[submissionId]/_components/SubmissionDetailsContent.module.css @@ -2,8 +2,6 @@ font-size: clamp(1.375rem, 4vw, 2rem); } -/* Mantine reserves 24px (the control height) below the content for the - absolutely positioned toggle; add the toggle's top offset to that reservation. */ .spoilerRoot[data-has-spoiler] { margin-bottom: calc(24px + var(--mantine-spacing-md)); } diff --git a/js/src/app/submission/[submissionId]/_components/SubmissionDetailsContent.test.tsx b/js/src/app/submission/[submissionId]/_components/SubmissionDetailsContent.test.tsx index 5fd7d158d..cd7c22a1a 100644 --- a/js/src/app/submission/[submissionId]/_components/SubmissionDetailsContent.test.tsx +++ b/js/src/app/submission/[submissionId]/_components/SubmissionDetailsContent.test.tsx @@ -3,11 +3,14 @@ import { submissionDetailsFailedHandler, submissionDetailsErrorHandler, submissionDetailsProcessingHandler, + submissionDetailsSparseHandler, + submissionDetailsVerifiedHandler, MOCK_SUBMISSION_ID, } from "@/__mock__/submissions"; import SubmissionDetailsContent from "@/app/submission/[submissionId]/_components/SubmissionDetailsContent"; 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"; describe("SubmissionDetailsContent API Succeeded", () => { @@ -195,4 +198,60 @@ describe("SubmissionDetailsContent API Succeeded", () => { expect(screen.getByText("Submission not found.")).toBeInTheDocument(); }); }); + + it("should render verified nickname, submitted time and topics when available", async () => { + server.use(submissionDetailsVerifiedHandler); + renderProviderFn?.( + , + ); + + await waitFor(() => { + expect(screen.getByRole("link", { name: "Aphrodite" })).toHaveAttribute( + "href", + "/user/user-1", + ); + }); + expect(screen.queryByText("aphrodite")).not.toBeInTheDocument(); + expect(screen.getByText("Submitted")).toBeInTheDocument(); + expect(screen.getByText("2d ago")).toBeInTheDocument(); + expect(screen.getByText("Array")).toBeInTheDocument(); + }); + + it("should render fallbacks when optional submission fields are missing", async () => { + server.use(submissionDetailsSparseHandler); + renderProviderFn?.( + , + ); + + await waitFor(() => { + expect( + screen.getByRole("link", { name: "Unknown user" }), + ).toBeInTheDocument(); + }); + expect(screen.getByText("—")).toBeInTheDocument(); + expect( + screen.getByText("No available description found."), + ).toBeInTheDocument(); + expect(screen.getByText("Csharp")).toBeInTheDocument(); + expect(screen.queryByText("Submitted")).not.toBeInTheDocument(); + }); + + it("should copy the solution code to the clipboard", async () => { + const user = userEvent.setup(); + renderProviderFn?.( + , + ); + + const copyButton = await screen.findByRole("button", { + name: "Copy code", + }); + await user.click(copyButton); + + await waitFor(async () => { + expect(await navigator.clipboard.readText()).toBe( + "def twoSum(nums, target):\n pass", + ); + }); + expect(await screen.findByText("Copied")).toBeInTheDocument(); + }); });