Skip to content

Commit 5674258

Browse files
author
Rajat
committed
Navigation fix for course viewer
1 parent 90860b6 commit 5674258

6 files changed

Lines changed: 418 additions & 103 deletions

File tree

apps/web/app/(with-contexts)/course/[slug]/[id]/__tests__/layout-with-sidebar.test.tsx

Lines changed: 91 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,10 @@
11
let mockPathname = "/course/test-course/course-1";
2+
let mockSearchParams = new URLSearchParams();
23

34
jest.mock("next/navigation", () => ({
45
usePathname: () => mockPathname,
56
useRouter: () => ({ push: jest.fn() }),
6-
useSearchParams: () => new URLSearchParams(),
7+
useSearchParams: () => mockSearchParams,
78
}));
89

910
jest.mock("next/link", () => {
@@ -141,6 +142,8 @@ describe("generateSideBarItems", () => {
141142
const originalRelativeDripUnitInMillis = constants.relativeDripUnitInMillis;
142143

143144
beforeEach(() => {
145+
mockPathname = "/course/test-course/course-1";
146+
mockSearchParams = new URLSearchParams();
144147
Date.now = jest.fn(() =>
145148
new Date("2026-03-22T00:00:00.000Z").getTime(),
146149
);
@@ -839,6 +842,7 @@ describe("generateSideBarItems", () => {
839842
describe("Course viewer layout", () => {
840843
beforeEach(() => {
841844
mockPathname = "/course/test-course/course-1";
845+
mockSearchParams = new URLSearchParams();
842846
});
843847

844848
it("renders the preview badge in the viewer header when preview mode is active", () => {
@@ -1004,4 +1008,90 @@ describe("Course viewer layout", () => {
10041008
"/course/test-course/course-1/lesson-1?discussion=open",
10051009
);
10061010
});
1011+
1012+
it("preserves preview session params when opening the discussion panel", () => {
1013+
mockPathname = "/course/test-course/course-1/lesson-1";
1014+
mockSearchParams = new URLSearchParams(
1015+
"preview=true&returnTo=%2Fdashboard%2Fproduct%2Fcourse-1",
1016+
);
1017+
const course = {
1018+
title: "Course",
1019+
description: "",
1020+
featuredImage: undefined,
1021+
updatedAt: new Date().toISOString(),
1022+
creatorId: "creator-1",
1023+
slug: "test-course",
1024+
cost: 0,
1025+
courseId: "course-1",
1026+
tags: [],
1027+
paymentPlans: [],
1028+
defaultPaymentPlan: "",
1029+
firstLesson: "lesson-1",
1030+
isPreview: true,
1031+
groups: [],
1032+
discussions: true,
1033+
} as unknown as CourseFrontend;
1034+
1035+
render(
1036+
<ProductPage product={course}>
1037+
<div>Lesson body</div>
1038+
</ProductPage>,
1039+
);
1040+
1041+
expect(screen.getByLabelText("Discussions")).toHaveAttribute(
1042+
"href",
1043+
"/course/test-course/course-1/lesson-1?preview=true&returnTo=%2Fdashboard%2Fproduct%2Fcourse-1&discussion=open",
1044+
);
1045+
});
1046+
1047+
it("preserves the open discussion panel while navigating lesson links", () => {
1048+
mockPathname = "/course/test-course/course-1/lesson-1";
1049+
mockSearchParams = new URLSearchParams("discussion=open");
1050+
const course = {
1051+
title: "Course",
1052+
description: "",
1053+
featuredImage: undefined,
1054+
updatedAt: new Date().toISOString(),
1055+
creatorId: "creator-1",
1056+
slug: "test-course",
1057+
cost: 0,
1058+
courseId: "course-1",
1059+
tags: [],
1060+
paymentPlans: [],
1061+
defaultPaymentPlan: "",
1062+
firstLesson: "lesson-1",
1063+
isPreview: false,
1064+
groups: [
1065+
{
1066+
id: "group-1",
1067+
name: "Section",
1068+
lessons: [
1069+
{
1070+
lessonId: "lesson-1",
1071+
title: "Lesson 1",
1072+
requiresEnrollment: false,
1073+
},
1074+
{
1075+
lessonId: "lesson-2",
1076+
title: "Lesson 2",
1077+
requiresEnrollment: false,
1078+
},
1079+
],
1080+
},
1081+
],
1082+
discussions: true,
1083+
} as unknown as CourseFrontend;
1084+
1085+
const { container } = render(
1086+
<ProductPage product={course}>
1087+
<div>Lesson body</div>
1088+
</ProductPage>,
1089+
);
1090+
1091+
expect(
1092+
container.querySelector(
1093+
'a[href="/course/test-course/course-1/lesson-2?discussion=open"]',
1094+
),
1095+
).toBeInTheDocument();
1096+
});
10071097
});

apps/web/app/(with-contexts)/course/[slug]/[id]/layout-with-sidebar.tsx

Lines changed: 23 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,7 @@ import {
6767
appendCourseViewerSessionParamsToHref,
6868
getCourseViewerSessionParams,
6969
getCourseViewerReturnPath,
70+
setHrefQueryParam,
7071
} from "@/lib/course-viewer-session-params";
7172
import { Badge } from "@/components/ui/badge";
7273
import ProductDiscussionPanel from "@/components/public/product-discussions/panel";
@@ -128,9 +129,11 @@ export default function ProductPage({
128129
const isActualLessonPage =
129130
isLessonPage && pathSegments[3] !== "discussions";
130131
const showDiscussionsAction = product.discussions && isActualLessonPage;
131-
const discussionsHref = isDiscussionOpen
132-
? pathname
133-
: `${pathname}?discussion=open`;
132+
const discussionsHref = getDiscussionHref({
133+
pathname,
134+
searchParams,
135+
isDiscussionOpen,
136+
});
134137

135138
if (!profile) {
136139
return null;
@@ -264,6 +267,23 @@ export default function ProductPage({
264267
);
265268
}
266269

270+
function getDiscussionHref({
271+
pathname,
272+
searchParams,
273+
isDiscussionOpen,
274+
}: {
275+
pathname: string;
276+
searchParams: ReturnType<typeof useSearchParams>;
277+
isDiscussionOpen: boolean;
278+
}) {
279+
const currentSearch = searchParams?.toString() || "";
280+
return setHrefQueryParam(
281+
currentSearch ? `${pathname}?${currentSearch}` : pathname,
282+
"discussion",
283+
isDiscussionOpen ? null : "open",
284+
);
285+
}
286+
267287
export function AppSidebar({
268288
course,
269289
profile,

apps/web/components/public/product-discussions/__tests__/panel.test.tsx

Lines changed: 70 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ const mockToast = jest.fn();
1111
const mockExec = jest.fn();
1212
const payloads: Record<string, any>[] = [];
1313
const scrollIntoView = jest.fn();
14+
let mockSearchParams = new URLSearchParams();
1415

1516
jest.mock("@components/contexts", () => {
1617
const React = jest.requireActual("react");
@@ -41,6 +42,10 @@ jest.mock("next/link", () => {
4142
return MockNextLink;
4243
});
4344

45+
jest.mock("next/navigation", () => ({
46+
useSearchParams: () => mockSearchParams,
47+
}));
48+
4449
jest.mock("@courselit/components-library", () => ({
4550
useToast: () => ({
4651
toast: mockToast,
@@ -69,12 +74,6 @@ jest.mock("@courselit/page-primitives", () => ({
6974
),
7075
}));
7176

72-
jest.mock("@components/ui/avatar", () => ({
73-
Avatar: ({ children }: any) => <div>{children}</div>,
74-
AvatarFallback: ({ children }: any) => <span>{children}</span>,
75-
AvatarImage: ({ alt }: any) => <span>{alt}</span>,
76-
}));
77-
7877
jest.mock("@courselit/page-blocks", () => ({
7978
TextRenderer: ({ json }: { json: any }) => (
8079
<div>{json?.content?.[0]?.content?.[0]?.text}</div>
@@ -132,7 +131,6 @@ jest.mock("lucide-react", () => ({
132131
Flag: () => null,
133132
ThumbsUp: () => null,
134133
MessageSquare: () => null,
135-
MoreVertical: () => null,
136134
Trash2: () => null,
137135
X: () => null,
138136
}));
@@ -212,6 +210,7 @@ describe("ProductDiscussionPanel", () => {
212210
beforeEach(() => {
213211
jest.clearAllMocks();
214212
payloads.length = 0;
213+
mockSearchParams = new URLSearchParams();
215214
window.location.hash = "";
216215
mockExec.mockImplementation(() => {
217216
const payload = payloads[payloads.length - 1];
@@ -247,6 +246,25 @@ describe("ProductDiscussionPanel", () => {
247246
});
248247
});
249248

249+
it("preserves preview session params in the view all discussions link", async () => {
250+
mockSearchParams = new URLSearchParams(
251+
"preview=true&returnTo=%2Fdashboard%2Fproduct%2Fcourse-1&discussion=open",
252+
);
253+
254+
renderPanel();
255+
256+
await waitFor(() => {
257+
expect(screen.getByText("Root comment")).toBeInTheDocument();
258+
});
259+
260+
expect(
261+
screen.getByText("View all discussions").closest("a"),
262+
).toHaveAttribute(
263+
"href",
264+
"/course/course-slug/course-1/discussions?preview=true&discussion=open&returnTo=%2Fdashboard%2Fproduct%2Fcourse-1",
265+
);
266+
});
267+
250268
it("loads the hash target, highlights it, and scrolls it into view", async () => {
251269
window.location.hash = "#discussion-reply-reply-1";
252270

@@ -271,6 +289,38 @@ describe("ProductDiscussionPanel", () => {
271289
});
272290
});
273291

292+
it("reloads, scrolls, and highlights when a notification hash arrives after mount", async () => {
293+
renderPanel();
294+
295+
await waitFor(() => {
296+
expect(screen.getByText("Root comment")).toBeInTheDocument();
297+
});
298+
expect(payloads[0].variables.targetContentId).toBeUndefined();
299+
300+
scrollIntoView.mockClear();
301+
window.location.hash = "#discussion-reply-reply-1";
302+
window.dispatchEvent(new HashChangeEvent("hashchange"));
303+
304+
await waitFor(() => {
305+
expect(payloads[payloads.length - 1].variables).toEqual(
306+
expect.objectContaining({
307+
targetContentType: "REPLY",
308+
targetContentId: "reply-1",
309+
}),
310+
);
311+
});
312+
313+
await waitFor(() => {
314+
expect(
315+
document.getElementById("discussion-reply-reply-1"),
316+
).toHaveClass("bg-yellow-100");
317+
});
318+
expect(scrollIntoView).toHaveBeenCalledWith({
319+
block: "center",
320+
behavior: "smooth",
321+
});
322+
});
323+
274324
it("stores parentReplyId when replying to an existing reply", async () => {
275325
renderPanel();
276326

@@ -280,10 +330,22 @@ describe("ProductDiscussionPanel", () => {
280330

281331
const replyButtons = screen.getAllByText("Reply");
282332
fireEvent.click(replyButtons[1]);
333+
await waitFor(() => {
334+
expect(screen.getByLabelText("Add a reply...")).toHaveFocus();
335+
});
336+
expect(scrollIntoView).toHaveBeenCalledWith({
337+
block: "center",
338+
behavior: "smooth",
339+
});
340+
283341
fireEvent.change(screen.getByLabelText("Add a reply..."), {
284342
target: { value: "Replying to a reply" },
285343
});
286-
fireEvent.click(screen.getByText("Post Reply"));
344+
const postReplyButton = screen.getByText("Post Reply");
345+
await waitFor(() => {
346+
expect(postReplyButton).not.toBeDisabled();
347+
});
348+
fireEvent.click(postReplyButton);
287349

288350
await waitFor(() => {
289351
expect(screen.getByText("Replying to a reply")).toBeInTheDocument();

0 commit comments

Comments
 (0)