Skip to content

Commit aeae20e

Browse files
author
Rajat
committed
Cross section lesson drag-n-drop; Section d-n-d replaced with buttons;
1 parent 141471c commit aeae20e

File tree

19 files changed

+2810
-531
lines changed

19 files changed

+2810
-531
lines changed

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

Lines changed: 80 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -214,7 +214,7 @@ describe("generateSideBarItems", () => {
214214
status: true,
215215
type: Constants.dripType[1].split("-")[0].toUpperCase(),
216216
dateInUTC: new Date(
217-
"2026-03-24T00:00:00.000Z",
217+
"2099-03-24T00:00:00.000Z",
218218
).getTime(),
219219
},
220220
},
@@ -374,7 +374,7 @@ describe("generateSideBarItems", () => {
374374
status: true,
375375
type: Constants.dripType[1].split("-")[0].toUpperCase(),
376376
dateInUTC: new Date(
377-
"2026-03-24T00:00:00.000Z",
377+
"2099-03-24T00:00:00.000Z",
378378
).getTime(),
379379
},
380380
},
@@ -458,7 +458,7 @@ describe("generateSideBarItems", () => {
458458
);
459459

460460
expect(items[2].badge?.text).toBe("Mar 22, 2026");
461-
expect(items[2].badge?.description).toBe("Available on Mar 22, 2026");
461+
expect(items[2].badge?.description).toBe("");
462462
});
463463

464464
it("uses purchase createdAt as the relative drip anchor when lastDripAt is absent", () => {
@@ -648,4 +648,81 @@ describe("generateSideBarItems", () => {
648648

649649
expect(items[3].badge?.text).toBe("3 days");
650650
});
651+
652+
it("renders reordered lessons under the destination section in sidebar order", () => {
653+
const course = {
654+
title: "Course",
655+
description: "",
656+
featuredImage: undefined,
657+
updatedAt: new Date().toISOString(),
658+
creatorId: "creator-1",
659+
slug: "test-course",
660+
cost: 0,
661+
courseId: "course-1",
662+
tags: [],
663+
paymentPlans: [],
664+
defaultPaymentPlan: "",
665+
firstLesson: "lesson-2",
666+
groups: [
667+
{
668+
id: "group-1",
669+
name: "First Section",
670+
lessons: [
671+
{
672+
lessonId: "lesson-1",
673+
title: "Text 1",
674+
requiresEnrollment: false,
675+
},
676+
],
677+
},
678+
{
679+
id: "group-2",
680+
name: "Second Section",
681+
lessons: [
682+
{
683+
lessonId: "lesson-2",
684+
title: "Chapter 5 - Text 2",
685+
requiresEnrollment: false,
686+
},
687+
{
688+
lessonId: "lesson-3",
689+
title: "Text 3",
690+
requiresEnrollment: false,
691+
},
692+
],
693+
},
694+
],
695+
} as unknown as CourseFrontend;
696+
697+
const profile = {
698+
userId: "user-1",
699+
purchases: [
700+
{
701+
courseId: "course-1",
702+
accessibleGroups: ["group-1", "group-2"],
703+
},
704+
],
705+
} as unknown as Profile;
706+
707+
const items = generateSideBarItems(
708+
course,
709+
profile,
710+
"/course/test-course/course-1",
711+
);
712+
713+
const firstSectionItems = items.find(
714+
(item) => item.title === "First Section",
715+
)?.items;
716+
const secondSectionItems = items.find(
717+
(item) => item.title === "Second Section",
718+
)?.items;
719+
720+
expect(firstSectionItems?.map((item) => item.title)).toEqual([
721+
"Text 1",
722+
]);
723+
expect(secondSectionItems?.map((item) => item.title)).toEqual([
724+
"Chapter 5 - Text 2",
725+
"Text 3",
726+
]);
727+
});
651728
});
Lines changed: 276 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,276 @@
1+
import React from "react";
2+
import {
3+
act,
4+
fireEvent,
5+
render,
6+
screen,
7+
waitFor,
8+
} from "@testing-library/react";
9+
import ContentSectionsBoard from "../content-sections-board";
10+
11+
const toastMock = jest.fn();
12+
const reorderExecMock = jest.fn();
13+
const moveLessonExecMock = jest.fn();
14+
15+
let resolveMoveLesson: (() => void) | null = null;
16+
let rejectMoveLesson: ((error: unknown) => void) | null = null;
17+
18+
jest.mock("@courselit/components-library", () => ({
19+
useToast: () => ({ toast: toastMock }),
20+
}));
21+
22+
jest.mock("../multi-container-drag-and-drop", () => ({
23+
MultiContainerDragAndDrop: ({
24+
children,
25+
onMove,
26+
onDragStateChange,
27+
}: any) => (
28+
<div>
29+
<button
30+
data-testid="start-lesson-drag"
31+
onClick={() =>
32+
onDragStateChange?.({
33+
itemId: "lesson-1",
34+
sourceContainerId: "group-1",
35+
})
36+
}
37+
>
38+
start lesson drag
39+
</button>
40+
<button
41+
data-testid="end-lesson-drag"
42+
onClick={() =>
43+
onMove?.({
44+
itemId: "lesson-1",
45+
sourceContainerId: "group-1",
46+
sourceIndex: 0,
47+
destinationContainerId: "group-2",
48+
destinationIndex: 0,
49+
})
50+
}
51+
>
52+
end lesson drag
53+
</button>
54+
{children}
55+
</div>
56+
),
57+
}));
58+
59+
jest.mock("@courselit/utils", () => ({
60+
FetchBuilder: class {
61+
payload: any;
62+
setUrl() {
63+
return this;
64+
}
65+
setPayload(payload: any) {
66+
this.payload = payload;
67+
return this;
68+
}
69+
setIsGraphQLEndpoint() {
70+
return this;
71+
}
72+
build() {
73+
const query = this.payload?.query ?? "";
74+
if (query.includes("moveLesson")) {
75+
return {
76+
exec: moveLessonExecMock,
77+
};
78+
}
79+
80+
return {
81+
exec: reorderExecMock,
82+
};
83+
}
84+
},
85+
}));
86+
87+
jest.mock("../content-section-card", () => {
88+
return function MockContentSectionCard(props: any) {
89+
return (
90+
<div>
91+
<div data-testid={`lessons-${props.section.id}`}>
92+
{(props.lessons ?? [])
93+
.map((lesson: any) => lesson.title)
94+
.join(",")}
95+
</div>
96+
<button
97+
data-testid={`move-up-${props.section.id}`}
98+
disabled={!props.canMoveUp || props.sectionMoveDisabled}
99+
onClick={props.onMoveUp}
100+
>
101+
up
102+
</button>
103+
<button
104+
data-testid={`move-down-${props.section.id}`}
105+
disabled={!props.canMoveDown || props.sectionMoveDisabled}
106+
onClick={props.onMoveDown}
107+
>
108+
down
109+
</button>
110+
</div>
111+
);
112+
};
113+
});
114+
115+
describe("ContentSectionsBoard", () => {
116+
beforeEach(() => {
117+
toastMock.mockReset();
118+
reorderExecMock.mockReset().mockResolvedValue({});
119+
moveLessonExecMock.mockReset().mockImplementation(
120+
() =>
121+
new Promise((resolve, reject) => {
122+
resolveMoveLesson = () => resolve({});
123+
rejectMoveLesson = reject;
124+
}),
125+
);
126+
});
127+
128+
const sections = [
129+
{
130+
id: "group-1",
131+
name: "Group 1",
132+
rank: 1000,
133+
collapsed: false,
134+
lessonsOrder: ["lesson-1"],
135+
},
136+
{
137+
id: "group-2",
138+
name: "Group 2",
139+
rank: 2000,
140+
collapsed: false,
141+
lessonsOrder: [],
142+
},
143+
] as any;
144+
145+
const lessons = [
146+
{
147+
lessonId: "lesson-1",
148+
title: "Lesson 1",
149+
type: "text",
150+
groupId: "group-1",
151+
published: true,
152+
},
153+
] as any;
154+
155+
it("disables section move controls while moveLesson is in-flight", async () => {
156+
const setOrderedSections = jest.fn();
157+
158+
render(
159+
<ContentSectionsBoard
160+
orderedSections={sections}
161+
setOrderedSections={setOrderedSections}
162+
lessons={lessons}
163+
courseId="course-1"
164+
productId="product-1"
165+
address="http://localhost:3000"
166+
onRequestDelete={jest.fn()}
167+
/>,
168+
);
169+
170+
fireEvent.click(screen.getByTestId("start-lesson-drag"));
171+
fireEvent.click(screen.getByTestId("end-lesson-drag"));
172+
173+
await waitFor(() =>
174+
expect(screen.getByTestId("move-down-group-1")).toBeDisabled(),
175+
);
176+
177+
fireEvent.click(screen.getByTestId("move-down-group-1"));
178+
expect(reorderExecMock).not.toHaveBeenCalled();
179+
180+
await act(async () => {
181+
resolveMoveLesson?.();
182+
});
183+
184+
await waitFor(() =>
185+
expect(screen.getByTestId("move-down-group-1")).not.toBeDisabled(),
186+
);
187+
});
188+
189+
it("moves section down using reorderGroups mutation", async () => {
190+
const setOrderedSections = jest.fn();
191+
192+
render(
193+
<ContentSectionsBoard
194+
orderedSections={sections}
195+
setOrderedSections={setOrderedSections}
196+
lessons={lessons}
197+
courseId="course-1"
198+
productId="product-1"
199+
address="http://localhost:3000"
200+
onRequestDelete={jest.fn()}
201+
/>,
202+
);
203+
204+
fireEvent.click(screen.getByTestId("move-down-group-1"));
205+
206+
expect(setOrderedSections).toHaveBeenCalledTimes(1);
207+
expect(reorderExecMock).toHaveBeenCalledTimes(1);
208+
});
209+
210+
it("rolls back optimistic lesson move when moveLesson fails", async () => {
211+
render(
212+
<ContentSectionsBoard
213+
orderedSections={sections}
214+
setOrderedSections={jest.fn()}
215+
lessons={lessons}
216+
courseId="course-1"
217+
productId="product-1"
218+
address="http://localhost:3000"
219+
onRequestDelete={jest.fn()}
220+
/>,
221+
);
222+
223+
fireEvent.click(screen.getByTestId("start-lesson-drag"));
224+
fireEvent.click(screen.getByTestId("end-lesson-drag"));
225+
226+
await act(async () => {
227+
rejectMoveLesson?.(new Error("Move failed"));
228+
});
229+
230+
await waitFor(() =>
231+
expect(toastMock).toHaveBeenCalledWith(
232+
expect.objectContaining({
233+
variant: "destructive",
234+
}),
235+
),
236+
);
237+
});
238+
239+
it("keeps moved lessons in destination section after section reorder", async () => {
240+
moveLessonExecMock.mockResolvedValueOnce({});
241+
242+
const Harness = () => {
243+
const [localSections, setLocalSections] = React.useState(sections);
244+
return (
245+
<ContentSectionsBoard
246+
orderedSections={localSections}
247+
setOrderedSections={setLocalSections}
248+
lessons={lessons}
249+
courseId="course-1"
250+
productId="product-1"
251+
address="http://localhost:3000"
252+
onRequestDelete={jest.fn()}
253+
/>
254+
);
255+
};
256+
257+
render(<Harness />);
258+
259+
fireEvent.click(screen.getByTestId("start-lesson-drag"));
260+
fireEvent.click(screen.getByTestId("end-lesson-drag"));
261+
262+
await waitFor(() =>
263+
expect(screen.getByTestId("lessons-group-2")).toHaveTextContent(
264+
"Lesson 1",
265+
),
266+
);
267+
268+
fireEvent.click(screen.getByTestId("move-up-group-2"));
269+
270+
await waitFor(() =>
271+
expect(screen.getByTestId("lessons-group-2")).toHaveTextContent(
272+
"Lesson 1",
273+
),
274+
);
275+
});
276+
});

0 commit comments

Comments
 (0)