From c1ac4d790a6373242a8bb8d74b81455cc849e2a6 Mon Sep 17 00:00:00 2001 From: smattymatty Date: Fri, 13 Feb 2026 08:40:02 -0500 Subject: [PATCH] build add module modal UX --- .../src/components/courses/AddModuleModal.tsx | 625 +++++++++++++++++- .../src/components/courses/ModulesTab.tsx | 2 + .../courses/__tests__/AddModuleModal.test.tsx | 518 +++++++++++++++ .../EduLiteFrontend/src/i18n/locales/ar.json | 31 +- .../EduLiteFrontend/src/i18n/locales/en.json | 31 +- .../src/services/slideshowApi.ts | 63 +- 6 files changed, 1216 insertions(+), 54 deletions(-) create mode 100644 Frontend/EduLiteFrontend/src/components/courses/__tests__/AddModuleModal.test.tsx diff --git a/Frontend/EduLiteFrontend/src/components/courses/AddModuleModal.tsx b/Frontend/EduLiteFrontend/src/components/courses/AddModuleModal.tsx index 838417d..1b843d0 100644 --- a/Frontend/EduLiteFrontend/src/components/courses/AddModuleModal.tsx +++ b/Frontend/EduLiteFrontend/src/components/courses/AddModuleModal.tsx @@ -1,6 +1,22 @@ +import { useState, useEffect, useRef } from "react"; import { createPortal } from "react-dom"; import { useTranslation } from "react-i18next"; -import { HiCubeTransparent } from "react-icons/hi2"; +import { Link } from "react-router-dom"; +import { + HiArrowLeft, + HiMagnifyingGlass, + HiPresentationChartBar, + HiDocumentText, + HiClipboardDocumentList, + HiAcademicCap, + HiCheck, +} from "react-icons/hi2"; +import { useDebounce } from "../../hooks/useDebounce"; +import { + searchSlideshows, + listMySlideshows, +} from "../../services/slideshowApi"; +import type { SlideshowListItem } from "../../types/slideshow.types"; import type { CourseModule } from "../../types/courses.types"; interface AddModuleModalProps { @@ -13,40 +29,605 @@ interface AddModuleModalProps { order?: number; }) => Promise; editModule?: CourseModule | null; + existingModules?: CourseModule[]; } -const AddModuleModal: React.FC = ({ isOpen, onClose }) => { +type Step = "type" | "search" | "confirm"; + +const MODULE_TYPES = [ + { + key: "slideshow", + contentType: "slideshows.slideshow", + icon: HiPresentationChartBar, + enabled: true, + }, + { + key: "quiz", + contentType: "", + icon: HiClipboardDocumentList, + enabled: false, + }, + { + key: "notes", + contentType: "", + icon: HiDocumentText, + enabled: false, + }, + { + key: "assignment", + contentType: "", + icon: HiAcademicCap, + enabled: false, + }, +] as const; + +const AddModuleModal: React.FC = ({ + isOpen, + onClose, + onSubmit, + editModule, + existingModules = [], +}) => { const { t } = useTranslation(); + const isEditMode = !!editModule; + + // Step management + const [step, setStep] = useState("type"); + const [selectedType, setSelectedType] = useState(""); + + // Search state + const [query, setQuery] = useState(""); + const debouncedQuery = useDebounce(query, 300); + const [mineOnly, setMineOnly] = useState(true); + const [results, setResults] = useState([]); + const [isLoading, setIsLoading] = useState(false); + const [searchError, setSearchError] = useState(null); + + // Selection state + const [selectedItem, setSelectedItem] = useState( + null, + ); + const [moduleTitle, setModuleTitle] = useState(""); + + // Submit state + const [isSubmitting, setIsSubmitting] = useState(false); + const [submitError, setSubmitError] = useState(null); + + const inputRef = useRef(null); + + // Build a set of already-used object IDs for the current content type + const existingObjectIds = new Set( + existingModules + .filter( + (m) => m.content_type === (selectedType || "slideshows.slideshow"), + ) + .map((m) => m.object_id), + ); + + // Reset state when modal opens/closes + useEffect(() => { + if (isOpen) { + if (isEditMode) { + // Edit mode: skip to search with type pre-set + setSelectedType(editModule.content_type); + setStep("search"); + setModuleTitle(editModule.title || ""); + } else { + setStep("type"); + setSelectedType(""); + setModuleTitle(""); + } + setQuery(""); + setResults([]); + setSelectedItem(null); + setIsSubmitting(false); + setSubmitError(null); + setSearchError(null); + setMineOnly(true); + } + }, [isOpen, isEditMode, editModule]); + + // Fetch results when debounced query or mineOnly changes + useEffect(() => { + if (!isOpen || step !== "search") return; + + // If no query and "My Slideshows" mode, list all user's slideshows + if (debouncedQuery.length === 0 && mineOnly) { + let cancelled = false; + const fetchMine = async () => { + setIsLoading(true); + setSearchError(null); + try { + const data = await listMySlideshows({ page_size: 20 }); + if (!cancelled) { + setResults(data.results); + } + } catch (err) { + if (!cancelled) { + setSearchError( + err instanceof Error ? err.message : "Failed to load slideshows", + ); + setResults([]); + } + } finally { + if (!cancelled) setIsLoading(false); + } + }; + fetchMine(); + return () => { + cancelled = true; + }; + } + + // If no query and "All Slideshows" mode, clear results + if (debouncedQuery.length === 0 && !mineOnly) { + setResults([]); + return; + } + + // If query too short, clear + if (debouncedQuery.length < 2) { + setResults([]); + return; + } + + // Search with query + let cancelled = false; + const fetchResults = async () => { + setIsLoading(true); + setSearchError(null); + try { + const params: { q: string; mine?: boolean; page_size: number } = { + q: debouncedQuery, + page_size: 20, + }; + if (mineOnly) params.mine = true; + const data = await searchSlideshows(params); + if (!cancelled) { + setResults(data.results); + } + } catch (err) { + if (!cancelled) { + setSearchError( + err instanceof Error ? err.message : "Failed to search slideshows", + ); + setResults([]); + } + } finally { + if (!cancelled) setIsLoading(false); + } + }; + fetchResults(); + return () => { + cancelled = true; + }; + }, [debouncedQuery, mineOnly, isOpen, step]); + + // Focus search input when entering search step + useEffect(() => { + if (step === "search") { + setTimeout(() => inputRef.current?.focus(), 100); + } + }, [step]); + + const handleTypeSelect = (contentType: string) => { + setSelectedType(contentType); + setStep("search"); + }; + + const handleItemSelect = (item: SlideshowListItem) => { + setSelectedItem(item); + if (!moduleTitle) { + setModuleTitle(item.title); + } + setStep("confirm"); + }; + + const handleSubmit = async () => { + if (!selectedItem || !selectedType) return; + + setIsSubmitting(true); + setSubmitError(null); + + try { + await onSubmit({ + title: moduleTitle || undefined, + content_type: selectedType, + object_id: selectedItem.id, + }); + onClose(); + } catch (err) { + setSubmitError( + err instanceof Error + ? err.message + : t("course.detail.modules.addError"), + ); + } finally { + setIsSubmitting(false); + } + }; + + const handleBack = () => { + if (step === "confirm") { + setSelectedItem(null); + setStep("search"); + } else if (step === "search" && !isEditMode) { + setSelectedType(""); + setQuery(""); + setResults([]); + setStep("type"); + } + }; + + const handleBackdropKeyDown = (e: React.KeyboardEvent) => { + if (e.key === "Escape" && !isSubmitting) { + onClose(); + } + }; if (!isOpen) return null; + const modalTitle = isEditMode + ? t("course.detail.addModuleModal.editTitle") + : t("course.detail.addModuleModal.title"); + return createPortal( -
+
{/* Backdrop */}
{/* Modal */} -
- - -

- {t("course.detail.addModuleModal.title")} -

- -

- {t("course.detail.addModuleModal.comingSoon")} -

- - +
+ {/* Header */} +
+ {step !== "type" && ( + + )} +

+ {modalTitle} +

+
+ + {/* Step 1: Type Selection */} + {step === "type" && ( +
+

+ {t("course.detail.addModuleModal.selectType")} +

+
+ {MODULE_TYPES.map((type) => { + const Icon = type.icon; + return ( + + ); + })} +
+
+ )} + + {/* Step 2: Search & Select */} + {step === "search" && ( +
+ {/* Mine/All toggle */} +
+ + +
+ + {/* Search input */} +
+ + setQuery(e.target.value)} + placeholder={t( + "course.detail.addModuleModal.searchPlaceholder", + )} + aria-label={t("course.detail.addModuleModal.searchLabel")} + className="w-full ps-9 pe-4 py-2.5 bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-600 rounded-xl shadow-sm text-gray-700 dark:text-gray-200 placeholder-gray-500 dark:placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-blue-500/30 focus:border-blue-400 transition-all duration-200" + /> + {isLoading && ( +
+
+
+ )} +
+ + {/* Min chars hint */} + {query.length > 0 && query.length < 2 && !mineOnly && ( +

+ {t("course.detail.addModuleModal.searchMinChars")} +

+ )} + + {/* Results */} + {!isLoading && results.length > 0 && ( +
    + {results.map((item) => { + const isAlreadyAdded = existingObjectIds.has(item.id); + return ( +
  • !isAlreadyAdded && handleItemSelect(item)} + className={`flex items-start gap-3 p-3 rounded-xl border transition-colors ${ + isAlreadyAdded + ? "border-gray-200/50 dark:border-gray-700/30 opacity-50 cursor-not-allowed" + : "border-gray-200/50 dark:border-gray-700/30 hover:bg-blue-50 dark:hover:bg-blue-900/20 hover:border-blue-300 dark:hover:border-blue-600 cursor-pointer" + }`} + > + +
    +

    + {item.title} +

    + {item.description && ( +

    + {item.description} +

    + )} +
    + + {item.is_published + ? t("course.detail.addModuleModal.published") + : t("course.detail.addModuleModal.draft")} + + + {t("course.detail.addModuleModal.slides", { + count: item.slide_count, + })} + + {item.visibility !== "public" && ( + + {item.visibility} + + )} + {isAlreadyAdded && ( + + {t("course.detail.addModuleModal.alreadyAdded")} + + )} +
    +
    +
  • + ); + })} +
+ )} + + {/* Create slideshow link below results */} + {!isLoading && results.length > 0 && mineOnly && ( +
+ + {t("course.detail.addModuleModal.createSlideshow")} + +
+ )} + + {/* No results */} + {!isLoading && + results.length === 0 && + (debouncedQuery.length >= 2 || + (mineOnly && debouncedQuery.length === 0)) && + !searchError && ( +
+ +

+ {t("course.detail.addModuleModal.noResults")} +

+

+ {t("course.detail.addModuleModal.noResultsHint")} +

+ {mineOnly && ( + + {t("course.detail.addModuleModal.createSlideshow")} + + )} +
+ )} + + {/* Search error */} + {searchError && ( +

+ {searchError} +

+ )} + + {/* Prompt to search when "All" mode with no query */} + {!mineOnly && query.length === 0 && !isLoading && ( +

+ {t("course.detail.addModuleModal.searchMinChars")} +

+ )} +
+ )} + + {/* Step 3: Confirm & Submit */} + {step === "confirm" && selectedItem && ( +
+ {/* Selected content summary */} +
+

+ {t("course.detail.addModuleModal.selectedContent")} +

+
+ +
+

+ {selectedItem.title} +

+ {selectedItem.description && ( +

+ {selectedItem.description} +

+ )} +
+ + {selectedItem.is_published + ? t("course.detail.addModuleModal.published") + : t("course.detail.addModuleModal.draft")} + + + {t("course.detail.addModuleModal.slides", { + count: selectedItem.slide_count, + })} + +
+
+
+
+ + {/* Module title input */} +
+ + setModuleTitle(e.target.value)} + placeholder={t("course.detail.addModuleModal.titlePlaceholder")} + disabled={isSubmitting} + className="w-full px-4 py-2.5 bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-600 rounded-xl shadow-sm text-gray-700 dark:text-gray-200 placeholder-gray-500 dark:placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-blue-500/30 focus:border-blue-400 transition-all duration-200 disabled:opacity-60 disabled:cursor-not-allowed" + /> +
+ + {/* Submit error */} + {submitError && ( +

{submitError}

+ )} + + {/* Action buttons */} +
+ + +
+
+ )}
, document.body, diff --git a/Frontend/EduLiteFrontend/src/components/courses/ModulesTab.tsx b/Frontend/EduLiteFrontend/src/components/courses/ModulesTab.tsx index e69af3e..02f91c0 100644 --- a/Frontend/EduLiteFrontend/src/components/courses/ModulesTab.tsx +++ b/Frontend/EduLiteFrontend/src/components/courses/ModulesTab.tsx @@ -246,6 +246,7 @@ const ModulesTab: React.FC = ({ isOpen={showAddModal} onClose={() => setShowAddModal(false)} onSubmit={handleAddModule} + existingModules={modules || []} /> {/* Edit Module Modal */} @@ -254,6 +255,7 @@ const ModulesTab: React.FC = ({ onClose={() => setEditModule(null)} onSubmit={handleEditModule} editModule={editModule} + existingModules={modules || []} /> {/* Delete Confirmation */} diff --git a/Frontend/EduLiteFrontend/src/components/courses/__tests__/AddModuleModal.test.tsx b/Frontend/EduLiteFrontend/src/components/courses/__tests__/AddModuleModal.test.tsx new file mode 100644 index 0000000..73a47fc --- /dev/null +++ b/Frontend/EduLiteFrontend/src/components/courses/__tests__/AddModuleModal.test.tsx @@ -0,0 +1,518 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { screen, waitFor, act } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { renderWithProviders } from "../../../test/utils"; +import AddModuleModal from "../AddModuleModal"; +import type { SlideshowListItem } from "../../../types/slideshow.types"; +import type { CourseModule } from "../../../types/courses.types"; + +// Mock the slideshowApi module +vi.mock("../../../services/slideshowApi", () => ({ + searchSlideshows: vi.fn(), + listMySlideshows: vi.fn(), + listSlideshows: vi.fn(), +})); + +import { + searchSlideshows, + listMySlideshows, +} from "../../../services/slideshowApi"; + +const mockSearchSlideshows = vi.mocked(searchSlideshows); +const mockListMySlideshows = vi.mocked(listMySlideshows); + +const mockSlideshows: SlideshowListItem[] = [ + { + id: 1, + title: "Intro to Physics", + description: "A basic physics overview", + created_by: 1, + created_by_username: "teacher1", + visibility: "public", + language: "en", + country: "US", + subject: "physics", + is_published: true, + slide_count: 10, + version: 1, + created_at: "2024-01-01T00:00:00Z", + updated_at: "2024-01-01T00:00:00Z", + }, + { + id: 2, + title: "Math Basics", + description: null, + created_by: 1, + created_by_username: "teacher1", + visibility: "private", + language: "en", + country: null, + subject: "math", + is_published: false, + slide_count: 5, + version: 1, + created_at: "2024-02-01T00:00:00Z", + updated_at: "2024-02-01T00:00:00Z", + }, +]; + +const defaultProps = { + isOpen: true, + onClose: vi.fn(), + onSubmit: vi.fn(), +}; + +describe("AddModuleModal", () => { + beforeEach(() => { + vi.useFakeTimers({ shouldAdvanceTime: true }); + mockSearchSlideshows.mockReset(); + mockListMySlideshows.mockReset(); + defaultProps.onClose.mockReset(); + defaultProps.onSubmit.mockReset(); + + // Default: listMySlideshows returns mock results + mockListMySlideshows.mockResolvedValue({ + count: 2, + next: null, + previous: null, + results: mockSlideshows, + }); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + describe("Rendering", () => { + it("renders nothing when isOpen is false", () => { + const { container } = renderWithProviders( + , + ); + expect(container.innerHTML).toBe(""); + }); + + it("renders modal with title when isOpen is true", () => { + renderWithProviders(); + expect( + screen.getByRole("heading", { name: "Add Module" }), + ).toBeInTheDocument(); + }); + + it("shows type selection grid on initial open", () => { + renderWithProviders(); + expect(screen.getByText("Select Module Type")).toBeInTheDocument(); + expect(screen.getByText("Slideshow")).toBeInTheDocument(); + }); + + it("shows disabled future types with coming soon label", () => { + renderWithProviders(); + expect(screen.getByText("Quiz")).toBeInTheDocument(); + expect(screen.getByText("Notes")).toBeInTheDocument(); + expect(screen.getByText("Assignment")).toBeInTheDocument(); + // All "Coming soon" labels + expect(screen.getAllByText("Coming soon")).toHaveLength(3); + }); + }); + + describe("Step 1: Type Selection", () => { + it("navigates to search step when Slideshow is clicked", async () => { + const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime }); + renderWithProviders(); + + await user.click(screen.getByText("Slideshow")); + + // Should now show search UI + await waitFor(() => { + expect( + screen.getByPlaceholderText("Type to search slideshows..."), + ).toBeInTheDocument(); + }); + }); + + it("does not navigate when disabled type is clicked", async () => { + const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime }); + renderWithProviders(); + + await user.click(screen.getByText("Quiz")); + + // Should still be on type selection + expect(screen.getByText("Select Module Type")).toBeInTheDocument(); + }); + }); + + describe("Step 2: Search & Select", () => { + const goToSearch = async () => { + const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime }); + renderWithProviders(); + + await user.click(screen.getByText("Slideshow")); + + await waitFor(() => { + expect( + screen.getByPlaceholderText("Type to search slideshows..."), + ).toBeInTheDocument(); + }); + + return user; + }; + + it("loads user's slideshows by default (My Slideshows mode)", async () => { + await goToSearch(); + + await waitFor(() => { + expect(mockListMySlideshows).toHaveBeenCalled(); + }); + + await waitFor(() => { + expect(screen.getByText("Intro to Physics")).toBeInTheDocument(); + expect(screen.getByText("Math Basics")).toBeInTheDocument(); + }); + }); + + it("shows published and draft badges", async () => { + await goToSearch(); + + await waitFor(() => { + expect(screen.getByText("Published")).toBeInTheDocument(); + expect(screen.getByText("Draft")).toBeInTheDocument(); + }); + }); + + it("shows slide count for each result", async () => { + await goToSearch(); + + await waitFor(() => { + expect(screen.getByText("10 slides")).toBeInTheDocument(); + expect(screen.getByText("5 slides")).toBeInTheDocument(); + }); + }); + + it("debounces search and calls searchSlideshows", async () => { + mockSearchSlideshows.mockResolvedValue({ + count: 1, + next: null, + previous: null, + results: [mockSlideshows[0]], + }); + + const user = await goToSearch(); + + await user.type( + screen.getByPlaceholderText("Type to search slideshows..."), + "physics", + ); + + // Not called yet — debounce + expect(mockSearchSlideshows).not.toHaveBeenCalled(); + + await act(() => vi.advanceTimersByTime(350)); + + expect(mockSearchSlideshows).toHaveBeenCalledWith({ + q: "physics", + mine: true, + page_size: 20, + }); + }); + + it("toggles between My Slideshows and All Slideshows", async () => { + const user = await goToSearch(); + + // Click "All Slideshows" + await user.click(screen.getByText("All Slideshows")); + + // Without a query in All mode, should show prompt + await waitFor(() => { + expect( + screen.getByText("Type at least 2 characters to search"), + ).toBeInTheDocument(); + }); + + // Switch back + await user.click(screen.getByText("My Slideshows")); + + await waitFor(() => { + expect(mockListMySlideshows).toHaveBeenCalled(); + }); + }); + + it("shows no results message when search returns empty", async () => { + mockListMySlideshows.mockResolvedValue({ + count: 0, + next: null, + previous: null, + results: [], + }); + + await goToSearch(); + + await waitFor(() => { + expect(screen.getByText("No slideshows found")).toBeInTheDocument(); + }); + }); + + it("navigates to confirm step when a slideshow is selected", async () => { + const user = await goToSearch(); + + await waitFor(() => { + expect(screen.getByText("Intro to Physics")).toBeInTheDocument(); + }); + + await user.click(screen.getByText("Intro to Physics")); + + // Should show confirm step + await waitFor(() => { + expect(screen.getByText("Selected content")).toBeInTheDocument(); + expect( + screen.getByRole("button", { name: /add module/i }), + ).toBeInTheDocument(); + }); + }); + + it("navigates back to type selection on back button", async () => { + const user = await goToSearch(); + + // Click the back button + await user.click(screen.getByRole("button", { name: /back/i })); + + expect(screen.getByText("Select Module Type")).toBeInTheDocument(); + }); + }); + + describe("Step 3: Confirm & Submit", () => { + const goToConfirm = async () => { + const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime }); + + renderWithProviders(); + + await user.click(screen.getByText("Slideshow")); + + await waitFor(() => { + expect(screen.getByText("Intro to Physics")).toBeInTheDocument(); + }); + + await user.click(screen.getByText("Intro to Physics")); + + await waitFor(() => { + expect(screen.getByText("Selected content")).toBeInTheDocument(); + }); + + return user; + }; + + it("shows selected slideshow details", async () => { + await goToConfirm(); + + // Title should be in the summary and pre-filled in input + expect(screen.getByText("Intro to Physics")).toBeInTheDocument(); + expect(screen.getByText("A basic physics overview")).toBeInTheDocument(); + expect(screen.getByDisplayValue("Intro to Physics")).toBeInTheDocument(); + }); + + it("pre-fills module title with slideshow title", async () => { + await goToConfirm(); + + const titleInput = screen.getByDisplayValue("Intro to Physics"); + expect(titleInput).toBeInTheDocument(); + }); + + it("allows editing the module title", async () => { + const user = await goToConfirm(); + + const titleInput = screen.getByDisplayValue("Intro to Physics"); + await user.clear(titleInput); + await user.type(titleInput, "Week 1: Physics"); + + expect(screen.getByDisplayValue("Week 1: Physics")).toBeInTheDocument(); + }); + + it("submits with correct data on Add Module click", async () => { + const onSubmit = vi.fn().mockResolvedValue(undefined); + const onClose = vi.fn(); + const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime }); + + renderWithProviders( + , + ); + + await user.click(screen.getByText("Slideshow")); + + await waitFor(() => { + expect(screen.getByText("Intro to Physics")).toBeInTheDocument(); + }); + + await user.click(screen.getByText("Intro to Physics")); + + await waitFor(() => { + expect(screen.getByText("Selected content")).toBeInTheDocument(); + }); + + // Click Add Module + await user.click(screen.getByRole("button", { name: /add module/i })); + + expect(onSubmit).toHaveBeenCalledWith({ + title: "Intro to Physics", + content_type: "slideshows.slideshow", + object_id: 1, + }); + + await waitFor(() => { + expect(onClose).toHaveBeenCalled(); + }); + }); + + it("shows error when submit fails", async () => { + const onSubmit = vi + .fn() + .mockRejectedValue(new Error("Module already exists")); + const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime }); + + renderWithProviders( + , + ); + + await user.click(screen.getByText("Slideshow")); + + await waitFor(() => { + expect(screen.getByText("Intro to Physics")).toBeInTheDocument(); + }); + + await user.click(screen.getByText("Intro to Physics")); + + await waitFor(() => { + expect(screen.getByText("Selected content")).toBeInTheDocument(); + }); + + await user.click(screen.getByRole("button", { name: /add module/i })); + + await waitFor(() => { + expect(screen.getByText("Module already exists")).toBeInTheDocument(); + }); + }); + + it("navigates back to search on back button", async () => { + const user = await goToConfirm(); + + await user.click(screen.getByRole("button", { name: /back/i })); + + expect( + screen.getByPlaceholderText("Type to search slideshows..."), + ).toBeInTheDocument(); + }); + }); + + describe("Edit Mode", () => { + const editModule: CourseModule = { + id: 10, + course: 1, + title: "Existing Module", + order: 2, + course_title: "Test Course", + content_type: "slideshows.slideshow", + object_id: 1, + }; + + it("skips type selection and goes to search in edit mode", () => { + renderWithProviders( + , + ); + + expect( + screen.getByRole("heading", { name: "Edit Module" }), + ).toBeInTheDocument(); + expect( + screen.getByPlaceholderText("Type to search slideshows..."), + ).toBeInTheDocument(); + }); + + it("pre-fills module title from editModule", () => { + renderWithProviders( + , + ); + + // Title is pre-filled but we're on search step, not confirm + // The title will be shown when we select an item + }); + + it("shows Save Module button instead of Add Module on confirm", async () => { + const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime }); + + renderWithProviders( + , + ); + + await waitFor(() => { + expect(screen.getByText("Intro to Physics")).toBeInTheDocument(); + }); + + await user.click(screen.getByText("Intro to Physics")); + + await waitFor(() => { + expect( + screen.getByRole("button", { name: /save module/i }), + ).toBeInTheDocument(); + }); + }); + + it("does not show back button to type selection in edit mode", async () => { + renderWithProviders( + , + ); + + // Back button should not go to type selection (since we skipped it) + expect(screen.queryByText("Select Module Type")).not.toBeInTheDocument(); + }); + }); + + describe("Close and Reset", () => { + it("calls onClose when Cancel is clicked", async () => { + const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime }); + const onClose = vi.fn(); + + renderWithProviders( + , + ); + + // Go to confirm step to see Cancel button + await user.click(screen.getByText("Slideshow")); + + await waitFor(() => { + expect(screen.getByText("Intro to Physics")).toBeInTheDocument(); + }); + + await user.click(screen.getByText("Intro to Physics")); + + await waitFor(() => { + expect( + screen.getByRole("button", { name: /cancel/i }), + ).toBeInTheDocument(); + }); + + await user.click(screen.getByRole("button", { name: /cancel/i })); + expect(onClose).toHaveBeenCalled(); + }); + + it("resets to type selection when reopened", async () => { + const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime }); + + const { rerender } = renderWithProviders( + , + ); + + // Navigate to search step + await user.click(screen.getByText("Slideshow")); + + await waitFor(() => { + expect( + screen.getByPlaceholderText("Type to search slideshows..."), + ).toBeInTheDocument(); + }); + + // Close and reopen + rerender(); + rerender(); + + // Should be back on type selection + expect(screen.getByText("Select Module Type")).toBeInTheDocument(); + }); + }); +}); diff --git a/Frontend/EduLiteFrontend/src/i18n/locales/ar.json b/Frontend/EduLiteFrontend/src/i18n/locales/ar.json index 772204f..b00d7c2 100644 --- a/Frontend/EduLiteFrontend/src/i18n/locales/ar.json +++ b/Frontend/EduLiteFrontend/src/i18n/locales/ar.json @@ -464,18 +464,35 @@ }, "addModuleModal": { "title": "إضافة وحدة", - "comingSoon": "واجهة إدارة الوحدات قادمة قريباً. ستتيح لك الوحدات إرفاق العروض التقديمية والاختبارات ومحتويات أخرى بدورتك.", "editTitle": "تعديل الوحدة", - "titleLabel": "العنوان", + "selectType": "اختر نوع الوحدة", + "slideshow": "عرض تقديمي", + "slideshowDesc": "إرفاق عرض تقديمي", + "quiz": "اختبار", + "notes": "ملاحظات", + "assignment": "واجب", + "comingSoonType": "قريباً", + "searchLabel": "البحث عن العروض التقديمية", + "searchPlaceholder": "اكتب للبحث عن العروض التقديمية...", + "mySlideshows": "عروضي التقديمية", + "allSlideshows": "جميع العروض التقديمية", + "noResults": "لم يتم العثور على عروض تقديمية", + "noResultsHint": "جرب بحثاً مختلفاً أو أنشئ عرضاً تقديمياً أولاً", + "createSlideshow": "إنشاء عرض تقديمي", + "alreadyAdded": "تمت الإضافة", + "searchMinChars": "اكتب حرفين على الأقل للبحث", + "published": "منشور", + "draft": "مسودة", + "slides": "{{count}} شرائح", + "selectedContent": "المحتوى المحدد", + "titleLabel": "عنوان الوحدة", "titlePlaceholder": "أدخل عنوان الوحدة (اختياري)", - "contentTypeLabel": "نوع المحتوى", - "contentTypePlaceholder": "مثال: chat.chatroom", - "objectIdLabel": "رقم الكائن", - "objectIdPlaceholder": "أدخل رقم الكائن", "submitButton": "إضافة الوحدة", "editSubmitButton": "حفظ الوحدة", "adding": "جارٍ الإضافة...", - "saving": "جارٍ الحفظ..." + "saving": "جارٍ الحفظ...", + "back": "رجوع", + "cancel": "إلغاء" } }, "enrollment": { diff --git a/Frontend/EduLiteFrontend/src/i18n/locales/en.json b/Frontend/EduLiteFrontend/src/i18n/locales/en.json index dd1cad2..3751661 100644 --- a/Frontend/EduLiteFrontend/src/i18n/locales/en.json +++ b/Frontend/EduLiteFrontend/src/i18n/locales/en.json @@ -463,18 +463,35 @@ }, "addModuleModal": { "title": "Add Module", - "comingSoon": "Module management UX is coming soon. Modules will let you attach slideshows, quizzes, and other content to your course.", "editTitle": "Edit Module", - "titleLabel": "Title", + "selectType": "Select Module Type", + "slideshow": "Slideshow", + "slideshowDesc": "Attach a slideshow presentation", + "quiz": "Quiz", + "notes": "Notes", + "assignment": "Assignment", + "comingSoonType": "Coming soon", + "searchLabel": "Search slideshows", + "searchPlaceholder": "Type to search slideshows...", + "mySlideshows": "My Slideshows", + "allSlideshows": "All Slideshows", + "noResults": "No slideshows found", + "noResultsHint": "Try a different search or create a slideshow first", + "createSlideshow": "Create a Slideshow", + "alreadyAdded": "Already added", + "searchMinChars": "Type at least 2 characters to search", + "published": "Published", + "draft": "Draft", + "slides": "{{count}} slides", + "selectedContent": "Selected content", + "titleLabel": "Module Title", "titlePlaceholder": "Enter module title (optional)", - "contentTypeLabel": "Content Type", - "contentTypePlaceholder": "e.g. chat.chatroom", - "objectIdLabel": "Object ID", - "objectIdPlaceholder": "Enter object ID", "submitButton": "Add Module", "editSubmitButton": "Save Module", "adding": "Adding...", - "saving": "Saving..." + "saving": "Saving...", + "back": "Back", + "cancel": "Cancel" } }, "enrollment": { diff --git a/Frontend/EduLiteFrontend/src/services/slideshowApi.ts b/Frontend/EduLiteFrontend/src/services/slideshowApi.ts index 59af806..c58831c 100644 --- a/Frontend/EduLiteFrontend/src/services/slideshowApi.ts +++ b/Frontend/EduLiteFrontend/src/services/slideshowApi.ts @@ -26,7 +26,7 @@ const API_BASE_URL = "http://localhost:8000/api"; * @returns Paginated list of slideshows */ export const listSlideshows = async ( - params?: SlideshowListParams + params?: SlideshowListParams, ): Promise> => { try { const response = await axios.get(`${API_BASE_URL}/slideshows/`, { @@ -47,11 +47,35 @@ export const listSlideshows = async ( * @returns Paginated list of user's slideshows */ export const listMySlideshows = async ( - params?: Omit + params?: Omit, ): Promise> => { return listSlideshows({ ...params, mine: true }); }; +/** + * Search slideshows by title and description + * Uses the dedicated search endpoint with text matching + * + * @param params - Search parameters (q required, min 2 chars, plus optional filters) + * @returns Paginated list of matching slideshows + */ +export const searchSlideshows = async (params: { + q: string; + mine?: boolean; + page?: number; + page_size?: number; +}): Promise> => { + try { + const response = await axios.get(`${API_BASE_URL}/slideshows/search/`, { + params, + timeout: 10000, + }); + return response.data; + } catch (error) { + throw new Error(getSafeErrorMessage(error, "Failed to search slideshows")); + } +}; + /** * Get detailed slideshow information * Supports progressive loading via initialSlideCount parameter @@ -62,7 +86,7 @@ export const listMySlideshows = async ( */ export const getSlideshowDetail = async ( id: number, - initialSlideCount?: number + initialSlideCount?: number, ): Promise => { try { const params = initialSlideCount ? { initial: initialSlideCount } : {}; @@ -86,14 +110,14 @@ export const getSlideshowDetail = async ( */ export const getSlide = async ( slideshowId: number, - slideId: number + slideId: number, ): Promise => { try { const response = await axios.get( `${API_BASE_URL}/slideshows/${slideshowId}/slides/${slideId}/`, { timeout: 10000, - } + }, ); return response.data; } catch (error) { @@ -109,7 +133,7 @@ export const getSlide = async ( * @returns Created slideshow detail */ export const createSlideshow = async ( - data: SlideshowCreateRequest + data: SlideshowCreateRequest, ): Promise => { try { const response = await axios.post(`${API_BASE_URL}/slideshows/`, data, { @@ -132,7 +156,7 @@ export const createSlideshow = async ( */ export const updateSlideshow = async ( id: number, - data: SlideshowUpdateRequest + data: SlideshowUpdateRequest, ): Promise => { try { const response = await axios.patch( @@ -140,7 +164,7 @@ export const updateSlideshow = async ( data, { timeout: 15000, - } + }, ); return response.data; } catch (error) { @@ -189,7 +213,7 @@ export const deleteSlideshow = async (id: number): Promise => { * @returns True if error is a version conflict */ export const isVersionConflictError = ( - error: any + error: any, ): error is VersionConflictError & { isVersionConflict: true } => { return ( error?.isVersionConflict === true || @@ -207,7 +231,7 @@ export const isVersionConflictError = ( * @returns Conflict details or null */ export const getVersionConflictDetails = ( - error: any + error: any, ): VersionConflictError | null => { if (isVersionConflictError(error)) { return { @@ -229,7 +253,7 @@ export const getVersionConflictDetails = ( */ export const createSlide = async ( slideshowId: number, - data: SlideCreateData + data: SlideCreateData, ): Promise => { try { const response = await axios.post( @@ -237,7 +261,7 @@ export const createSlide = async ( data, { timeout: 10000, - } + }, ); return response.data; } catch (error) { @@ -256,7 +280,7 @@ export const createSlide = async ( export const updateSlide = async ( slideshowId: number, slideId: number, - data: Partial + data: Partial, ): Promise => { try { const response = await axios.patch( @@ -264,7 +288,7 @@ export const updateSlide = async ( data, { timeout: 10000, - } + }, ); return response.data; } catch (error) { @@ -280,14 +304,14 @@ export const updateSlide = async ( */ export const deleteSlide = async ( slideshowId: number, - slideId: number + slideId: number, ): Promise => { try { await axios.delete( `${API_BASE_URL}/slideshows/${slideshowId}/slides/${slideId}/`, { timeout: 10000, - } + }, ); } catch (error) { throw new Error(getSafeErrorMessage(error, "Failed to delete slide")); @@ -302,11 +326,14 @@ export const deleteSlide = async ( * @param signal - Optional AbortSignal to cancel the request * @returns Rendered HTML string */ -export const previewMarkdown = async (content: string, signal?: AbortSignal): Promise => { +export const previewMarkdown = async ( + content: string, + signal?: AbortSignal, +): Promise => { const response = await axios.post( `${API_BASE_URL}/slideshows/preview/`, { content }, - { timeout: 5000, signal } + { timeout: 5000, signal }, ); return response.data.rendered_content; };