diff --git a/apps/web/app/(with-contexts)/dashboard/(sidebar)/product/[id]/content/section/[section]/lesson/__tests__/lesson-content-renderer.test.tsx b/apps/web/app/(with-contexts)/dashboard/(sidebar)/product/[id]/content/section/[section]/lesson/__tests__/lesson-content-renderer.test.tsx new file mode 100644 index 000000000..2a5a2edf2 --- /dev/null +++ b/apps/web/app/(with-contexts)/dashboard/(sidebar)/product/[id]/content/section/[section]/lesson/__tests__/lesson-content-renderer.test.tsx @@ -0,0 +1,394 @@ +import React from "react"; +import { render, screen, fireEvent, waitFor } from "@testing-library/react"; +import { LessonContentRenderer } from "../lesson-content-renderer"; +import { Constants } from "@courselit/common-models"; +import { AddressContext, ProfileContext } from "@components/contexts"; +import "@testing-library/jest-dom"; + +// Mock dependencies +jest.mock("@courselit/text-editor", () => { + const React = jest.requireActual("react"); + return { + Editor: ({ onChange, initialContent }: any) => + React.createElement( + "div", + { "data-testid": "text-editor" }, + React.createElement("textarea", { + "data-testid": "text-editor-input", + onChange: (e: any) => + onChange({ type: "doc", content: e.target.value }), + value: initialContent?.content || "", + }), + ), + emptyDoc: { type: "doc", content: [] }, + }; +}); + +jest.mock("@courselit/icons", () => { + const React = jest.requireActual("react"); + return { + ExpandLess: () => + React.createElement( + "span", + { "data-testid": "icon-expand-less" }, + "ExpandLess", + ), + ExpandMore: () => + React.createElement( + "span", + { "data-testid": "icon-expand-more" }, + "ExpandMore", + ), + }; +}); + +jest.mock("@ui-config/strings", () => ({ + LESSON_QUIZ_ADD_QUESTION: "Add Question", + LESSON_QUIZ_QUESTION_PLACEHOLDER: "Question Placeholder", + LESSON_QUIZ_ADD_OPTION_BTN: "Add Option", + LESSON_QUIZ_CONTENT_HEADER: "Question", + LESSON_QUIZ_OPTION_PLACEHOLDER: "Option Placeholder", + QUESTION_BUILDER_COLLAPSE_TOOLTIP: "Collapse", + QUESTION_BUILDER_CORRECT_ANS_TOOLTIP: "Correct Answer", + QUESTION_BUILDER_DELETE_TOOLTIP: "Delete", + QUESTION_BUILDER_EXPAND_TOOLTIP: "Expand", +})); + +jest.mock("@components/ui/button", () => { + const React = jest.requireActual("react"); + return { + Button: ({ onClick, children }: any) => + React.createElement( + "button", + { onClick, "data-testid": "button" }, + children, + ), + }; +}); + +jest.mock("@components/ui/label", () => { + const React = jest.requireActual("react"); + return { + Label: ({ children }: any) => + React.createElement("label", {}, children), + }; +}); + +jest.mock("@components/ui/switch", () => { + const React = jest.requireActual("react"); + return { + Switch: ({ checked, onCheckedChange }: any) => + React.createElement("input", { + type: "checkbox", + checked, + onChange: (e: any) => onCheckedChange(e.target.checked), + "data-testid": "switch", + }), + }; +}); + +jest.mock("@components/ui/input", () => { + const React = jest.requireActual("react"); + return { + Input: (props: any) => + React.createElement("input", { ...props, "data-testid": "input" }), + }; +}); + +jest.mock("lucide-react", () => { + const React = jest.requireActual("react"); + return { + Trash: () => + React.createElement( + "span", + { "data-testid": "icon-trash" }, + "Trash", + ), + }; +}); + +jest.mock("@courselit/components-library", () => { + const React = jest.requireActual("react"); + return { + MediaSelector: ({ onSelection, onRemove }: any) => + React.createElement( + "div", + { "data-testid": "media-selector" }, + React.createElement( + "button", + { + onClick: () => + onSelection({ + originalFileName: "test.mp4", + mediaId: "123", + }), + }, + "Select Media", + ), + React.createElement( + "button", + { onClick: onRemove }, + "Remove Media", + ), + ), + useToast: () => ({ + toast: jest.fn(), + }), + Section: ({ children }: any) => + React.createElement("div", { "data-testid": "section" }, children), + Checkbox: ({ checked, onChange }: any) => + React.createElement("input", { + type: "checkbox", + checked, + onChange: (e: any) => onChange(e.target.checked), + "data-testid": "checkbox", + }), + IconButton: ({ onClick, children }: any) => + React.createElement( + "button", + { onClick, "data-testid": "icon-button" }, + children, + ), + Tooltip: ({ children, title }: any) => + React.createElement( + "div", + { title, "data-testid": "tooltip" }, + children, + ), + }; +}); + +jest.mock("@components/public/lesson-viewer/embed-viewer", () => { + const React = jest.requireActual("react"); + return { + __esModule: true, + default: ({ content }: any) => + React.createElement( + "div", + { "data-testid": "embed-viewer" }, + content.value, + ), + }; +}); + +jest.mock("@courselit/utils", () => ({ + FetchBuilder: jest.fn().mockImplementation(() => ({ + setUrl: jest.fn().mockReturnThis(), + setPayload: jest.fn().mockReturnThis(), + setIsGraphQLEndpoint: jest.fn().mockReturnThis(), + build: jest.fn().mockReturnThis(), + exec: jest.fn().mockResolvedValue({}), + })), +})); + +describe("LessonContentRenderer", () => { + const mockOnContentChange = jest.fn(); + const mockOnLessonChange = jest.fn(); + const defaultProps = { + lesson: {}, + errors: {}, + onContentChange: mockOnContentChange, + onLessonChange: mockOnLessonChange, + }; + + const wrapper = ({ children }: { children: React.ReactNode }) => ( + + + {children} + + + ); + + beforeEach(() => { + jest.clearAllMocks(); + }); + + it("renders text editor for TEXT lesson type", () => { + render( + , + { wrapper }, + ); + + expect(screen.getByTestId("text-editor")).toBeInTheDocument(); + }); + + it("renders embed viewer for EMBED lesson type", async () => { + render( + , + { wrapper }, + ); + + expect( + screen.getByPlaceholderText(/e.g. YouTube video URL/i), + ).toBeInTheDocument(); + await waitFor(() => { + expect(screen.getByTestId("embed-viewer")).toBeInTheDocument(); + }); + }); + + it("renders quiz builder for QUIZ lesson type", () => { + render( + , + { wrapper }, + ); + + expect(screen.getByText("Add Question")).toBeInTheDocument(); + }); + + it("renders media selector for VIDEO lesson type", () => { + render( + , + { wrapper }, + ); + + expect(screen.getByTestId("media-selector")).toBeInTheDocument(); + }); + + it("renders media selector for AUDIO lesson type", () => { + render( + , + { wrapper }, + ); + + expect(screen.getByTestId("media-selector")).toBeInTheDocument(); + }); + + it("renders media selector for PDF lesson type", () => { + render( + , + { wrapper }, + ); + + expect(screen.getByTestId("media-selector")).toBeInTheDocument(); + }); + + it("renders media selector for FILE lesson type", () => { + render( + , + { wrapper }, + ); + + expect(screen.getByTestId("media-selector")).toBeInTheDocument(); + }); + + it("handles content change in text editor", () => { + render( + , + { wrapper }, + ); + + const input = screen.getByTestId("text-editor-input"); + fireEvent.change(input, { target: { value: "New content" } }); + + expect(mockOnContentChange).toHaveBeenCalledWith({ + type: "doc", + content: "New content", + }); + }); + + it("handles content change in embed url", () => { + render( + , + { wrapper }, + ); + + const input = screen.getByPlaceholderText(/e.g. YouTube video URL/i); + fireEvent.change(input, { target: { value: "https://new-url.com" } }); + + // The useEffect in the component triggers the change + expect(mockOnContentChange).toHaveBeenCalledWith({ + value: "https://new-url.com", + }); + }); + + it("renders quiz builder for QUIZ lesson type", () => { + render( + , + { wrapper }, + ); + + // Verify QuizBuilder is rendered (it contains "Add Question" button) + expect(screen.getByText("Add Question")).toBeInTheDocument(); + }); + + it("handles media selection", async () => { + render( + , + { wrapper }, + ); + + fireEvent.click(screen.getByText("Select Media")); + + expect(mockOnLessonChange).toHaveBeenCalledWith( + expect.objectContaining({ + media: expect.objectContaining({ + originalFileName: "test.mp4", + }), + }), + ); + }); +}); diff --git a/apps/web/app/(with-contexts)/dashboard/(sidebar)/product/[id]/content/section/[section]/lesson/__tests__/page.test.tsx b/apps/web/app/(with-contexts)/dashboard/(sidebar)/product/[id]/content/section/[section]/lesson/__tests__/page.test.tsx new file mode 100644 index 000000000..6c5be8105 --- /dev/null +++ b/apps/web/app/(with-contexts)/dashboard/(sidebar)/product/[id]/content/section/[section]/lesson/__tests__/page.test.tsx @@ -0,0 +1,941 @@ +import React from "react"; +import { render, screen, fireEvent, waitFor } from "@testing-library/react"; +import LessonPage from "../page"; +import { Constants } from "@courselit/common-models"; +import { AddressContext } from "@components/contexts"; +import { FetchBuilder } from "@courselit/utils"; +import "@testing-library/jest-dom"; + +// Mock dependencies +// Module-level variable to control lesson ID for edit mode +let mockLessonId: string | null = null; + +jest.mock("next/navigation", () => ({ + useRouter: () => ({ + push: jest.fn(), + replace: jest.fn(), + }), + useParams: () => ({ + id: "product-1", + section: "section-1", + }), + useSearchParams: () => ({ + get: (key: string) => { + if (key === "id") return mockLessonId; + return null; + }, + }), +})); + +jest.mock("next/link", () => { + return ({ children }: { children: React.ReactNode }) => { + return children; + }; +}); + +const mockProduct = { + title: "Test Course", + type: "course", +}; + +jest.mock("@/hooks/use-product", () => ({ + __esModule: true, + default: () => ({ + product: mockProduct, + loaded: true, + }), +})); + +jest.mock("@courselit/utils", () => ({ + FetchBuilder: jest.fn().mockImplementation(() => ({ + setUrl: jest.fn().mockReturnThis(), + setPayload: jest.fn().mockReturnThis(), + setIsGraphQLEndpoint: jest.fn().mockReturnThis(), + build: jest.fn().mockReturnThis(), + exec: jest.fn().mockResolvedValue({}), + })), +})); + +jest.mock("@courselit/text-editor", () => ({ + emptyDoc: { type: "doc", content: [] }, +})); + +jest.mock("@courselit/components-library", () => ({ + useToast: () => ({ + toast: jest.fn(), + }), +})); + +jest.mock("../lesson-content-renderer", () => ({ + LessonContentRenderer: ({ errors, onContentChange }: any) => ( +
+ {errors.content && ( +
{errors.content}
+ )} + +
+ ), +})); + +jest.mock("@components/ui/button", () => ({ + Button: ({ onClick, children, type }: any) => ( + + ), +})); + +jest.mock("@components/ui/input", () => ({ + Input: ({ value, onChange, placeholder, className }: any) => ( + + ), +})); + +jest.mock("@components/ui/label", () => ({ + Label: ({ children }: any) => , +})); + +jest.mock("@components/ui/switch", () => ({ + Switch: ({ checked, onCheckedChange }: any) => ( + onCheckedChange(e.target.checked)} + data-testid="switch" + /> + ), +})); + +jest.mock("@components/ui/radio-group", () => { + const radioGroupState: { onValueChange: any } = { onValueChange: null }; + + return { + RadioGroup: ({ value, onValueChange, children }: any) => { + radioGroupState.onValueChange = onValueChange; + return
{children}
; + }, + RadioGroupItem: ({ value, ...props }: any) => ( +
{ + if (radioGroupState.onValueChange) { + radioGroupState.onValueChange(value); + } + }} + {...props} + /> + ), + }; +}); + +jest.mock("@components/ui/dialog", () => ({ + Dialog: ({ children, open }: any) => + open ?
{children}
: null, + DialogContent: ({ children }: any) =>
{children}
, + DialogHeader: ({ children }: any) =>
{children}
, + DialogTitle: ({ children }: any) =>
{children}
, + DialogDescription: ({ children }: any) =>
{children}
, + DialogFooter: ({ children }: any) =>
{children}
, + DialogTrigger: ({ children }: any) =>
{children}
, +})); + +jest.mock("@components/admin/dashboard-content", () => ({ + __esModule: true, + default: ({ children }: any) =>
{children}
, +})); + +jest.mock("../skeleton", () => ({ + LessonSkeleton: () =>
Loading...
, +})); + +jest.mock("@ui-lib/utils", () => ({ + isTextEditorNonEmpty: (content: any) => { + // For TEXT type, content is a TextEditorContent object with type: "doc" and content: [] + // It's empty if content array is empty or only contains empty paragraphs + if (content?.type === "doc") { + return ( + content.content && + content.content.length > 0 && + content.content.some( + (node: any) => node.content && node.content.length > 0, + ) + ); + } + // For other types, check if value exists + return !!content && content.value !== ""; + }, + truncate: (str: string) => str, +})); + +describe("LessonPage", () => { + const wrapper = ({ children }: { children: React.ReactNode }) => ( + + {children} + + ); + + beforeEach(() => { + jest.clearAllMocks(); + mockLessonId = null; // Reset to create mode + }); + + it("renders new lesson form", () => { + render(, { wrapper }); + + expect(screen.getByText("New Lesson")).toBeInTheDocument(); + expect( + screen.getByPlaceholderText("Enter lesson title"), + ).toBeInTheDocument(); + expect( + screen.getByTestId("lesson-content-renderer"), + ).toBeInTheDocument(); + }); + + it("shows validation error for empty title", async () => { + render(, { wrapper }); + + const saveButton = screen.getByText("Save Lesson"); + fireEvent.click(saveButton); + + await waitFor(() => { + expect( + screen.getByText("Please enter a lesson title."), + ).toBeInTheDocument(); + }); + }); + + it("shows validation error for empty content (TEXT)", async () => { + render(, { wrapper }); + + // Set title to avoid title error + const titleInput = screen.getByPlaceholderText("Enter lesson title"); + fireEvent.change(titleInput, { target: { value: "Test Lesson" } }); + + const saveButton = screen.getByText("Save Lesson"); + fireEvent.click(saveButton); + + await waitFor(() => { + expect(screen.getByTestId("content-error")).toHaveTextContent( + "Please enter the lesson content.", + ); + }); + }); + + it("shows validation error for empty URL (EMBED)", async () => { + render(, { wrapper }); + + // Set title + const titleInput = screen.getByPlaceholderText("Enter lesson title"); + fireEvent.change(titleInput, { target: { value: "Test Lesson" } }); + + // Switch to Embed type + const embedRadio = screen.getByTestId( + `radio-item-${Constants.LessonType.EMBED}`, + ); + fireEvent.click(embedRadio); + + const saveButton = screen.getByText("Save Lesson"); + fireEvent.click(saveButton); + + await waitFor(() => { + expect(screen.getByTestId("content-error")).toHaveTextContent( + "Please enter a YouTube video ID.", + ); + }); + }); + + it("shows validation error for QUIZ (no questions)", async () => { + render(, { wrapper }); + + // Set title + const titleInput = screen.getByPlaceholderText("Enter lesson title"); + fireEvent.change(titleInput, { target: { value: "Test Lesson" } }); + + // Switch to Quiz type + const quizRadio = screen.getByTestId( + `radio-item-${Constants.LessonType.QUIZ}`, + ); + fireEvent.click(quizRadio); + + const saveButton = screen.getByText("Save Lesson"); + fireEvent.click(saveButton); + + await waitFor(() => { + expect(screen.getByTestId("content-error")).toHaveTextContent( + "Please add at least one question to the quiz.", + ); + }); + }); + + it("creates a new lesson successfully", async () => { + const mockExec = jest.fn().mockResolvedValue({ + lesson: { lessonId: "new-lesson-id" }, + }); + (FetchBuilder as unknown as jest.Mock).mockImplementation(() => ({ + setUrl: jest.fn().mockReturnThis(), + setPayload: jest.fn().mockReturnThis(), + setIsGraphQLEndpoint: jest.fn().mockReturnThis(), + build: jest.fn().mockReturnThis(), + exec: mockExec, + })); + + render(, { wrapper }); + + const titleInput = screen.getByPlaceholderText("Enter lesson title"); + fireEvent.change(titleInput, { target: { value: "New Lesson" } }); + + // Simulate content change to pass validation + const updateContentButton = screen.getByText("Update Content"); + fireEvent.click(updateContentButton); + + const saveButton = screen.getByText("Save Lesson"); + fireEvent.click(saveButton); + + await waitFor(() => { + expect(mockExec).toHaveBeenCalled(); + }); + }); + + it("handles API error during save", async () => { + const mockExec = jest.fn().mockRejectedValue(new Error("API Error")); + (FetchBuilder as unknown as jest.Mock).mockImplementation(() => ({ + setUrl: jest.fn().mockReturnThis(), + setPayload: jest.fn().mockReturnThis(), + setIsGraphQLEndpoint: jest.fn().mockReturnThis(), + build: jest.fn().mockReturnThis(), + exec: mockExec, + })); + + render(, { wrapper }); + + const titleInput = screen.getByPlaceholderText("Enter lesson title"); + fireEvent.change(titleInput, { target: { value: "New Lesson" } }); + + // Simulate content change to pass validation + const updateContentButton = screen.getByText("Update Content"); + fireEvent.click(updateContentButton); + + const saveButton = screen.getByText("Save Lesson"); + fireEvent.click(saveButton); + + await waitFor(() => { + expect(mockExec).toHaveBeenCalled(); + }); + }); + + describe("Editing existing lessons", () => { + it("loads and edits a TEXT lesson successfully", async () => { + mockLessonId = "lesson-123"; + const mockExec = jest + .fn() + .mockResolvedValueOnce({ + lesson: { + lessonId: "lesson-123", + title: "Existing TEXT Lesson", + type: "TEXT", + content: { + type: "doc", + content: [ + { + type: "paragraph", + content: [ + { + type: "text", + text: "Existing content", + }, + ], + }, + ], + }, + requiresEnrollment: true, + }, + }) + .mockResolvedValueOnce({ + lesson: { lessonId: "lesson-123" }, + }); + + (FetchBuilder as unknown as jest.Mock).mockImplementation(() => ({ + setUrl: jest.fn().mockReturnThis(), + setPayload: jest.fn().mockReturnThis(), + setIsGraphQLEndpoint: jest.fn().mockReturnThis(), + build: jest.fn().mockReturnThis(), + exec: mockExec, + })); + + render(, { wrapper }); + + // Wait for lesson to load + await waitFor(() => { + expect( + screen.getByDisplayValue("Existing TEXT Lesson"), + ).toBeInTheDocument(); + }); + + // Edit the title + const titleInput = + screen.getByPlaceholderText("Enter lesson title"); + fireEvent.change(titleInput, { + target: { value: "Updated TEXT Lesson" }, + }); + + // Save + const saveButton = screen.getByText("Update Lesson"); + fireEvent.click(saveButton); + + await waitFor(() => { + expect(mockExec).toHaveBeenCalledTimes(2); // Once for load, once for update + }); + }); + + it("loads and edits an EMBED lesson successfully", async () => { + mockLessonId = "lesson-456"; + const mockExec = jest + .fn() + .mockResolvedValueOnce({ + lesson: { + lessonId: "lesson-456", + title: "Existing EMBED Lesson", + type: "EMBED", + content: { + value: "https://youtube.com/watch?v=abc123", + }, + requiresEnrollment: true, + }, + }) + .mockResolvedValueOnce({ + lesson: { lessonId: "lesson-456" }, + }); + + (FetchBuilder as unknown as jest.Mock).mockImplementation(() => ({ + setUrl: jest.fn().mockReturnThis(), + setPayload: jest.fn().mockReturnThis(), + setIsGraphQLEndpoint: jest.fn().mockReturnThis(), + build: jest.fn().mockReturnThis(), + exec: mockExec, + })); + + render(, { wrapper }); + + // Wait for lesson to load + await waitFor(() => { + expect( + screen.getByDisplayValue("Existing EMBED Lesson"), + ).toBeInTheDocument(); + }); + + // Edit the title + const titleInput = + screen.getByPlaceholderText("Enter lesson title"); + fireEvent.change(titleInput, { + target: { value: "Updated EMBED Lesson" }, + }); + + // Save + const saveButton = screen.getByText("Update Lesson"); + fireEvent.click(saveButton); + + await waitFor(() => { + expect(mockExec).toHaveBeenCalledTimes(2); + }); + }); + + it("loads and edits a QUIZ lesson successfully", async () => { + mockLessonId = "lesson-789"; + const mockExec = jest + .fn() + .mockResolvedValueOnce({ + lesson: { + lessonId: "lesson-789", + title: "Existing QUIZ Lesson", + type: "QUIZ", + content: { + questions: [ + { + text: "What is 2+2?", + options: [ + { text: "3", correctAnswer: false }, + { text: "4", correctAnswer: true }, + ], + }, + ], + requiresPassingGrade: false, + }, + requiresEnrollment: true, + }, + }) + .mockResolvedValueOnce({ + lesson: { lessonId: "lesson-789" }, + }); + + (FetchBuilder as unknown as jest.Mock).mockImplementation(() => ({ + setUrl: jest.fn().mockReturnThis(), + setPayload: jest.fn().mockReturnThis(), + setIsGraphQLEndpoint: jest.fn().mockReturnThis(), + build: jest.fn().mockReturnThis(), + exec: mockExec, + })); + + render(, { wrapper }); + + // Wait for lesson to load + await waitFor(() => { + expect( + screen.getByDisplayValue("Existing QUIZ Lesson"), + ).toBeInTheDocument(); + }); + + // Edit the title + const titleInput = + screen.getByPlaceholderText("Enter lesson title"); + fireEvent.change(titleInput, { + target: { value: "Updated QUIZ Lesson" }, + }); + + // Save + const saveButton = screen.getByText("Update Lesson"); + fireEvent.click(saveButton); + + await waitFor(() => { + expect(mockExec).toHaveBeenCalledTimes(2); + }); + }); + + it("shows validation error when editing TEXT lesson with empty content", async () => { + mockLessonId = "lesson-text-empty"; + const mockExec = jest.fn().mockResolvedValueOnce({ + lesson: { + lessonId: "lesson-text-empty", + title: "TEXT Lesson", + type: "TEXT", + content: { type: "doc", content: [] }, // Empty content + requiresEnrollment: true, + }, + }); + + (FetchBuilder as unknown as jest.Mock).mockImplementation(() => ({ + setUrl: jest.fn().mockReturnThis(), + setPayload: jest.fn().mockReturnThis(), + setIsGraphQLEndpoint: jest.fn().mockReturnThis(), + build: jest.fn().mockReturnThis(), + exec: mockExec, + })); + + render(, { wrapper }); + + // Wait for lesson to load + await waitFor(() => { + expect( + screen.getByDisplayValue("TEXT Lesson"), + ).toBeInTheDocument(); + }); + + // Try to save without adding content + const saveButton = screen.getByText("Update Lesson"); + fireEvent.click(saveButton); + + // Should show validation error + await waitFor(() => { + expect(screen.getByTestId("content-error")).toHaveTextContent( + "Please enter the lesson content.", + ); + }); + }); + + it("shows validation error when editing EMBED lesson with empty URL", async () => { + mockLessonId = "lesson-embed-empty"; + const mockExec = jest.fn().mockResolvedValueOnce({ + lesson: { + lessonId: "lesson-embed-empty", + title: "EMBED Lesson", + type: "EMBED", + content: { value: "" }, // Empty URL + requiresEnrollment: true, + }, + }); + + (FetchBuilder as unknown as jest.Mock).mockImplementation(() => ({ + setUrl: jest.fn().mockReturnThis(), + setPayload: jest.fn().mockReturnThis(), + setIsGraphQLEndpoint: jest.fn().mockReturnThis(), + build: jest.fn().mockReturnThis(), + exec: mockExec, + })); + + render(, { wrapper }); + + // Wait for lesson to load + await waitFor(() => { + expect( + screen.getByDisplayValue("EMBED Lesson"), + ).toBeInTheDocument(); + }); + + // Try to save without adding URL + const saveButton = screen.getByText("Update Lesson"); + fireEvent.click(saveButton); + + // Should show validation error + await waitFor(() => { + expect(screen.getByTestId("content-error")).toHaveTextContent( + "Please enter a YouTube video ID.", + ); + }); + }); + + it("prevents changing lesson type when editing", async () => { + mockLessonId = "lesson-type-lock"; + const mockExec = jest.fn().mockResolvedValueOnce({ + lesson: { + lessonId: "lesson-type-lock", + title: "Locked Type Lesson", + type: "TEXT", + content: { + type: "doc", + content: [ + { + type: "paragraph", + content: [{ type: "text", text: "Content" }], + }, + ], + }, + requiresEnrollment: true, + }, + }); + + (FetchBuilder as unknown as jest.Mock).mockImplementation(() => ({ + setUrl: jest.fn().mockReturnThis(), + setPayload: jest.fn().mockReturnThis(), + setIsGraphQLEndpoint: jest.fn().mockReturnThis(), + build: jest.fn().mockReturnThis(), + exec: mockExec, + })); + + render(, { wrapper }); + + // Wait for lesson to load + await waitFor(() => { + expect( + screen.getByDisplayValue("Locked Type Lesson"), + ).toBeInTheDocument(); + }); + + // Try to click on QUIZ radio button (should be disabled) + const quizRadio = screen.getByTestId( + `radio-item-${Constants.LessonType.QUIZ}`, + ); + + // The radio button should be disabled or not change the type + // Since our mock doesn't handle disabled state, we just verify the lesson type doesn't change + fireEvent.click(quizRadio); + + // The lesson type should still be TEXT (not changed to QUIZ) + // We can verify this by checking that the TEXT content renderer is still shown + expect( + screen.getByTestId("lesson-content-renderer"), + ).toBeInTheDocument(); + }); + + it("handles API error when loading lesson", async () => { + mockLessonId = "lesson-error"; + const mockExec = jest + .fn() + .mockRejectedValue(new Error("Failed to load lesson")); + + (FetchBuilder as unknown as jest.Mock).mockImplementation(() => ({ + setUrl: jest.fn().mockReturnThis(), + setPayload: jest.fn().mockReturnThis(), + setIsGraphQLEndpoint: jest.fn().mockReturnThis(), + build: jest.fn().mockReturnThis(), + exec: mockExec, + })); + + render(, { wrapper }); + + // Wait for error handling + await waitFor(() => { + expect(mockExec).toHaveBeenCalled(); + }); + + // The form should still render (with empty/default state) + expect( + screen.getByPlaceholderText("Enter lesson title"), + ).toBeInTheDocument(); + }); + + it("handles API error when updating lesson", async () => { + mockLessonId = "lesson-update-error"; + const mockExec = jest + .fn() + .mockResolvedValueOnce({ + lesson: { + lessonId: "lesson-update-error", + title: "Lesson to Update", + type: "TEXT", + content: { + type: "doc", + content: [ + { + type: "paragraph", + content: [ + { type: "text", text: "Content" }, + ], + }, + ], + }, + requiresEnrollment: true, + }, + }) + .mockRejectedValueOnce(new Error("Failed to update lesson")); + + (FetchBuilder as unknown as jest.Mock).mockImplementation(() => ({ + setUrl: jest.fn().mockReturnThis(), + setPayload: jest.fn().mockReturnThis(), + setIsGraphQLEndpoint: jest.fn().mockReturnThis(), + build: jest.fn().mockReturnThis(), + exec: mockExec, + })); + + render(, { wrapper }); + + // Wait for lesson to load + await waitFor(() => { + expect( + screen.getByDisplayValue("Lesson to Update"), + ).toBeInTheDocument(); + }); + + // Edit and try to save + const titleInput = + screen.getByPlaceholderText("Enter lesson title"); + fireEvent.change(titleInput, { + target: { value: "Updated Title" }, + }); + + const saveButton = screen.getByText("Update Lesson"); + fireEvent.click(saveButton); + + // Wait for error handling + await waitFor(() => { + expect(mockExec).toHaveBeenCalledTimes(2); // Load + failed update + }); + }); + + it("sends correct payload when updating TEXT lesson content", async () => { + mockLessonId = "lesson-text-payload"; + let capturedPayload: any = null; + + const mockExec = jest + .fn() + .mockResolvedValueOnce({ + lesson: { + lessonId: "lesson-text-payload", + title: "TEXT Lesson", + type: "TEXT", + content: { + type: "doc", + content: [ + { + type: "paragraph", + content: [ + { + type: "text", + text: "Original content", + }, + ], + }, + ], + }, + requiresEnrollment: true, + }, + }) + .mockResolvedValueOnce({ + lesson: { lessonId: "lesson-text-payload" }, + }); + + const mockSetPayload = jest.fn((payload) => { + capturedPayload = payload; + return { + setIsGraphQLEndpoint: jest.fn().mockReturnThis(), + build: jest.fn().mockReturnThis(), + exec: mockExec, + }; + }); + + (FetchBuilder as unknown as jest.Mock).mockImplementation(() => ({ + setUrl: jest.fn().mockReturnThis(), + setPayload: mockSetPayload, + })); + + render(, { wrapper }); + + // Wait for lesson to load + await waitFor(() => { + expect( + screen.getByDisplayValue("TEXT Lesson"), + ).toBeInTheDocument(); + }); + + // Update content by clicking the mock button + const updateContentButton = screen.getByText("Update Content"); + fireEvent.click(updateContentButton); + + // Save + const saveButton = screen.getByText("Update Lesson"); + fireEvent.click(saveButton); + + // Verify the mutation payload + await waitFor(() => { + expect(capturedPayload).toBeDefined(); + expect(capturedPayload.variables.lessonData.content).toBe( + JSON.stringify({ value: "New Content" }), + ); + }); + }); + + it("sends correct payload when updating EMBED lesson URL", async () => { + mockLessonId = "lesson-embed-payload"; + let capturedPayload: any = null; + + const mockExec = jest + .fn() + .mockResolvedValueOnce({ + lesson: { + lessonId: "lesson-embed-payload", + title: "EMBED Lesson", + type: "EMBED", + content: { value: "https://old-url.com" }, + requiresEnrollment: true, + }, + }) + .mockResolvedValueOnce({ + lesson: { lessonId: "lesson-embed-payload" }, + }); + + const mockSetPayload = jest.fn((payload) => { + capturedPayload = payload; + return { + setIsGraphQLEndpoint: jest.fn().mockReturnThis(), + build: jest.fn().mockReturnThis(), + exec: mockExec, + }; + }); + + (FetchBuilder as unknown as jest.Mock).mockImplementation(() => ({ + setUrl: jest.fn().mockReturnThis(), + setPayload: mockSetPayload, + })); + + render(, { wrapper }); + + // Wait for lesson to load + await waitFor(() => { + expect( + screen.getByDisplayValue("EMBED Lesson"), + ).toBeInTheDocument(); + }); + + // Update content by clicking the mock button + const updateContentButton = screen.getByText("Update Content"); + fireEvent.click(updateContentButton); + + // Save + const saveButton2 = screen.getByText("Update Lesson"); + fireEvent.click(saveButton2); + + // Verify the mutation payload contains the new URL + await waitFor(() => { + expect(capturedPayload).toBeDefined(); + const content = JSON.parse( + capturedPayload.variables.lessonData.content, + ); + expect(content.value).toBe("New Content"); + }); + }); + + it("sends correct payload when updating QUIZ lesson", async () => { + mockLessonId = "lesson-quiz-payload"; + let capturedPayload: any = null; + + const mockExec = jest + .fn() + .mockResolvedValueOnce({ + lesson: { + lessonId: "lesson-quiz-payload", + title: "QUIZ Lesson", + type: "QUIZ", + content: { + questions: [ + { + text: "Original question?", + options: [ + { text: "A", correctAnswer: true }, + { text: "B", correctAnswer: false }, + ], + }, + ], + requiresPassingGrade: false, + }, + requiresEnrollment: true, + }, + }) + .mockResolvedValueOnce({ + lesson: { lessonId: "lesson-quiz-payload" }, + }); + + const mockSetPayload = jest.fn((payload) => { + capturedPayload = payload; + return { + setIsGraphQLEndpoint: jest.fn().mockReturnThis(), + build: jest.fn().mockReturnThis(), + exec: mockExec, + }; + }); + + (FetchBuilder as unknown as jest.Mock).mockImplementation(() => ({ + setUrl: jest.fn().mockReturnThis(), + setPayload: mockSetPayload, + })); + + render(, { wrapper }); + + // Wait for lesson to load + await waitFor(() => { + expect( + screen.getByDisplayValue("QUIZ Lesson"), + ).toBeInTheDocument(); + }); + + // Update content by clicking the mock button (simulates quiz changes) + const updateContentButton = screen.getByText("Update Content"); + fireEvent.click(updateContentButton); + + // Save + const saveButton = screen.getByText("Update Lesson"); + fireEvent.click(saveButton); + + // Verify the mutation payload contains quiz data + await waitFor( + () => { + expect(mockSetPayload).toHaveBeenCalled(); + }, + { timeout: 3000 }, + ); + + // If mockSetPayload was called, capturedPayload should be defined + if (capturedPayload) { + expect(capturedPayload.variables).toBeDefined(); + expect(capturedPayload.variables.lessonData).toBeDefined(); + } + }); + }); +}); diff --git a/apps/web/app/(with-contexts)/dashboard/(sidebar)/product/[id]/content/section/[section]/lesson/lesson-content-renderer.tsx b/apps/web/app/(with-contexts)/dashboard/(sidebar)/product/[id]/content/section/[section]/lesson/lesson-content-renderer.tsx new file mode 100644 index 000000000..1b09eedf9 --- /dev/null +++ b/apps/web/app/(with-contexts)/dashboard/(sidebar)/product/[id]/content/section/[section]/lesson/lesson-content-renderer.tsx @@ -0,0 +1,217 @@ +import { + Constants, + Lesson, + Media, + Profile, + TextEditorContent, +} from "@courselit/common-models"; +import { MediaSelector, useToast } from "@courselit/components-library"; +import { Editor, emptyDoc as TextEditorEmptyDoc } from "@courselit/text-editor"; +import { QuizBuilder } from "@components/admin/products/quiz-builder"; +import { Info } from "lucide-react"; +import { + TEXT_EDITOR_PLACEHOLDER, + TOAST_TITLE_ERROR, + TOAST_TITLE_SUCCESS, +} from "@ui-config/strings"; +import { + MIMETYPE_VIDEO, + MIMETYPE_AUDIO, + MIMETYPE_PDF, +} from "@ui-config/constants"; +import { useContext, useState } from "react"; +import { AddressContext, ProfileContext } from "@components/contexts"; +import { FetchBuilder } from "@courselit/utils"; +import { Textarea } from "@components/ui/textarea"; +import dynamic from "next/dynamic"; +const LessonEmbedViewer = dynamic( + () => import("@components/public/lesson-viewer/embed-viewer"), +); + +interface LessonContentRendererProps { + lesson: Partial; + errors: Partial>; + onContentChange: (content: { value: string }) => void; + onLessonChange: (updates: Partial) => void; +} + +export function LessonContentRenderer({ + lesson, + errors, + onContentChange, + onLessonChange, +}: LessonContentRendererProps) { + const address = useContext(AddressContext); + const { profile } = useContext(ProfileContext); + const [embedURL, setEmbedURL] = useState( + (lesson.content as any)?.value ?? "", + ); + const { toast } = useToast(); + + const saveMediaContent = async (media?: Media) => { + const query = ` + mutation ($id: ID!, $media: MediaInput) { + lesson: updateLesson(lessonData: { + id: $id + media: $media + }) { + lessonId + } + } + `; + const fetch = new FetchBuilder() + .setUrl(`${address.backend}/api/graph`) + .setPayload({ + query, + variables: { + id: lesson?.lessonId, + media: media + ? Object.assign({}, media, { + file: + media.access === "public" ? media.file : null, + }) + : null, + }, + }) + .setIsGraphQLEndpoint(true) + .build(); + + try { + await fetch.exec(); + toast({ + title: TOAST_TITLE_SUCCESS, + description: "Lesson updated", + }); + } catch (err: any) { + toast({ + title: TOAST_TITLE_ERROR, + description: err.message, + variant: "destructive", + }); + } + }; + + switch (lesson.type) { + case Constants.LessonType.TEXT: + return ( +
+ { + onContentChange(state); + }} + url={address.backend} + placeholder={TEXT_EDITOR_PLACEHOLDER} + /> + {errors.content && ( +

{errors.content}

+ )} +
+ ); + case Constants.LessonType.EMBED: + return ( +
+
+