diff --git a/codewit/client/src/hooks/useCourse.ts b/codewit/client/src/hooks/useCourse.ts index e8920f1..695bc40 100644 --- a/codewit/client/src/hooks/useCourse.ts +++ b/codewit/client/src/hooks/useCourse.ts @@ -4,6 +4,16 @@ import axios from 'axios'; import { Course, StudentProgress } from '@codewit/interfaces'; import { useAuth } from './useAuth'; +interface CourseMutationPayload { + title: string; + enrolling: boolean; + auto_enroll: boolean; + language?: string; + modules?: number[]; + instructors?: number[]; + roster?: number[]; +} + // General hook to handle fetching data with axios const useAxiosFetch = (initialUrl: string, initialData: Course[] = []) => { const [data, setData] = useState(initialData); @@ -86,13 +96,13 @@ const useAxiosCRUD = (method: 'get' | 'post' | 'patch' | 'delete') => { // Hook to post a new course export const usePostCourse = () => { const { operation } = useAxiosCRUD('post'); - return (courseData: Course) => operation('/api/courses', courseData); + return (courseData: CourseMutationPayload) => operation('/api/courses', courseData); }; // Hook to patch an existing course export const usePatchCourse = () => { const { operation } = useAxiosCRUD('patch'); - return (courseData: Course, uid: number | string) => operation(`/api/courses/${uid}`, courseData); + return (courseData: CourseMutationPayload, uid: number | string) => operation(`/api/courses/${uid}`, courseData); }; // Hook to delete a course @@ -124,4 +134,4 @@ export const useCourseProgress = (courseId: string) => { }, [courseId]); return { data, setData, loading, error }; -}; \ No newline at end of file +}; diff --git a/codewit/client/src/pages/CourseForm.tsx b/codewit/client/src/pages/CourseForm.tsx index 930d8b2..e5981e7 100644 --- a/codewit/client/src/pages/CourseForm.tsx +++ b/codewit/client/src/pages/CourseForm.tsx @@ -1,15 +1,31 @@ -// codewit/client/src/pages/CourseForm.tsx -import React, { useState, useEffect } from "react"; +import { useMemo, useState, type Dispatch, type SetStateAction } from "react"; +import Select, { MultiValue, SingleValue } from "react-select"; +import { toast } from "react-toastify"; +import { + DndContext, + DragEndEvent, + KeyboardSensor, + PointerSensor, + closestCenter, + useSensor, + useSensors, +} from "@dnd-kit/core"; +import { + SortableContext, + arrayMove, + sortableKeyboardCoordinates, + useSortable, + verticalListSortingStrategy, +} from "@dnd-kit/sortable"; +import { Bars3Icon, TrashIcon } from "@heroicons/react/24/solid"; +import { Module, SelectedTag } from "@codewit/interfaces"; + import ReusableTable, { Column } from "../components/form/ReusableTable"; import ReusableModal from "../components/form/ReusableModal"; -import Select, { MultiValue } from "react-select"; import LanguageSelect from "../components/form/LanguageSelect"; import CreateButton from "../components/form/CreateButton"; import InputLabel from "../components/form/InputLabel"; import TextInput from "../components/form/TextInput"; -import { SelectStyles } from "../utils/styles"; -import { SelectedTag, Course } from "@codewit/interfaces"; -import { toast } from "react-toastify"; import { useFetchCourses, usePostCourse, @@ -19,21 +35,43 @@ import { import { useFetchModules } from "../hooks/useModule"; import { useFetchUsers } from "../hooks/useUsers"; import { isFormValid } from "../utils/formValidationUtils"; -import { cn } from "../utils/styles"; +import { SelectStyles, cn } from "../utils/styles"; -export default function CourseForm() { - const { data: courses, setData: setCourses } = useFetchCourses(); - const { data: modules } = useFetchModules(); - const { data: users } = useFetchUsers(); +interface CourseUser { + uid: number; + username: string; + email: string; +} - const postCourse = usePostCourse(); - const patchCourse = usePatchCourse(); - const deleteCourse = useDeleteCourse(); +interface CourseModuleFormItem { + uid: number; + topic: string; +} - const [modalOpen, setModalOpen] = useState(false); - const [isEditing, setIsEditing] = useState(false); +interface CourseFormData { + id: string; + title: string; + enrolling: boolean; + auto_enroll: boolean; + language: string; + modules: CourseModuleFormItem[]; + instructors: number[]; + roster: number[]; +} - const [formData, setFormData] = useState({ +interface CourseListItem { + id: string; + title: string; + enrolling: boolean; + auto_enroll: boolean; + language: string; + modules: Array; + instructors: CourseUser[]; + roster: CourseUser[]; +} + +function blankCourseForm(): CourseFormData { + return { id: "", title: "", enrolling: false, @@ -42,114 +80,175 @@ export default function CourseForm() { modules: [], instructors: [], roster: [], - }); + }; +} - const [moduleOptions, setModuleOptions] = useState([]); - const [userOptions, setUserOptions] = useState([]); +function normalizeCourseModule( + module: number | Module, + moduleLookup: Map +): CourseModuleFormItem { + if (typeof module === "number") { + return { + uid: module, + topic: moduleLookup.get(module) ?? `Module ${module}`, + }; + } - useEffect(() => { - setModuleOptions( - modules.map((module: any) => ({ - value: module.uid, - label: module.topic || module.uid, - })) - ); + return { + uid: module.uid ?? 0, + topic: module.topic, + }; +} + +function courseToFormData( + course: CourseListItem, + moduleLookup: Map +): CourseFormData { + return { + id: course.id, + title: course.title, + enrolling: course.enrolling, + auto_enroll: course.auto_enroll, + language: course.language, + modules: course.modules.map((module) => normalizeCourseModule(module, moduleLookup)), + instructors: course.instructors.map((instructor) => instructor.uid), + roster: course.roster.map((student) => student.uid), + }; +} + +export default function CourseForm() { + const { data: courses, setData: setCourses } = useFetchCourses(); + const { data: modules } = useFetchModules(); + const { data: users } = useFetchUsers(); + + const postCourse = usePostCourse(); + const patchCourse = usePatchCourse(); + const deleteCourse = useDeleteCourse(); + + const [modalOpen, setModalOpen] = useState(false); + const [isEditing, setIsEditing] = useState(false); + const [formData, setFormData] = useState(blankCourseForm); + + const courseRows = courses as unknown as CourseListItem[]; + const setCourseRows = setCourses as unknown as Dispatch>; - // Prepare options for users - setUserOptions( - users.map((user: any) => ({ - value: user.uid, - label: user.username, - })) + const moduleLookup = useMemo(() => { + return new Map( + modules + .filter((module) => module.uid != null) + .map((module) => [module.uid as number, module.topic || `${module.uid}`]) ); - }, [users, courses]); + }, [modules]); - const handleInputChange = (name: string, value: any) => { + const moduleOptions = useMemo(() => { + return modules + .filter((module) => module.uid != null) + .map((module) => ({ + value: module.uid as number, + label: module.topic || `${module.uid}`, + })); + }, [modules]); + + const userOptions = useMemo(() => { + return users.map((user: any) => ({ + value: user.uid, + label: user.username, + })); + }, [users]); + + const handleInputChange = ( + name: K, + value: CourseFormData[K] + ) => { setFormData((prev) => ({ ...prev, [name]: value })); }; - const handleSelectChange = (selectedOption: MultiValue, name: string) => { + const handleSelectChange = ( + selectedOption: MultiValue, + name: "instructors" | "roster" + ) => { const selectedValues = selectedOption ? selectedOption.map((option) => option.value) : []; setFormData((prev) => ({ ...prev, [name]: selectedValues })); }; - const handleEdit = (course: any) => { - setFormData({ - ...course, - instructors: course.instructors.map((instructor: any) => instructor.uid), // Only include uid for instructors - roster: course.roster.map((user: any) => user.uid), // Only include uid for roster - }); + const handleEdit = (course: CourseListItem) => { + setFormData(courseToFormData(course, moduleLookup)); setIsEditing(true); setModalOpen(true); }; - const handleDelete = async (course: Course) => { + const handleDelete = async (course: CourseListItem) => { try { - if (course.id !== undefined) { - await deleteCourse(course.id); - setCourses((prev) => prev.filter((c) => c.id !== course.id)); - toast.success("Course successfully deleted!"); - } - } catch (err) { + await deleteCourse(course.id); + setCourseRows((prev) => prev.filter((currentCourse) => currentCourse.id !== course.id)); + toast.success("Course successfully deleted!"); + } catch { toast.error("Error deleting course."); } }; + const resetFormData = () => { + setFormData(blankCourseForm()); + }; + const handleSubmit = async () => { try { const payload = { - ...formData, - instructors: formData.instructors.map((uid) => ( uid)), - roster: formData.roster.map((uid) => (uid)), + title: formData.title, + enrolling: formData.enrolling, + auto_enroll: formData.auto_enroll, + language: formData.language, + modules: formData.modules.map((module) => module.uid), + instructors: formData.instructors, + roster: formData.roster, }; if (isEditing) { - const updatedCourse = await patchCourse(payload, formData.id as unknown as number); - setCourses((prev) => prev.map((course) => (course.id === formData.id ? updatedCourse : course))); + const updatedCourse = await patchCourse(payload, formData.id); + setCourseRows((prev) => + prev.map((course) => + course.id === formData.id ? (updatedCourse as CourseListItem) : course + ) + ); toast.success("Course successfully updated!"); } else { const newCourse = await postCourse(payload); - setCourses((prev) => [...prev, newCourse]); + setCourseRows((prev) => [...prev, newCourse as CourseListItem]); toast.success("Course successfully created!"); } + setModalOpen(false); setIsEditing(false); resetFormData(); - } catch (err) { + } catch { toast.error("Error creating/updating course."); } }; - const resetFormData = () => { - setFormData({ - id: "", - title: "", - enrolling: false, - auto_enroll: false, - language: "cpp", - modules: [], - instructors: [], - roster: [], - }); - }; - const requiredFields = ["title"]; const isValid = isFormValid(formData, requiredFields); - const columns: Column[] = [ + const columns: Column[] = [ { header: "Title", accessor: "title" }, - { header: "Modules", accessor: (c: Course) => c.modules.length }, - { header: "Instructors", accessor: (c: Course) => c.instructors.length }, - { header: "Roster", accessor: (c: Course) => c.roster.length }, + { header: "Modules", accessor: (course) => course.modules.length }, + { header: "Instructors", accessor: (course) => course.instructors.length }, + { header: "Roster", accessor: (course) => course.roster.length }, ]; return (
- setModalOpen(true)} title="Create Course" /> + { + resetFormData(); + setIsEditing(false); + setModalOpen(true); + }} + title="Create Course" + /> @@ -206,35 +305,39 @@ export default function CourseForm() { type="checkbox" name="enrolling" checked={formData.enrolling} - onChange={e => { - setFormData(v => ({ - ...v, + onChange={(e) => { + setFormData((value) => ({ + ...value, enrolling: e.target.checked, - auto_enroll: !e.target.checked ? false : v.auto_enroll, + auto_enroll: !e.target.checked ? false : value.auto_enroll, })); }} />
-
+
handleInputChange("auto_enroll", e.target.checked)} + onChange={(e) => handleInputChange("auto_enroll", e.target.checked)} /> @@ -244,19 +347,6 @@ export default function CourseForm() { handleChange={(e) => handleInputChange("language", e.target.value)} initialLanguage={formData.language} /> -
- Modules - formData.instructors.includes(option.value))} onChange={(selectedOption) => handleSelectChange(selectedOption, "instructors")} /> @@ -277,13 +366,164 @@ export default function CourseForm() { isMulti options={userOptions} styles={SelectStyles} - // @ts-ignore value={userOptions.filter((option) => formData.roster.includes(option.value))} onChange={(selectedOption) => handleSelectChange(selectedOption, "roster")} />
+ +
+ Modules + { + setFormData((prev) => ({ + ...prev, + modules: [...prev.modules, module], + })); + }} + /> + { + setFormData((prev) => ({ + ...prev, + modules: arrayMove(prev.modules, fromIndex, toIndex), + })); + }} + onRemove={(uid) => { + setFormData((prev) => ({ + ...prev, + modules: prev.modules.filter((module) => module.uid !== uid), + })); + }} + /> +
); } + +interface ModuleSearchProps { + options: SelectedTag[]; + selectedModules: CourseModuleFormItem[]; + onAdd: (module: CourseModuleFormItem) => void; +} + +function ModuleSearch({ options, selectedModules, onAdd }: ModuleSearchProps) { + const availableOptions = useMemo(() => { + return options.filter((option) => !selectedModules.find((module) => module.uid === option.value)); + }, [options, selectedModules]); + + return ( +