From a694c92c68e3f784a8135a52bc6da7df1370429c Mon Sep 17 00:00:00 2001 From: smattymatty Date: Thu, 12 Feb 2026 19:48:15 -0500 Subject: [PATCH 1/2] implement invite member ux into course detail --- .../src/components/common/UserSearchInput.tsx | 306 +++++++++++ .../common/__tests__/UserSearchInput.test.tsx | 508 ++++++++++++++++++ .../components/courses/InviteMemberModal.tsx | 151 +++++- .../__tests__/InviteMemberModal.test.tsx | 341 ++++++++++++ .../EduLiteFrontend/src/i18n/locales/ar.json | 14 +- .../EduLiteFrontend/src/i18n/locales/en.json | 14 +- .../src/services/__tests__/usersApi.test.ts | 140 +++++ .../EduLiteFrontend/src/services/usersApi.ts | 29 + .../EduLiteFrontend/src/types/users.types.ts | 34 ++ 9 files changed, 1514 insertions(+), 23 deletions(-) create mode 100644 Frontend/EduLiteFrontend/src/components/common/UserSearchInput.tsx create mode 100644 Frontend/EduLiteFrontend/src/components/common/__tests__/UserSearchInput.test.tsx create mode 100644 Frontend/EduLiteFrontend/src/components/courses/__tests__/InviteMemberModal.test.tsx create mode 100644 Frontend/EduLiteFrontend/src/services/__tests__/usersApi.test.ts create mode 100644 Frontend/EduLiteFrontend/src/services/usersApi.ts create mode 100644 Frontend/EduLiteFrontend/src/types/users.types.ts diff --git a/Frontend/EduLiteFrontend/src/components/common/UserSearchInput.tsx b/Frontend/EduLiteFrontend/src/components/common/UserSearchInput.tsx new file mode 100644 index 0000000..e8e086e --- /dev/null +++ b/Frontend/EduLiteFrontend/src/components/common/UserSearchInput.tsx @@ -0,0 +1,306 @@ +import { useState, useEffect, useRef, useCallback } from "react"; +import { useTranslation } from "react-i18next"; +import { HiMagnifyingGlass, HiXMark } from "react-icons/hi2"; +import { useDebounce } from "../../hooks/useDebounce"; +import { searchUsers } from "../../services/usersApi"; +import type { UserSearchResult } from "../../types/users.types"; + +interface UserSearchInputProps { + onSelect: (user: UserSearchResult) => void; + selectedUser: UserSearchResult | null; + onClear: () => void; + placeholder?: string; + disabled?: boolean; + error?: string; + label?: string; +} + +const UserSearchInput: React.FC = ({ + onSelect, + selectedUser, + onClear, + placeholder, + disabled = false, + error, + label, +}) => { + const { t } = useTranslation(); + const [query, setQuery] = useState(""); + const debouncedQuery = useDebounce(query, 300); + const [results, setResults] = useState([]); + const [isLoading, setIsLoading] = useState(false); + const [isOpen, setIsOpen] = useState(false); + const [searchError, setSearchError] = useState(null); + const [activeIndex, setActiveIndex] = useState(-1); + + const wrapperRef = useRef(null); + const inputRef = useRef(null); + const listRef = useRef(null); + + // Fetch results when debounced query changes + useEffect(() => { + if (debouncedQuery.length < 2) { + setResults([]); + setIsOpen(false); + setSearchError(null); + return; + } + + let cancelled = false; + + const fetchResults = async () => { + setIsLoading(true); + setSearchError(null); + try { + const data = await searchUsers({ q: debouncedQuery, page_size: 8 }); + if (!cancelled) { + setResults(data.results); + setIsOpen(true); + setActiveIndex(-1); + } + } catch (err) { + if (!cancelled) { + setSearchError( + err instanceof Error + ? err.message + : t("course.detail.inviteModal.searchError"), + ); + setResults([]); + } + } finally { + if (!cancelled) { + setIsLoading(false); + } + } + }; + + fetchResults(); + return () => { + cancelled = true; + }; + }, [debouncedQuery, t]); + + // Click outside to close + useEffect(() => { + const handleClickOutside = (e: MouseEvent) => { + if ( + wrapperRef.current && + !wrapperRef.current.contains(e.target as Node) + ) { + setIsOpen(false); + setActiveIndex(-1); + } + }; + + document.addEventListener("mousedown", handleClickOutside); + return () => document.removeEventListener("mousedown", handleClickOutside); + }, []); + + // Scroll active item into view + useEffect(() => { + if (activeIndex >= 0 && listRef.current) { + const activeItem = listRef.current.children[activeIndex] as HTMLElement; + activeItem?.scrollIntoView?.({ block: "nearest" }); + } + }, [activeIndex]); + + const handleSelect = useCallback( + (user: UserSearchResult) => { + onSelect(user); + setQuery(""); + setResults([]); + setIsOpen(false); + setActiveIndex(-1); + }, + [onSelect], + ); + + const handleClear = useCallback(() => { + onClear(); + setQuery(""); + setResults([]); + setSearchError(null); + // Focus input after clearing + setTimeout(() => inputRef.current?.focus(), 0); + }, [onClear]); + + const handleKeyDown = (e: React.KeyboardEvent) => { + if (!isOpen || results.length === 0) { + if (e.key === "Escape") { + e.stopPropagation(); + (e.target as HTMLInputElement).blur(); + } + return; + } + + switch (e.key) { + case "ArrowDown": + e.preventDefault(); + setActiveIndex((prev) => (prev >= results.length - 1 ? 0 : prev + 1)); + break; + case "ArrowUp": + e.preventDefault(); + setActiveIndex((prev) => (prev <= 0 ? results.length - 1 : prev - 1)); + break; + case "Enter": + e.preventDefault(); + if (activeIndex >= 0 && activeIndex < results.length) { + handleSelect(results[activeIndex]); + } + break; + case "Escape": + e.preventDefault(); + e.stopPropagation(); + setIsOpen(false); + setActiveIndex(-1); + break; + case "Tab": + setIsOpen(false); + setActiveIndex(-1); + break; + } + }; + + const resolvedLabel = label || t("course.detail.inviteModal.searchLabel"); + const resolvedPlaceholder = + placeholder || t("course.detail.inviteModal.searchPlaceholder"); + + return ( +
+ {resolvedLabel && ( + + )} + +
+ {selectedUser ? ( + // Selected user pill +
+
+ + {selectedUser.username} + + {selectedUser.full_name && ( + + {selectedUser.full_name} + + )} +
+ +
+ ) : ( + // Search input + <> +
+ + = 0 + ? `user-search-option-${results[activeIndex]?.id}` + : undefined + } + aria-autocomplete="list" + aria-label={resolvedLabel} + value={query} + onChange={(e) => setQuery(e.target.value)} + onKeyDown={handleKeyDown} + onFocus={() => { + if (results.length > 0 && query.length >= 2) { + setIsOpen(true); + } + }} + placeholder={resolvedPlaceholder} + disabled={disabled} + className="w-full ps-9 pe-9 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" + /> + {isLoading && ( +
+
+
+ )} +
+ + {/* Dropdown */} + {isOpen && results.length > 0 && ( +
    + {results.map((user, index) => ( +
  • handleSelect(user)} + onMouseEnter={() => setActiveIndex(index)} + className={`px-4 py-3 cursor-pointer transition-colors ${ + index === activeIndex + ? "bg-blue-50 dark:bg-blue-900/20" + : "hover:bg-gray-50 dark:hover:bg-gray-700/50" + }`} + > + + {user.username} + + {user.full_name && ( + + {user.full_name} + + )} +
  • + ))} +
+ )} + + {/* No results */} + {isOpen && + !isLoading && + debouncedQuery.length >= 2 && + results.length === 0 && + !searchError && ( +

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

+ )} + + {/* Min chars hint */} + {query.length > 0 && query.length < 2 && !isLoading && ( +

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

+ )} + + )} + + {/* Error messages */} + {(searchError || error) && ( +

{searchError || error}

+ )} +
+
+ ); +}; + +export default UserSearchInput; diff --git a/Frontend/EduLiteFrontend/src/components/common/__tests__/UserSearchInput.test.tsx b/Frontend/EduLiteFrontend/src/components/common/__tests__/UserSearchInput.test.tsx new file mode 100644 index 0000000..6f90ab5 --- /dev/null +++ b/Frontend/EduLiteFrontend/src/components/common/__tests__/UserSearchInput.test.tsx @@ -0,0 +1,508 @@ +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 UserSearchInput from "../UserSearchInput"; +import type { UserSearchResult } from "../../../types/users.types"; + +// Mock the usersApi module +vi.mock("../../../services/usersApi", () => ({ + searchUsers: vi.fn(), +})); + +import { searchUsers } from "../../../services/usersApi"; + +const mockSearchUsers = vi.mocked(searchUsers); + +const mockResults: UserSearchResult[] = [ + { + id: 1, + username: "john_doe", + full_name: "John Doe", + email: "john@example.com", + first_name: "John", + last_name: "Doe", + profile_id: 10, + }, + { + id: 2, + username: "jane_doe", + full_name: "Jane Doe", + profile_id: 11, + }, + { + id: 3, + username: "private_user", + profile_id: 12, + }, +]; + +const defaultProps = { + onSelect: vi.fn(), + selectedUser: null, + onClear: vi.fn(), +}; + +describe("UserSearchInput", () => { + beforeEach(() => { + vi.useFakeTimers({ shouldAdvanceTime: true }); + mockSearchUsers.mockReset(); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + describe("Rendering", () => { + it("renders search input with placeholder", () => { + renderWithProviders(); + expect( + screen.getByPlaceholderText("Type a username or name..."), + ).toBeInTheDocument(); + }); + + it("renders label when provided", () => { + renderWithProviders( + , + ); + expect(screen.getByText("Find a user")).toBeInTheDocument(); + }); + + it("renders selected user pill when selectedUser prop is set", () => { + renderWithProviders( + , + ); + expect(screen.getByText("john_doe")).toBeInTheDocument(); + expect(screen.getByText("John Doe")).toBeInTheDocument(); + // Search input should not be visible + expect( + screen.queryByPlaceholderText("Type a username or name..."), + ).not.toBeInTheDocument(); + }); + + it("renders disabled state correctly", () => { + renderWithProviders( + , + ); + expect( + screen.getByPlaceholderText("Type a username or name..."), + ).toBeDisabled(); + }); + + it("renders external error message", () => { + renderWithProviders( + , + ); + expect(screen.getByText("Something went wrong")).toBeInTheDocument(); + }); + }); + + describe("Search Behavior", () => { + it("does not search when query is less than 2 characters", async () => { + const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime }); + renderWithProviders(); + + await user.type( + screen.getByPlaceholderText("Type a username or name..."), + "a", + ); + await act(() => vi.advanceTimersByTime(400)); + + expect(mockSearchUsers).not.toHaveBeenCalled(); + }); + + it("shows min chars hint when typing 1 character", async () => { + const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime }); + renderWithProviders(); + + await user.type( + screen.getByPlaceholderText("Type a username or name..."), + "a", + ); + + expect( + screen.getByText("Type at least 2 characters to search"), + ).toBeInTheDocument(); + }); + + it("debounces search input by 300ms", async () => { + const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime }); + mockSearchUsers.mockResolvedValue({ + count: 1, + next: null, + previous: null, + results: [mockResults[0]], + }); + + renderWithProviders(); + + await user.type( + screen.getByPlaceholderText("Type a username or name..."), + "john", + ); + + // Not called yet — debounce hasn't fired + expect(mockSearchUsers).not.toHaveBeenCalled(); + + // Advance past debounce + await act(() => vi.advanceTimersByTime(350)); + + expect(mockSearchUsers).toHaveBeenCalledWith({ + q: "john", + page_size: 8, + }); + }); + + it("displays search results in dropdown", async () => { + const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime }); + mockSearchUsers.mockResolvedValue({ + count: 3, + next: null, + previous: null, + results: mockResults, + }); + + renderWithProviders(); + + await user.type( + screen.getByPlaceholderText("Type a username or name..."), + "doe", + ); + await act(() => vi.advanceTimersByTime(350)); + + await waitFor(() => { + expect(screen.getByText("john_doe")).toBeInTheDocument(); + expect(screen.getByText("jane_doe")).toBeInTheDocument(); + expect(screen.getByText("private_user")).toBeInTheDocument(); + }); + }); + + it("shows no results message when search returns empty", async () => { + const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime }); + mockSearchUsers.mockResolvedValue({ + count: 0, + next: null, + previous: null, + results: [], + }); + + renderWithProviders(); + + await user.type( + screen.getByPlaceholderText("Type a username or name..."), + "nonexistent", + ); + await act(() => vi.advanceTimersByTime(350)); + + await waitFor(() => { + expect(screen.getByText("No users found")).toBeInTheDocument(); + }); + }); + + it("shows error message on API failure", async () => { + const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime }); + mockSearchUsers.mockRejectedValue(new Error("Network error")); + + renderWithProviders(); + + await user.type( + screen.getByPlaceholderText("Type a username or name..."), + "test", + ); + await act(() => vi.advanceTimersByTime(350)); + + await waitFor(() => { + expect(screen.getByText("Network error")).toBeInTheDocument(); + }); + }); + + it("only shows username for privacy-hidden users", async () => { + const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime }); + mockSearchUsers.mockResolvedValue({ + count: 1, + next: null, + previous: null, + results: [mockResults[2]], // private_user with no full_name + }); + + renderWithProviders(); + + await user.type( + screen.getByPlaceholderText("Type a username or name..."), + "private", + ); + await act(() => vi.advanceTimersByTime(350)); + + await waitFor(() => { + expect(screen.getByText("private_user")).toBeInTheDocument(); + }); + + // Ensure no "undefined" or empty full_name text + const listbox = screen.getByRole("listbox"); + expect(listbox.textContent).not.toContain("undefined"); + }); + }); + + describe("Selection", () => { + it("calls onSelect when clicking a result", async () => { + const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime }); + const onSelect = vi.fn(); + mockSearchUsers.mockResolvedValue({ + count: 1, + next: null, + previous: null, + results: [mockResults[0]], + }); + + renderWithProviders( + , + ); + + await user.type( + screen.getByPlaceholderText("Type a username or name..."), + "john", + ); + await act(() => vi.advanceTimersByTime(350)); + + await waitFor(() => { + expect(screen.getByText("john_doe")).toBeInTheDocument(); + }); + + await user.click(screen.getByText("john_doe")); + expect(onSelect).toHaveBeenCalledWith(mockResults[0]); + }); + + it("closes dropdown after selection", async () => { + const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime }); + mockSearchUsers.mockResolvedValue({ + count: 2, + next: null, + previous: null, + results: [mockResults[0], mockResults[1]], + }); + + renderWithProviders(); + + await user.type( + screen.getByPlaceholderText("Type a username or name..."), + "doe", + ); + await act(() => vi.advanceTimersByTime(350)); + + await waitFor(() => { + expect(screen.getByRole("listbox")).toBeInTheDocument(); + }); + + await user.click(screen.getByText("john_doe")); + expect(screen.queryByRole("listbox")).not.toBeInTheDocument(); + }); + + it("calls onClear when clicking clear button on pill", async () => { + const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime }); + const onClear = vi.fn(); + + renderWithProviders( + , + ); + + await user.click( + screen.getByRole("button", { name: /clear selection/i }), + ); + expect(onClear).toHaveBeenCalledTimes(1); + }); + }); + + describe("Keyboard Navigation", () => { + const setupWithResults = async () => { + const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime }); + mockSearchUsers.mockResolvedValue({ + count: 3, + next: null, + previous: null, + results: mockResults, + }); + + renderWithProviders(); + const input = screen.getByPlaceholderText("Type a username or name..."); + + await user.type(input, "doe"); + await act(() => vi.advanceTimersByTime(350)); + + await waitFor(() => { + expect(screen.getByRole("listbox")).toBeInTheDocument(); + }); + + return { user, input }; + }; + + it("highlights first result on ArrowDown", async () => { + const { user, input } = await setupWithResults(); + + await user.type(input, "{ArrowDown}"); + + const firstOption = screen.getByText("john_doe").closest("[role='option']"); + expect(firstOption).toHaveAttribute("aria-selected", "true"); + }); + + it("wraps to first item when ArrowDown at end", async () => { + const { user, input } = await setupWithResults(); + + // Navigate to end + await user.type(input, "{ArrowDown}{ArrowDown}{ArrowDown}"); + // One more should wrap to first + await user.type(input, "{ArrowDown}"); + + const firstOption = screen.getByText("john_doe").closest("[role='option']"); + expect(firstOption).toHaveAttribute("aria-selected", "true"); + }); + + it("wraps to last item when ArrowUp at beginning", async () => { + const { user, input } = await setupWithResults(); + + await user.type(input, "{ArrowUp}"); + + const lastOption = screen.getByText("private_user").closest("[role='option']"); + expect(lastOption).toHaveAttribute("aria-selected", "true"); + }); + + it("selects highlighted result on Enter", async () => { + const onSelect = vi.fn(); + const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime }); + mockSearchUsers.mockResolvedValue({ + count: 3, + next: null, + previous: null, + results: mockResults, + }); + + renderWithProviders( + , + ); + const input = screen.getByPlaceholderText("Type a username or name..."); + + await user.type(input, "doe"); + await act(() => vi.advanceTimersByTime(350)); + + await waitFor(() => { + expect(screen.getByRole("listbox")).toBeInTheDocument(); + }); + + await user.type(input, "{ArrowDown}{Enter}"); + expect(onSelect).toHaveBeenCalledWith(mockResults[0]); + }); + + it("closes dropdown on Escape", async () => { + const { user, input } = await setupWithResults(); + + await user.type(input, "{Escape}"); + + expect(screen.queryByRole("listbox")).not.toBeInTheDocument(); + }); + }); + + describe("Click Outside", () => { + it("closes dropdown when clicking outside the component", async () => { + const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime }); + mockSearchUsers.mockResolvedValue({ + count: 1, + next: null, + previous: null, + results: [mockResults[0]], + }); + + renderWithProviders( +
+ + +
, + ); + + await user.type( + screen.getByPlaceholderText("Type a username or name..."), + "john", + ); + await act(() => vi.advanceTimersByTime(350)); + + await waitFor(() => { + expect(screen.getByRole("listbox")).toBeInTheDocument(); + }); + + await user.click(screen.getByText("Outside")); + expect(screen.queryByRole("listbox")).not.toBeInTheDocument(); + }); + }); + + describe("Accessibility", () => { + it("has combobox role on input", () => { + renderWithProviders(); + expect(screen.getByRole("combobox")).toBeInTheDocument(); + }); + + it("has aria-expanded matching dropdown state", async () => { + const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime }); + mockSearchUsers.mockResolvedValue({ + count: 1, + next: null, + previous: null, + results: [mockResults[0]], + }); + + renderWithProviders(); + const combobox = screen.getByRole("combobox"); + + expect(combobox).toHaveAttribute("aria-expanded", "false"); + + await user.type(combobox, "john"); + await act(() => vi.advanceTimersByTime(350)); + + await waitFor(() => { + expect(combobox).toHaveAttribute("aria-expanded", "true"); + }); + }); + + it("has listbox role on dropdown", async () => { + const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime }); + mockSearchUsers.mockResolvedValue({ + count: 1, + next: null, + previous: null, + results: [mockResults[0]], + }); + + renderWithProviders(); + + await user.type(screen.getByRole("combobox"), "john"); + await act(() => vi.advanceTimersByTime(350)); + + await waitFor(() => { + expect(screen.getByRole("listbox")).toBeInTheDocument(); + }); + }); + + it("has option role on each result item", async () => { + const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime }); + mockSearchUsers.mockResolvedValue({ + count: 2, + next: null, + previous: null, + results: [mockResults[0], mockResults[1]], + }); + + renderWithProviders(); + + await user.type(screen.getByRole("combobox"), "doe"); + await act(() => vi.advanceTimersByTime(350)); + + await waitFor(() => { + expect(screen.getAllByRole("option")).toHaveLength(2); + }); + }); + }); +}); diff --git a/Frontend/EduLiteFrontend/src/components/courses/InviteMemberModal.tsx b/Frontend/EduLiteFrontend/src/components/courses/InviteMemberModal.tsx index 3407584..154e34c 100644 --- a/Frontend/EduLiteFrontend/src/components/courses/InviteMemberModal.tsx +++ b/Frontend/EduLiteFrontend/src/components/courses/InviteMemberModal.tsx @@ -1,7 +1,11 @@ +import { useState, useEffect } from "react"; import { createPortal } from "react-dom"; import { useTranslation } from "react-i18next"; import { HiUserPlus } from "react-icons/hi2"; import type { CourseRole } from "../../types/courses.types"; +import type { UserSearchResult } from "../../types/users.types"; +import UserSearchInput from "../common/UserSearchInput"; +import HardLoadSelect from "../common/HardLoadSelect"; interface InviteMemberModalProps { isOpen: boolean; @@ -9,41 +13,158 @@ interface InviteMemberModalProps { onSubmit: (userId: number, role: CourseRole) => Promise; } +const ROLE_CHOICES: Array<[string, string]> = [ + ["student", "Student"], + ["assistant", "Assistant"], + ["teacher", "Teacher"], +]; + const InviteMemberModal: React.FC = ({ isOpen, onClose, + onSubmit, }) => { const { t } = useTranslation(); + const [selectedUser, setSelectedUser] = useState( + null, + ); + const [selectedRole, setSelectedRole] = useState("student"); + const [isSubmitting, setIsSubmitting] = useState(false); + const [submitError, setSubmitError] = useState(null); + + // Reset state when modal closes + useEffect(() => { + if (!isOpen) { + setSelectedUser(null); + setSelectedRole("student"); + setIsSubmitting(false); + setSubmitError(null); + } + }, [isOpen]); + + const handleSubmit = async () => { + if (!selectedUser) return; + + setIsSubmitting(true); + setSubmitError(null); + + try { + await onSubmit(selectedUser.id, selectedRole); + onClose(); + } catch (err) { + setSubmitError( + err instanceof Error + ? err.message + : t("course.detail.inviteModal.error"), + ); + } finally { + setIsSubmitting(false); + } + }; + + const handleBackdropKeyDown = (e: React.KeyboardEvent) => { + if (e.key === "Escape" && !isSubmitting) { + onClose(); + } + }; if (!isOpen) return null; return createPortal( -
+
{/* Backdrop */}
{/* Modal */} -
- - -

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

- -

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

- +
+ {/* Close button */} + + {/* Header */} +
+ +

+ {t("course.detail.inviteModal.title")} +

+
+ + {/* User search */} +
+ setSelectedUser(null)} + label={t("course.detail.inviteModal.searchLabel")} + placeholder={t("course.detail.inviteModal.searchPlaceholder")} + disabled={isSubmitting} + /> + + {/* Role selector */} + setSelectedRole(e.target.value as CourseRole)} + choices={ROLE_CHOICES} + disabled={isSubmitting} + /> + + {/* Submit error */} + {submitError && ( +

{submitError}

+ )} + + {/* Action buttons */} +
+ + +
+
, document.body, diff --git a/Frontend/EduLiteFrontend/src/components/courses/__tests__/InviteMemberModal.test.tsx b/Frontend/EduLiteFrontend/src/components/courses/__tests__/InviteMemberModal.test.tsx new file mode 100644 index 0000000..f261c06 --- /dev/null +++ b/Frontend/EduLiteFrontend/src/components/courses/__tests__/InviteMemberModal.test.tsx @@ -0,0 +1,341 @@ +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 InviteMemberModal from "../InviteMemberModal"; +import type { UserSearchResult } from "../../../types/users.types"; + +// Mock the usersApi module +vi.mock("../../../services/usersApi", () => ({ + searchUsers: vi.fn(), +})); + +import { searchUsers } from "../../../services/usersApi"; + +const mockSearchUsers = vi.mocked(searchUsers); + +const mockResults: UserSearchResult[] = [ + { + id: 1, + username: "john_doe", + full_name: "John Doe", + profile_id: 10, + }, + { + id: 2, + username: "jane_doe", + full_name: "Jane Doe", + profile_id: 11, + }, +]; + +const defaultProps = { + isOpen: true, + onClose: vi.fn(), + onSubmit: vi.fn(), +}; + +describe("InviteMemberModal", () => { + beforeEach(() => { + vi.useFakeTimers({ shouldAdvanceTime: true }); + mockSearchUsers.mockReset(); + defaultProps.onClose.mockReset(); + defaultProps.onSubmit.mockReset(); + }); + + 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.getByText("Invite Member")).toBeInTheDocument(); + }); + + it("renders search input and role selector", () => { + renderWithProviders(); + expect( + screen.getByPlaceholderText("Type a username or name..."), + ).toBeInTheDocument(); + expect(screen.getByText("Role")).toBeInTheDocument(); + }); + + it("renders Send Invite button disabled initially", () => { + renderWithProviders(); + expect( + screen.getByRole("button", { name: /send invite/i }), + ).toBeDisabled(); + }); + + it("renders Cancel button", () => { + renderWithProviders(); + expect( + screen.getByRole("button", { name: /cancel/i }), + ).toBeInTheDocument(); + }); + }); + + describe("Full Invite Flow", () => { + it("search -> select user -> pick role -> submit succeeds", async () => { + const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime }); + const onSubmit = vi.fn().mockResolvedValue(undefined); + const onClose = vi.fn(); + + mockSearchUsers.mockResolvedValue({ + count: 2, + next: null, + previous: null, + results: mockResults, + }); + + renderWithProviders( + , + ); + + // 1. Type username and wait for search + await user.type( + screen.getByPlaceholderText("Type a username or name..."), + "doe", + ); + await act(() => vi.advanceTimersByTime(350)); + + // 2. Click a result + await waitFor(() => { + expect(screen.getByText("john_doe")).toBeInTheDocument(); + }); + await user.click(screen.getByText("john_doe")); + + // 3. Change role to assistant + const roleSelect = document.querySelector( + 'select[name="invite-role"]', + ) as HTMLSelectElement; + await user.selectOptions(roleSelect, "assistant"); + + // 4. Click Send Invite + const submitButton = screen.getByRole("button", { name: /send invite/i }); + expect(submitButton).toBeEnabled(); + await user.click(submitButton); + + // 5. Verify onSubmit called with correct args + expect(onSubmit).toHaveBeenCalledWith(1, "assistant"); + await waitFor(() => { + expect(onClose).toHaveBeenCalled(); + }); + }); + + it("shows error when submit fails", async () => { + const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime }); + const onSubmit = vi + .fn() + .mockRejectedValue(new Error("User is already a member")); + + mockSearchUsers.mockResolvedValue({ + count: 1, + next: null, + previous: null, + results: [mockResults[0]], + }); + + renderWithProviders( + , + ); + + // Search and select + await user.type( + screen.getByPlaceholderText("Type a username or name..."), + "john", + ); + await act(() => vi.advanceTimersByTime(350)); + + await waitFor(() => { + expect(screen.getByText("john_doe")).toBeInTheDocument(); + }); + await user.click(screen.getByText("john_doe")); + + // Submit + await user.click(screen.getByRole("button", { name: /send invite/i })); + + // Error should be shown + await waitFor(() => { + expect( + screen.getByText("User is already a member"), + ).toBeInTheDocument(); + }); + }); + + it("shows sending text on button while submitting", async () => { + const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime }); + let resolveSubmit: () => void; + const onSubmit = vi.fn().mockReturnValue( + new Promise((resolve) => { + resolveSubmit = resolve; + }), + ); + + mockSearchUsers.mockResolvedValue({ + count: 1, + next: null, + previous: null, + results: [mockResults[0]], + }); + + renderWithProviders( + , + ); + + // Search and select + await user.type( + screen.getByPlaceholderText("Type a username or name..."), + "john", + ); + await act(() => vi.advanceTimersByTime(350)); + + await waitFor(() => { + expect(screen.getByText("john_doe")).toBeInTheDocument(); + }); + await user.click(screen.getByText("john_doe")); + + // Submit + await user.click(screen.getByRole("button", { name: /send invite/i })); + + // Should show "Sending..." + expect(screen.getByText("Sending...")).toBeInTheDocument(); + + // Resolve the submit + await act(async () => { + resolveSubmit!(); + }); + }); + }); + + describe("Close and Reset", () => { + it("calls onClose when clicking Cancel", async () => { + const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime }); + const onClose = vi.fn(); + + renderWithProviders( + , + ); + + await user.click(screen.getByRole("button", { name: /cancel/i })); + expect(onClose).toHaveBeenCalled(); + }); + + it("calls onClose when clicking close button", async () => { + const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime }); + const onClose = vi.fn(); + + renderWithProviders( + , + ); + + await user.click(screen.getByRole("button", { name: /close/i })); + expect(onClose).toHaveBeenCalled(); + }); + + it("resets state when reopened", async () => { + const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime }); + + mockSearchUsers.mockResolvedValue({ + count: 1, + next: null, + previous: null, + results: [mockResults[0]], + }); + + const { rerender } = renderWithProviders( + , + ); + + // Search and select a user + await user.type( + screen.getByPlaceholderText("Type a username or name..."), + "john", + ); + await act(() => vi.advanceTimersByTime(350)); + + await waitFor(() => { + expect(screen.getByText("john_doe")).toBeInTheDocument(); + }); + await user.click(screen.getByText("john_doe")); + + // Verify user is selected (pill visible) + expect(screen.getByText("john_doe")).toBeInTheDocument(); + + // Close modal + rerender(); + + // Reopen modal + rerender(); + + // Should be back to empty search input + expect( + screen.getByPlaceholderText("Type a username or name..."), + ).toBeInTheDocument(); + // Send Invite should be disabled again + expect( + screen.getByRole("button", { name: /send invite/i }), + ).toBeDisabled(); + }); + }); + + describe("Role Selection", () => { + it("defaults to student role", () => { + renderWithProviders(); + const roleSelect = document.querySelector( + 'select[name="invite-role"]', + ) as HTMLSelectElement; + expect(roleSelect).toHaveValue("student"); + }); + + it("allows changing role to assistant", async () => { + const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime }); + renderWithProviders(); + + const roleSelect = document.querySelector( + 'select[name="invite-role"]', + ) as HTMLSelectElement; + await user.selectOptions(roleSelect, "assistant"); + expect(roleSelect).toHaveValue("assistant"); + }); + + it("allows changing role to teacher", async () => { + const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime }); + renderWithProviders(); + + const roleSelect = document.querySelector( + 'select[name="invite-role"]', + ) as HTMLSelectElement; + await user.selectOptions(roleSelect, "teacher"); + expect(roleSelect).toHaveValue("teacher"); + }); + }); +}); diff --git a/Frontend/EduLiteFrontend/src/i18n/locales/ar.json b/Frontend/EduLiteFrontend/src/i18n/locales/ar.json index 0b04478..772204f 100644 --- a/Frontend/EduLiteFrontend/src/i18n/locales/ar.json +++ b/Frontend/EduLiteFrontend/src/i18n/locales/ar.json @@ -447,14 +447,20 @@ }, "inviteModal": { "title": "دعوة عضو", - "comingSoon": "البحث عن المستخدمين ودعوتهم قادم قريباً. ستتمكن من البحث عن المستخدمين بالاسم ودعوتهم إلى دورتك.", - "userIdLabel": "رقم المستخدم", - "userIdPlaceholder": "أدخل رقم المستخدم", + "searchLabel": "البحث عن مستخدم", + "searchPlaceholder": "اكتب اسم المستخدم أو الاسم...", + "searchResults": "نتائج البحث", + "noResults": "لم يتم العثور على مستخدمين", + "searchMinChars": "اكتب حرفين على الأقل للبحث", + "searchError": "فشل البحث عن المستخدمين", + "selectedUser": "المستخدم المحدد", + "clearSelection": "مسح التحديد", "roleLabel": "الدور", "submitButton": "إرسال الدعوة", "sending": "جارٍ الإرسال...", "success": "تم إرسال الدعوة بنجاح", - "error": "فشل إرسال الدعوة" + "error": "فشل إرسال الدعوة", + "cancel": "إلغاء" }, "addModuleModal": { "title": "إضافة وحدة", diff --git a/Frontend/EduLiteFrontend/src/i18n/locales/en.json b/Frontend/EduLiteFrontend/src/i18n/locales/en.json index e52d101..dd1cad2 100644 --- a/Frontend/EduLiteFrontend/src/i18n/locales/en.json +++ b/Frontend/EduLiteFrontend/src/i18n/locales/en.json @@ -446,14 +446,20 @@ }, "inviteModal": { "title": "Invite Member", - "comingSoon": "User search and invite is coming soon. You'll be able to search for users by name and invite them to your course.", - "userIdLabel": "User ID", - "userIdPlaceholder": "Enter user ID", + "searchLabel": "Search for a user", + "searchPlaceholder": "Type a username or name...", + "searchResults": "Search results", + "noResults": "No users found", + "searchMinChars": "Type at least 2 characters to search", + "searchError": "Failed to search users", + "selectedUser": "Selected user", + "clearSelection": "Clear selection", "roleLabel": "Role", "submitButton": "Send Invite", "sending": "Sending...", "success": "Invitation sent successfully", - "error": "Failed to send invitation" + "error": "Failed to send invitation", + "cancel": "Cancel" }, "addModuleModal": { "title": "Add Module", diff --git a/Frontend/EduLiteFrontend/src/services/__tests__/usersApi.test.ts b/Frontend/EduLiteFrontend/src/services/__tests__/usersApi.test.ts new file mode 100644 index 0000000..7ca2610 --- /dev/null +++ b/Frontend/EduLiteFrontend/src/services/__tests__/usersApi.test.ts @@ -0,0 +1,140 @@ +import { describe, it, expect, beforeEach, afterEach } from "vitest"; +import axios from "axios"; +import MockAdapter from "axios-mock-adapter"; +import { searchUsers } from "../usersApi"; + +describe("usersApi", () => { + let mock: MockAdapter; + + beforeEach(() => { + mock = new MockAdapter(axios); + }); + + afterEach(() => { + mock.restore(); + }); + + describe("searchUsers", () => { + it("should search users with query parameter", async () => { + const mockResponse = { + count: 2, + next: null, + previous: null, + results: [ + { + id: 1, + username: "john_doe", + email: "john@example.com", + first_name: "John", + last_name: "Doe", + full_name: "John Doe", + profile_id: 10, + }, + { + id: 2, + username: "johnny", + full_name: "Johnny Smith", + profile_id: 11, + }, + ], + }; + + mock + .onGet("http://localhost:8000/api/users/search/", { + params: { q: "john" }, + }) + .reply(200, mockResponse); + + const result = await searchUsers({ q: "john" }); + expect(result).toEqual(mockResponse); + expect(result.results).toHaveLength(2); + }); + + it("should pass pagination parameters", async () => { + const mockResponse = { + count: 10, + next: "http://localhost:8000/api/users/search/?q=test&page=3", + previous: "http://localhost:8000/api/users/search/?q=test&page=1", + results: [ + { id: 5, username: "tester5", profile_id: 50 }, + ], + }; + + mock + .onGet("http://localhost:8000/api/users/search/", { + params: { q: "test", page: 2, page_size: 5 }, + }) + .reply(200, mockResponse); + + const result = await searchUsers({ q: "test", page: 2, page_size: 5 }); + expect(result.count).toBe(10); + expect(result.next).toContain("page=3"); + expect(result.previous).toContain("page=1"); + }); + + it("should handle empty results", async () => { + const mockResponse = { + count: 0, + next: null, + previous: null, + results: [], + }; + + mock + .onGet("http://localhost:8000/api/users/search/") + .reply(200, mockResponse); + + const result = await searchUsers({ q: "nonexistent" }); + expect(result.count).toBe(0); + expect(result.results).toHaveLength(0); + }); + + it("should handle results with privacy-hidden fields", async () => { + const mockResponse = { + count: 1, + next: null, + previous: null, + results: [ + { + id: 3, + username: "private_user", + profile_id: 30, + }, + ], + }; + + mock + .onGet("http://localhost:8000/api/users/search/") + .reply(200, mockResponse); + + const result = await searchUsers({ q: "private" }); + expect(result.results[0].username).toBe("private_user"); + expect(result.results[0].email).toBeUndefined(); + expect(result.results[0].full_name).toBeUndefined(); + }); + + it("should handle 400 for short query", async () => { + mock + .onGet("http://localhost:8000/api/users/search/") + .reply(400, { q: ["Search query must be at least 2 characters."] }); + + await expect(searchUsers({ q: "a" })).rejects.toThrow(); + }); + + it("should handle 500 server errors", async () => { + mock + .onGet("http://localhost:8000/api/users/search/") + .reply(500, { detail: "Internal server error" }); + + await expect(searchUsers({ q: "test" })).rejects.toThrow(); + }); + + it("should handle network errors", async () => { + mock + .onGet("http://localhost:8000/api/users/search/") + .networkError(); + + await expect(searchUsers({ q: "test" })).rejects.toThrow(); + }); + }); +}); diff --git a/Frontend/EduLiteFrontend/src/services/usersApi.ts b/Frontend/EduLiteFrontend/src/services/usersApi.ts new file mode 100644 index 0000000..526cfc9 --- /dev/null +++ b/Frontend/EduLiteFrontend/src/services/usersApi.ts @@ -0,0 +1,29 @@ +import axios from "axios"; +import { getSafeErrorMessage } from "../utils/errorUtils"; +import type { + UserSearchParams, + UserSearchPaginatedResponse, +} from "../types/users.types"; + +const API_BASE_URL = "http://localhost:8000/api"; + +/** + * Search for users by username, first name, or last name. + * Privacy settings are respected — some fields may be absent. + * + * @param params - Search parameters (q required, min 2 chars) + * @returns Paginated list of matching users + */ +export const searchUsers = async ( + params: UserSearchParams, +): Promise => { + try { + const response = await axios.get(`${API_BASE_URL}/users/search/`, { + params, + timeout: 10000, + }); + return response.data; + } catch (error) { + throw new Error(getSafeErrorMessage(error, "Failed to search users")); + } +}; diff --git a/Frontend/EduLiteFrontend/src/types/users.types.ts b/Frontend/EduLiteFrontend/src/types/users.types.ts new file mode 100644 index 0000000..219421b --- /dev/null +++ b/Frontend/EduLiteFrontend/src/types/users.types.ts @@ -0,0 +1,34 @@ +/** + * A single user result from the search API. + * Mirrors backend UserSearchSerializer. + * Fields may be absent due to privacy settings. + */ +export interface UserSearchResult { + id: number; + username: string; + email?: string; + first_name?: string; + last_name?: string; + full_name?: string; + profile_id: number; +} + +/** + * Query parameters for the user search endpoint. + */ +export interface UserSearchParams { + q: string; + page?: number; + page_size?: number; +} + +/** + * Paginated response from the user search endpoint. + * Uses standard DRF PageNumberPagination (count, next, previous, results). + */ +export interface UserSearchPaginatedResponse { + count: number; + next: string | null; + previous: string | null; + results: UserSearchResult[]; +} From 9c4bd4158a96b97cb345798dea680dc6e5aeff57 Mon Sep 17 00:00:00 2001 From: smattymatty Date: Thu, 12 Feb 2026 19:50:25 -0500 Subject: [PATCH 2/2] proper mock for tests --- .../src/pages/__tests__/CourseDetailPage.test.tsx | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/Frontend/EduLiteFrontend/src/pages/__tests__/CourseDetailPage.test.tsx b/Frontend/EduLiteFrontend/src/pages/__tests__/CourseDetailPage.test.tsx index e6a23bd..9d943d0 100644 --- a/Frontend/EduLiteFrontend/src/pages/__tests__/CourseDetailPage.test.tsx +++ b/Frontend/EduLiteFrontend/src/pages/__tests__/CourseDetailPage.test.tsx @@ -632,8 +632,13 @@ describe("CourseDetailPage", () => { expect( screen.getByRole("heading", { name: "Invite Member" }), ).toBeInTheDocument(); - // Modal shows coming soon placeholder - expect(screen.getByText(/coming soon/i)).toBeInTheDocument(); + // Modal shows search input and Send Invite button + expect( + screen.getByPlaceholderText("Type a username or name..."), + ).toBeInTheDocument(); + expect( + screen.getByRole("button", { name: /send invite/i }), + ).toBeInTheDocument(); }); }); });