From 3e3b58e53ccc0a406abc2ef94b5952010131317f Mon Sep 17 00:00:00 2001 From: NickK21 Date: Fri, 27 Mar 2026 05:57:08 -0700 Subject: [PATCH 1/2] Allow ordering modules within courses --- codewit/client/src/hooks/useCourse.ts | 16 +- codewit/client/src/pages/CourseForm.tsx | 432 ++++++++++++++++++------ 2 files changed, 349 insertions(+), 99 deletions(-) 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..4a80b91 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[]; +} + +interface CourseListItem { + id: string; + title: string; + enrolling: boolean; + auto_enroll: boolean; + language: string; + modules: Array; + instructors: CourseUser[]; + roster: CourseUser[]; +} - const [formData, setFormData] = useState({ +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, + }; +} - // Prepare options for users - setUserOptions( - users.map((user: any) => ({ - value: user.uid, - label: user.username, - })) +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>; + + 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,16 +347,32 @@ export default function CourseForm() { handleChange={(e) => handleInputChange("language", e.target.value)} initialLanguage={formData.language} /> -
- Modules - ) => { + if (value != null) { + onAdd({ uid: value.value, topic: value.label }); + } + }} + isDisabled={availableOptions.length === 0} + /> + ); +} + +interface SortableModuleListProps { + modules: CourseModuleFormItem[]; + onSwap: (fromIndex: number, toIndex: number) => void; + onRemove: (uid: number) => void; +} + +function SortableModuleList({ modules, onSwap, onRemove }: SortableModuleListProps) { + const sensors = useSensors( + useSensor(PointerSensor), + useSensor(KeyboardSensor, { + coordinateGetter: sortableKeyboardCoordinates, + }) + ); + + function onDragEnd({ active, over }: DragEndEvent) { + if (over == null || active.id === over.id) { + return; + } + + const activeIndex = modules.findIndex((module) => module.uid === active.id); + const overIndex = modules.findIndex((module) => module.uid === over.id); + + if (activeIndex === -1 || overIndex === -1) { + return; + } + + onSwap(activeIndex, overIndex); + } + + if (modules.length === 0) { + return

No modules selected.

; + } + + return ( +
+ + module.uid)} strategy={verticalListSortingStrategy}> + {modules.map((module, index) => ( + onRemove(module.uid)} + /> + ))} + + +
+ ); +} + +interface SortableModuleItemProps { + module: CourseModuleFormItem; + index: number; + onRemove: () => void; +} + +function SortableModuleItem({ module, index, onRemove }: SortableModuleItemProps) { + const { attributes, listeners, setNodeRef, transform, transition } = useSortable({ + id: module.uid, + }); + + const style = { + transform: + transform != null + ? `translate3d(0px, ${Math.round(transform.y)}px, 0) scaleX(${transform.scaleX}) scaleY(${transform.scaleY})` + : "", + transition, + }; + + return ( +
+
+ +
+
+

{`${index + 1}. ${module.topic}`}

+ uid: {module.uid} +
+ +
+ ); +} From 93806076257d21c8193f615f2a266173af9a285c Mon Sep 17 00:00:00 2001 From: NickK21 Date: Fri, 27 Mar 2026 18:26:57 -0700 Subject: [PATCH 2/2] Move course modules section to end of form --- codewit/client/src/pages/CourseForm.tsx | 48 ++++++++++++------------- 1 file changed, 24 insertions(+), 24 deletions(-) diff --git a/codewit/client/src/pages/CourseForm.tsx b/codewit/client/src/pages/CourseForm.tsx index 4a80b91..e5981e7 100644 --- a/codewit/client/src/pages/CourseForm.tsx +++ b/codewit/client/src/pages/CourseForm.tsx @@ -347,6 +347,30 @@ export default function CourseForm() { handleChange={(e) => handleInputChange("language", e.target.value)} initialLanguage={formData.language} /> +
+ Instructors + formData.roster.includes(option.value))} + onChange={(selectedOption) => handleSelectChange(selectedOption, "roster")} + /> +
+
Modules
- -
- Instructors - formData.roster.includes(option.value))} - onChange={(selectedOption) => handleSelectChange(selectedOption, "roster")} - /> -