Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions frontend/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@ function App() {
<Route path="/home" element={<Home />} />
<Route path="/leaderboard/:id" element={<Leaderboard />} />
<Route path="/news" element={<News />} />
<Route path="/news/:postId" element={<News />} />
<Route path="/login" element={<Login />} />
// error handling page
{errorRoutes.map(({ path, code, title, description }) => (
Expand Down
56 changes: 53 additions & 3 deletions frontend/src/components/markdown-renderer/MarkdownRenderer.tsx
Original file line number Diff line number Diff line change
@@ -1,10 +1,12 @@
import React from "react";
import React, { useEffect, useRef } from "react";
import ReactMarkdown from "react-markdown";
import rehypeRaw from "rehype-raw";

type MarkdownRendererProps = {
content: string;
imageProps?: MarkdownRendererImageProps;
// called when the rendered content is fully laid out (images loaded)
onLoadComplete?: () => void;
};

type MarkdownRendererImageProps = {
Expand Down Expand Up @@ -40,11 +42,58 @@ const defaultImageProps: MarkdownRendererImageProps = {
const MarkdownRenderer: React.FC<MarkdownRendererProps> = ({
content,
imageProps,
onLoadComplete,
}) => {
const mergedImageProps = { ...defaultImageProps, ...imageProps };
const { align, ...styleProps } = mergedImageProps;
const containerRef = useRef<HTMLDivElement | null>(null);

// Detect when images inside the rendered markdown finish loading.
useEffect(() => {
const el = containerRef.current;
if (!el) {
onLoadComplete?.();
return;
}

const imgs = Array.from(el.querySelectorAll("img")) as HTMLImageElement[];
if (imgs.length === 0) {
// no images -> content is effectively ready
// schedule on next tick to ensure paint
const t = window.setTimeout(() => onLoadComplete?.(), 0);
return () => clearTimeout(t);
}

let settled = 0;
const handlers: Array<() => void> = [];
imgs.forEach((img) => {
if (img.complete) {
settled += 1;
return;
}
const onFinish = () => {
settled += 1;
if (settled === imgs.length) onLoadComplete?.();
};
img.addEventListener("load", onFinish);
img.addEventListener("error", onFinish);
handlers.push(() => {
img.removeEventListener("load", onFinish);
img.removeEventListener("error", onFinish);
});
});

if (settled === imgs.length) {
// all images already complete
const t = window.setTimeout(() => onLoadComplete?.(), 0);
return () => clearTimeout(t);
}

return () => handlers.forEach((h) => h());
}, [content, onLoadComplete]);
return (
<ReactMarkdown
<div ref={containerRef}>
<ReactMarkdown
rehypePlugins={[rehypeRaw]}
components={{
figure: ({ node, ...props }) => (
Expand All @@ -70,7 +119,8 @@ const MarkdownRenderer: React.FC<MarkdownRendererProps> = ({
}}
>
{content}
</ReactMarkdown>
</ReactMarkdown>
</div>
);
};

Expand Down
18 changes: 9 additions & 9 deletions frontend/src/pages/news/News.test.tsx
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
import {
render,
screen,
fireEvent,
within,
waitFor,
} from "@testing-library/react";
import { renderWithProviders } from "../../tests/test-utils";
import { vi, describe, it, expect, beforeEach } from "vitest";
import News from "./News"; // 假设你当前文件路径为 pages/News.tsx
import * as apiHook from "../../lib/hooks/useApi";
Expand Down Expand Up @@ -57,8 +57,8 @@ describe("News", () => {
mockHookReturn,
);

// render
render(<News />);
// render inside MemoryRouter + ThemeProvider
renderWithProviders(<News />);

// asserts
expect(screen.getByText(/Summoning/i)).toBeInTheDocument();
Expand All @@ -78,8 +78,8 @@ describe("News", () => {
mockHookReturn,
);

// render
render(<News />);
// render inside MemoryRouter + ThemeProvider
renderWithProviders(<News />);

// asserts
expect(screen.getByText(/something went wrong/i)).toBeInTheDocument();
Expand All @@ -99,8 +99,8 @@ describe("News", () => {
mockHookReturn,
);

// render
render(<News />);
// render inside MemoryRouter + ThemeProvider
renderWithProviders(<News />);

// asserts
expect(screen.getByText("News and Announcements")).toBeInTheDocument();
Expand Down Expand Up @@ -136,8 +136,8 @@ describe("News", () => {
mockHookReturn,
);

// render
render(<News />);
// render inside MemoryRouter + ThemeProvider
renderWithProviders(<News />);

// asserts
const sidebar = screen.getByTestId("news-sidbar");
Expand Down
40 changes: 37 additions & 3 deletions frontend/src/pages/news/News.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { ErrorAlert } from "../../components/alert/ErrorAlert";
import { fetcherApiCallback } from "../../lib/hooks/useApi";
import { fetchAllNews } from "../../api/api";
import { useEffect, useRef } from "react";
import { useParams, useLocation } from "react-router-dom";
import { Box } from "@mui/material";
import { NewsContentSection } from "./components/NewsContentSection";
import { Sidebar } from "./components/NewsSideBar";
Expand All @@ -18,22 +19,55 @@ export default function News() {

const sectionRefs = useRef<Record<string, HTMLDivElement | null>>({});

const scrollTo = (id: string) => {
// read optional :postId param from URL; if present we'll scroll to it once data loads
const { postId } = useParams<{ postId?: string }>();

// scrollTo accepts an optional `smooth` flag. Sidebar clicks use smooth scrolling
// but when the page initially loads (deep link) we want an immediate jump.
const scrollTo = (id: string, smooth = true) => {
const el = sectionRefs.current[id];
if (el) el.scrollIntoView({ behavior: "smooth", block: "start" });
if (el) el.scrollIntoView({ behavior: smooth ? "smooth" : "auto", block: "start" });
};

useEffect(() => {
call();
}, []);

// when data loads and there's a postId, jump immediately (no animation).
// We'll also re-jump when the rendered section signals it's finished loading
// via the onSectionLoad callback (see below).
const location = useLocation();

useEffect(() => {
if (!postId) return;
// If navigation came from the sidebar we already initiated a smooth scroll
// there, so skip the immediate jump. We check location.state.fromSidebar
// to determine that.
if (location.state && location.state.fromSidebar) return;

// initial immediate jump once refs attach
const t = window.setTimeout(() => {
if (sectionRefs.current[postId]) scrollTo(postId, false);
}, 0);
return () => clearTimeout(t);
}, [postId, data, location]);

if (loading) return <Loading />;
if (error) return <ErrorAlert status={errorStatus} message={error} />;

return (
<Box sx={styles.container} id="news">
<Sidebar data={data} scrollTo={scrollTo} />
<NewsContentSection data={data} sectionRefs={sectionRefs} />
<NewsContentSection
data={data}
sectionRefs={sectionRefs}
onSectionLoad={() => {
// re-jump after content (images/markdown) finished loading.
// We re-jump whenever any section finishes to handle layout changes
// caused by images loading above/below the target post.
if (postId) scrollTo(postId, false);
}}
/>
</Box>
);
}
82 changes: 82 additions & 0 deletions frontend/src/pages/news/NewsRoute.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
import { screen, within, waitFor } from "@testing-library/react";
import { vi, describe, it, beforeEach, expect } from "vitest";
import News from "./News";
import { renderWithProviders } from "../../tests/test-utils";

// Mock the useApi hook used by News
vi.mock("../../lib/hooks/useApi", () => ({
fetcherApiCallback: vi.fn(),
}));

// Mock MarkdownRenderer to avoid lazy loading in tests
vi.mock("../../components/markdown-renderer/MarkdownRenderer", () => ({
default: ({ content }: { content: string }) => <div>{content}</div>,
}));

import * as apiHook from "../../lib/hooks/useApi";

// helper to generate very large markdown content (many newlines)
const makeLargeMarkdown = (paragraphs = 200) =>
Array.from({ length: paragraphs })
.map((_, i) => `Paragraph ${i + 1}\n\nThis is a long line to increase vertical space.`)
.join("\n\n");

const mockData = Array.from({ length: 5 }).map((_, i) => ({
id: `news-${i + 1}`,
title: `Displayed Post ${i + 1}`,
date: `2025-10-01`,
category: `Other`,
markdown: makeLargeMarkdown(200),
}));

describe("News route deep link", () => {
beforeEach(() => {
vi.clearAllMocks();
});

it("renders the requested post when route is /news/:postId", async () => {
const mockHookReturn = {
data: mockData,
loading: false,
error: null,
errorStatus: null,
call: vi.fn(),
};

(apiHook.fetcherApiCallback as any).mockReturnValue(mockHookReturn);

// spy on scrollIntoView so we can assert the deep-link triggers a scroll
const original = Element.prototype.scrollIntoView;
const scrollCalls: Array<{ el: Element; opts: any }> = [];
Element.prototype.scrollIntoView = function (opts?: any) {
scrollCalls.push({ el: this as Element, opts });
};

try {
// render at /v2/news/news-3 (app is mounted with basename /v2)
renderWithProviders(
// Render the News component directly; renderWithProviders wraps MemoryRouter
<News />,
"/v2/news/news-3",
);

// title should be visible
expect(screen.getByText("News and Announcements")).toBeInTheDocument();

const newsContent = screen.getByTestId("news-content");
await waitFor(() => {
// The post title is present in the content area
expect(within(newsContent).getByText("Displayed Post 3")).toBeInTheDocument();
});

// Assert scrollIntoView was called for the section with id 'news-3'
const scrolled = scrollCalls.find((c) => c.el && (c.el as Element).id === "news-3");
expect(scrolled).toBeTruthy();
// Optionally check options (initial jump used 'auto')
if (scrolled) expect(scrolled.opts).toMatchObject({ behavior: "auto", block: "start" });
} finally {
// restore
Element.prototype.scrollIntoView = original;
}
});
});
40 changes: 40 additions & 0 deletions frontend/src/pages/news/NewsSidebar.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { vi, describe, it, expect, afterEach } from "vitest";

// the Sidebar is a named export
import { Sidebar } from "./components/NewsSideBar";

// mock useNavigate from react-router-dom
const mockNavigate = vi.fn();
vi.mock("react-router-dom", async () => {
const actual = await vi.importActual("react-router-dom");
return {
...actual,
useNavigate: () => mockNavigate,
};
});

describe("News Sidebar navigation", () => {
afterEach(() => {
mockNavigate.mockReset();
});

it("renders anchor hrefs and calls navigate on click", async () => {
const data = [
{ id: "post-1", title: "First", date: "2025-10-08" },
{ id: "post-2", title: "Second", date: "2025-10-07" },
];

render(<Sidebar data={data} scrollTo={() => {}} />);

// anchors are ListItemButton with component="a" and have the href
const firstAnchor = screen.getByTestId("news-sidbar-button-post-1");
// anchor should have href attribute set
expect(firstAnchor.getAttribute("href")).toBe("/v2/news/post-1");

// click should call navigate to the expected path
await userEvent.click(firstAnchor);
expect(mockNavigate).toHaveBeenCalledWith("/news/post-1", { state: { fromSidebar: true } });
});
});
3 changes: 3 additions & 0 deletions frontend/src/pages/news/components/NewsContentSection.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -27,9 +27,11 @@ const styles = {
export function NewsContentSection({
data,
sectionRefs,
onSectionLoad,
}: {
data: any[];
sectionRefs: React.MutableRefObject<Record<string, HTMLDivElement | null>>;
onSectionLoad?: () => void;
}) {
return (
<Box sx={styles.content} data-testid="news-content">
Expand Down Expand Up @@ -59,6 +61,7 @@ export function NewsContentSection({
height: "auto",
align: "center",
}}
onLoadComplete={() => onSectionLoad?.()}
/>
</Suspense>
</Box>
Expand Down
Loading