Skip to content

Commit 5b9830f

Browse files
rajat1saxenaRajat
andauthored
Reworked the lesson editor (#687)
* Removed circular flow introduced by useEffect * WIP: leaner lesson builder and embed lesson supports iframes in addition to youtube * build fixes * Tested manually; Added unit tests; Lesson viewer not supports iframe embeds * Lint fixes * added any to zod schemas --------- Co-authored-by: Rajat <hi@rajatsaxena.dev>
1 parent fa0885c commit 5b9830f

File tree

17 files changed

+2363
-1401
lines changed

17 files changed

+2363
-1401
lines changed
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,394 @@
1+
import React from "react";
2+
import { render, screen, fireEvent, waitFor } from "@testing-library/react";
3+
import { LessonContentRenderer } from "../lesson-content-renderer";
4+
import { Constants } from "@courselit/common-models";
5+
import { AddressContext, ProfileContext } from "@components/contexts";
6+
import "@testing-library/jest-dom";
7+
8+
// Mock dependencies
9+
jest.mock("@courselit/text-editor", () => {
10+
const React = jest.requireActual("react");
11+
return {
12+
Editor: ({ onChange, initialContent }: any) =>
13+
React.createElement(
14+
"div",
15+
{ "data-testid": "text-editor" },
16+
React.createElement("textarea", {
17+
"data-testid": "text-editor-input",
18+
onChange: (e: any) =>
19+
onChange({ type: "doc", content: e.target.value }),
20+
value: initialContent?.content || "",
21+
}),
22+
),
23+
emptyDoc: { type: "doc", content: [] },
24+
};
25+
});
26+
27+
jest.mock("@courselit/icons", () => {
28+
const React = jest.requireActual("react");
29+
return {
30+
ExpandLess: () =>
31+
React.createElement(
32+
"span",
33+
{ "data-testid": "icon-expand-less" },
34+
"ExpandLess",
35+
),
36+
ExpandMore: () =>
37+
React.createElement(
38+
"span",
39+
{ "data-testid": "icon-expand-more" },
40+
"ExpandMore",
41+
),
42+
};
43+
});
44+
45+
jest.mock("@ui-config/strings", () => ({
46+
LESSON_QUIZ_ADD_QUESTION: "Add Question",
47+
LESSON_QUIZ_QUESTION_PLACEHOLDER: "Question Placeholder",
48+
LESSON_QUIZ_ADD_OPTION_BTN: "Add Option",
49+
LESSON_QUIZ_CONTENT_HEADER: "Question",
50+
LESSON_QUIZ_OPTION_PLACEHOLDER: "Option Placeholder",
51+
QUESTION_BUILDER_COLLAPSE_TOOLTIP: "Collapse",
52+
QUESTION_BUILDER_CORRECT_ANS_TOOLTIP: "Correct Answer",
53+
QUESTION_BUILDER_DELETE_TOOLTIP: "Delete",
54+
QUESTION_BUILDER_EXPAND_TOOLTIP: "Expand",
55+
}));
56+
57+
jest.mock("@components/ui/button", () => {
58+
const React = jest.requireActual("react");
59+
return {
60+
Button: ({ onClick, children }: any) =>
61+
React.createElement(
62+
"button",
63+
{ onClick, "data-testid": "button" },
64+
children,
65+
),
66+
};
67+
});
68+
69+
jest.mock("@components/ui/label", () => {
70+
const React = jest.requireActual("react");
71+
return {
72+
Label: ({ children }: any) =>
73+
React.createElement("label", {}, children),
74+
};
75+
});
76+
77+
jest.mock("@components/ui/switch", () => {
78+
const React = jest.requireActual("react");
79+
return {
80+
Switch: ({ checked, onCheckedChange }: any) =>
81+
React.createElement("input", {
82+
type: "checkbox",
83+
checked,
84+
onChange: (e: any) => onCheckedChange(e.target.checked),
85+
"data-testid": "switch",
86+
}),
87+
};
88+
});
89+
90+
jest.mock("@components/ui/input", () => {
91+
const React = jest.requireActual("react");
92+
return {
93+
Input: (props: any) =>
94+
React.createElement("input", { ...props, "data-testid": "input" }),
95+
};
96+
});
97+
98+
jest.mock("lucide-react", () => {
99+
const React = jest.requireActual("react");
100+
return {
101+
Trash: () =>
102+
React.createElement(
103+
"span",
104+
{ "data-testid": "icon-trash" },
105+
"Trash",
106+
),
107+
};
108+
});
109+
110+
jest.mock("@courselit/components-library", () => {
111+
const React = jest.requireActual("react");
112+
return {
113+
MediaSelector: ({ onSelection, onRemove }: any) =>
114+
React.createElement(
115+
"div",
116+
{ "data-testid": "media-selector" },
117+
React.createElement(
118+
"button",
119+
{
120+
onClick: () =>
121+
onSelection({
122+
originalFileName: "test.mp4",
123+
mediaId: "123",
124+
}),
125+
},
126+
"Select Media",
127+
),
128+
React.createElement(
129+
"button",
130+
{ onClick: onRemove },
131+
"Remove Media",
132+
),
133+
),
134+
useToast: () => ({
135+
toast: jest.fn(),
136+
}),
137+
Section: ({ children }: any) =>
138+
React.createElement("div", { "data-testid": "section" }, children),
139+
Checkbox: ({ checked, onChange }: any) =>
140+
React.createElement("input", {
141+
type: "checkbox",
142+
checked,
143+
onChange: (e: any) => onChange(e.target.checked),
144+
"data-testid": "checkbox",
145+
}),
146+
IconButton: ({ onClick, children }: any) =>
147+
React.createElement(
148+
"button",
149+
{ onClick, "data-testid": "icon-button" },
150+
children,
151+
),
152+
Tooltip: ({ children, title }: any) =>
153+
React.createElement(
154+
"div",
155+
{ title, "data-testid": "tooltip" },
156+
children,
157+
),
158+
};
159+
});
160+
161+
jest.mock("@components/public/lesson-viewer/embed-viewer", () => {
162+
const React = jest.requireActual("react");
163+
return {
164+
__esModule: true,
165+
default: ({ content }: any) =>
166+
React.createElement(
167+
"div",
168+
{ "data-testid": "embed-viewer" },
169+
content.value,
170+
),
171+
};
172+
});
173+
174+
jest.mock("@courselit/utils", () => ({
175+
FetchBuilder: jest.fn().mockImplementation(() => ({
176+
setUrl: jest.fn().mockReturnThis(),
177+
setPayload: jest.fn().mockReturnThis(),
178+
setIsGraphQLEndpoint: jest.fn().mockReturnThis(),
179+
build: jest.fn().mockReturnThis(),
180+
exec: jest.fn().mockResolvedValue({}),
181+
})),
182+
}));
183+
184+
describe("LessonContentRenderer", () => {
185+
const mockOnContentChange = jest.fn();
186+
const mockOnLessonChange = jest.fn();
187+
const defaultProps = {
188+
lesson: {},
189+
errors: {},
190+
onContentChange: mockOnContentChange,
191+
onLessonChange: mockOnLessonChange,
192+
};
193+
194+
const wrapper = ({ children }: { children: React.ReactNode }) => (
195+
<AddressContext.Provider
196+
value={{ backend: "http://localhost:3000", frontend: "" }}
197+
>
198+
<ProfileContext.Provider
199+
value={
200+
{ profile: { userId: "1", email: "test@test.com" } } as any
201+
}
202+
>
203+
{children}
204+
</ProfileContext.Provider>
205+
</AddressContext.Provider>
206+
);
207+
208+
beforeEach(() => {
209+
jest.clearAllMocks();
210+
});
211+
212+
it("renders text editor for TEXT lesson type", () => {
213+
render(
214+
<LessonContentRenderer
215+
{...defaultProps}
216+
lesson={{ type: Constants.LessonType.TEXT }}
217+
/>,
218+
{ wrapper },
219+
);
220+
221+
expect(screen.getByTestId("text-editor")).toBeInTheDocument();
222+
});
223+
224+
it("renders embed viewer for EMBED lesson type", async () => {
225+
render(
226+
<LessonContentRenderer
227+
{...defaultProps}
228+
lesson={{
229+
type: Constants.LessonType.EMBED,
230+
content: {
231+
value: "https://youtube.com/watch?v=123",
232+
} as any,
233+
}}
234+
/>,
235+
{ wrapper },
236+
);
237+
238+
expect(
239+
screen.getByPlaceholderText(/e.g. YouTube video URL/i),
240+
).toBeInTheDocument();
241+
await waitFor(() => {
242+
expect(screen.getByTestId("embed-viewer")).toBeInTheDocument();
243+
});
244+
});
245+
246+
it("renders quiz builder for QUIZ lesson type", () => {
247+
render(
248+
<LessonContentRenderer
249+
{...defaultProps}
250+
lesson={{ type: Constants.LessonType.QUIZ }}
251+
/>,
252+
{ wrapper },
253+
);
254+
255+
expect(screen.getByText("Add Question")).toBeInTheDocument();
256+
});
257+
258+
it("renders media selector for VIDEO lesson type", () => {
259+
render(
260+
<LessonContentRenderer
261+
{...defaultProps}
262+
lesson={{
263+
type: Constants.LessonType.VIDEO,
264+
lessonId: "1",
265+
title: "Test Lesson",
266+
}}
267+
/>,
268+
{ wrapper },
269+
);
270+
271+
expect(screen.getByTestId("media-selector")).toBeInTheDocument();
272+
});
273+
274+
it("renders media selector for AUDIO lesson type", () => {
275+
render(
276+
<LessonContentRenderer
277+
{...defaultProps}
278+
lesson={{
279+
type: Constants.LessonType.AUDIO,
280+
lessonId: "1",
281+
title: "Test Lesson",
282+
}}
283+
/>,
284+
{ wrapper },
285+
);
286+
287+
expect(screen.getByTestId("media-selector")).toBeInTheDocument();
288+
});
289+
290+
it("renders media selector for PDF lesson type", () => {
291+
render(
292+
<LessonContentRenderer
293+
{...defaultProps}
294+
lesson={{
295+
type: Constants.LessonType.PDF,
296+
lessonId: "1",
297+
title: "Test Lesson",
298+
}}
299+
/>,
300+
{ wrapper },
301+
);
302+
303+
expect(screen.getByTestId("media-selector")).toBeInTheDocument();
304+
});
305+
306+
it("renders media selector for FILE lesson type", () => {
307+
render(
308+
<LessonContentRenderer
309+
{...defaultProps}
310+
lesson={{
311+
type: Constants.LessonType.FILE,
312+
lessonId: "1",
313+
title: "Test Lesson",
314+
}}
315+
/>,
316+
{ wrapper },
317+
);
318+
319+
expect(screen.getByTestId("media-selector")).toBeInTheDocument();
320+
});
321+
322+
it("handles content change in text editor", () => {
323+
render(
324+
<LessonContentRenderer
325+
{...defaultProps}
326+
lesson={{ type: Constants.LessonType.TEXT }}
327+
/>,
328+
{ wrapper },
329+
);
330+
331+
const input = screen.getByTestId("text-editor-input");
332+
fireEvent.change(input, { target: { value: "New content" } });
333+
334+
expect(mockOnContentChange).toHaveBeenCalledWith({
335+
type: "doc",
336+
content: "New content",
337+
});
338+
});
339+
340+
it("handles content change in embed url", () => {
341+
render(
342+
<LessonContentRenderer
343+
{...defaultProps}
344+
lesson={{ type: Constants.LessonType.EMBED }}
345+
/>,
346+
{ wrapper },
347+
);
348+
349+
const input = screen.getByPlaceholderText(/e.g. YouTube video URL/i);
350+
fireEvent.change(input, { target: { value: "https://new-url.com" } });
351+
352+
// The useEffect in the component triggers the change
353+
expect(mockOnContentChange).toHaveBeenCalledWith({
354+
value: "https://new-url.com",
355+
});
356+
});
357+
358+
it("renders quiz builder for QUIZ lesson type", () => {
359+
render(
360+
<LessonContentRenderer
361+
{...defaultProps}
362+
lesson={{ type: Constants.LessonType.QUIZ }}
363+
/>,
364+
{ wrapper },
365+
);
366+
367+
// Verify QuizBuilder is rendered (it contains "Add Question" button)
368+
expect(screen.getByText("Add Question")).toBeInTheDocument();
369+
});
370+
371+
it("handles media selection", async () => {
372+
render(
373+
<LessonContentRenderer
374+
{...defaultProps}
375+
lesson={{
376+
type: Constants.LessonType.VIDEO,
377+
lessonId: "1",
378+
title: "Test Lesson",
379+
}}
380+
/>,
381+
{ wrapper },
382+
);
383+
384+
fireEvent.click(screen.getByText("Select Media"));
385+
386+
expect(mockOnLessonChange).toHaveBeenCalledWith(
387+
expect.objectContaining({
388+
media: expect.objectContaining({
389+
originalFileName: "test.mp4",
390+
}),
391+
}),
392+
);
393+
});
394+
});

0 commit comments

Comments
 (0)