diff --git a/src/App.tsx b/src/App.tsx index 59c76165..3bc2e976 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -69,11 +69,6 @@ function App() { path: "view-team-grades", element: } />, }, - { - path: "edit-questionnaire", - element: } />, - }, - { path: "assignments/edit/:id", element: , @@ -398,7 +393,6 @@ function App() { }, { path: "*", element: }, - { path: "questionnaire", element: , loader: loadQuestionnaire }, { path: "questionnaires", diff --git a/src/components/Modals/GradeExportModal.tsx b/src/components/Modals/GradeExportModal.tsx new file mode 100644 index 00000000..ef06dce5 --- /dev/null +++ b/src/components/Modals/GradeExportModal.tsx @@ -0,0 +1,199 @@ +// src/components/Modals/GradeExportModal.tsx +import React, { useState } from "react"; +import { Modal, Button, Form, Row, Col } from "react-bootstrap"; +import useAPI from "../../hooks/useAPI"; +import { HttpMethod } from "../../utils/httpMethods"; + +/* ------------------------------------------------------------------ */ +/* Shared text style */ +/* ------------------------------------------------------------------ */ +const TABLE_TEXT: React.CSSProperties = { + fontFamily: "verdana, arial, helvetica, sans-serif", + color: "#333", + fontSize: "15px", + lineHeight: "1.428em", +}; + +const STANDARD_TEXT: React.CSSProperties = { + fontFamily: "verdana, arial, helvetica, sans-serif", + color: "#333", + fontSize: "13px", + lineHeight: "30px", +}; + +/* ------------------------------------------------------------------ */ +/* Export type options */ +/* ------------------------------------------------------------------ */ +type ExportType = + | "export_assignment_grades" + | "export_author_feedback_grades" + | "export_teammate_review_grades"; + +const EXPORT_OPTIONS: { value: ExportType; label: string; description: string }[] = [ + { + value: "export_assignment_grades", + label: "Assignment Grades", + description: "One row per team with the instructor-assigned grade and comment.", + }, + { + value: "export_author_feedback_grades", + label: "Author Feedback Grades", + description: "One row per participant with their average score from author feedback.", + }, + { + value: "export_teammate_review_grades", + label: "Teammate Review Grades", + description: "One row per participant with their average teammate-review score.", + }, +]; + +/* ------------------------------------------------------------------ */ +/* Props */ +/* ------------------------------------------------------------------ */ +type GradeExportModalProps = { + show: boolean; + onHide: () => void; + assignmentId: number | string; +}; + +/* ================================================================== */ +/* GradeExportModal Component */ +/* ================================================================== */ +const GradeExportModal: React.FC = ({ + show, + onHide, + assignmentId, +}) => { + const [selectedType, setSelectedType] = useState("export_assignment_grades"); + const [status, setStatus] = useState(""); + + const { isLoading, sendRequest: fetchExport } = useAPI(); + + /* Reset status when modal opens */ + React.useEffect(() => { + if (show) setStatus(""); + }, [show]); + + /* Download helper — same pattern as ExportModal */ + const downloadFile = (csvData: string, filename: string) => { + const url = window.URL.createObjectURL(new Blob([csvData], { type: "text/csv" })); + const link = document.createElement("a"); + link.href = url; + link.setAttribute("download", filename); + document.body.appendChild(link); + link.click(); + link.remove(); + window.URL.revokeObjectURL(url); + }; + + const onExport = async () => { + if (!assignmentId) { + setStatus("Assignment ID is missing."); + return; + } + + setStatus("Generating CSV…"); + + try { + const response = await fetchExport({ + url: `/grades/${assignmentId}/${selectedType}`, + method: HttpMethod.GET, + }); + + // fetchExport stores the result in useAPI's `data`; we need the raw response + // The hook returns void, so we rely on the data state — handled in useEffect below + } catch (err: any) { + setStatus(err.message || "Unexpected error."); + } + }; + + /* Listen to API response via a separate hook call */ + const { data: exportResponse, error: exportError, sendRequest: doExport } = useAPI(); + + const handleExport = async () => { + if (!assignmentId) { + setStatus("Assignment ID is missing."); + return; + } + setStatus("Generating CSV…"); + await doExport({ + url: `/grades/${assignmentId}/${selectedType}`, + method: HttpMethod.GET, + }); + }; + + React.useEffect(() => { + if (exportResponse) { + const { data: csvData, filename, message } = exportResponse.data; + setStatus(message || "Export complete."); + downloadFile(csvData, filename); + setTimeout(onHide, 1500); + } else if (exportError) { + setStatus(exportError); + } + }, [exportResponse, exportError]); + + /* ---------------------------------------------------------------- */ + /* Render */ + /* ---------------------------------------------------------------- */ + return ( + + + + Export Grades + + + + + + Select grade type to export + + + {EXPORT_OPTIONS.map((opt) => ( +
+ {opt.label}} + checked={selectedType === opt.value} + onChange={() => setSelectedType(opt.value)} + style={TABLE_TEXT} + /> +
+ {opt.description} +
+
+ ))} + + {status && ( + + +
+ Status: {status} +
+ +
+ )} +
+ + + + + +
+ ); +}; + +export default GradeExportModal; diff --git a/src/layout/Header.tsx b/src/layout/Header.tsx index 480446ca..4fb3b02f 100644 --- a/src/layout/Header.tsx +++ b/src/layout/Header.tsx @@ -128,7 +128,7 @@ const Header: React.FC = () => { Assignments - + Questionnaire diff --git a/src/pages/Questionnaires/Questionnaire.tsx b/src/pages/Questionnaires/Questionnaire.tsx index 982d8989..90cde3db 100644 --- a/src/pages/Questionnaires/Questionnaire.tsx +++ b/src/pages/Questionnaires/Questionnaire.tsx @@ -22,7 +22,7 @@ const Questionnaires = () => { const [showTypeModal, setShowTypeModal] = useState(false); // loader option - const questionnaireData :any = useLoaderData(); + const questionnaireData = useLoaderData() as QuestionnaireResponse[]; useEffect(() => { setShowTypeModal(false); @@ -63,12 +63,6 @@ const Questionnaires = () => { [] ); - - useEffect(() => { - if (error) { - dispatch(alertActions.showAlert({ variant: "danger", message: error })); - } - }, [error, dispatch]); const tableColumns = useMemo( () => questionnaireColumns(onEditHandle, onDeleteHandle), diff --git a/src/pages/Questionnaires/QuestionnaireDelete.tsx b/src/pages/Questionnaires/QuestionnaireDelete.tsx index 5d00638a..2b8b568f 100644 --- a/src/pages/Questionnaires/QuestionnaireDelete.tsx +++ b/src/pages/Questionnaires/QuestionnaireDelete.tsx @@ -15,20 +15,24 @@ interface IDeleteQuestionnaire { const DeleteQuestionnaire: React.FC = ({ questionnaireData, onClose, onDeleteSuccess }) => { const { data: deletedQuestionnaire, error: questionnaireError, sendRequest: deleteQuestionnaire } = useAPI(); - + const [show, setShow] = useState(true); + const [isDeleting, setIsDeleting] = useState(false); const dispatch = useDispatch(); - - const deleteHandler = () => - deleteQuestionnaire({ - url: `/questionnaires/${questionnaireData.id}`, - method: HttpMethod.DELETE + + const deleteHandler = () => { + setIsDeleting(true); + deleteQuestionnaire({ + url: `/questionnaires/${questionnaireData.id}`, + method: HttpMethod.DELETE }); + }; useEffect(() => { if (questionnaireError) { + setIsDeleting(false); dispatch(alertActions.showAlert({ variant: "danger", message: questionnaireError })); } }, [questionnaireError, dispatch]); @@ -73,8 +77,8 @@ const DeleteQuestionnaire: React.FC = ({ questionnaireData - diff --git a/src/pages/Questionnaires/QuestionnaireEditor.tsx b/src/pages/Questionnaires/QuestionnaireEditor.tsx index 16097f7c..0d0a1048 100644 --- a/src/pages/Questionnaires/QuestionnaireEditor.tsx +++ b/src/pages/Questionnaires/QuestionnaireEditor.tsx @@ -1,23 +1,26 @@ import axiosClient from "../../utils/axios_client"; import { IEditor } from "../../utils/interfaces"; -import { QuestionnaireFormValues , transformQuestionnaireRequest } from "./QuestionnaireUtils"; +import { IItem, QuestionnaireFormValues, transformQuestionnaireRequest } from "./QuestionnaireUtils"; import React, { useEffect, useState } from "react"; import { useLoaderData, useLocation, useNavigate, useSearchParams } from "react-router-dom"; -import { Col, Container, Modal, Row } from 'react-bootstrap'; +import { Col, Container, Row } from 'react-bootstrap'; import QuestionnaireForm from "./QuestionnaireForm"; -import { useSelector} from "react-redux"; +import { useDispatch, useSelector } from "react-redux"; import { RootState } from "../../store/store"; +import { alertActions } from "../../store/slices/alertSlice"; const QuestionnaireEditor: React.FC = ({ mode }) => { const token = localStorage.getItem("token"); - const questionnaire :any = useLoaderData(); + const questionnaire = useLoaderData() as QuestionnaireFormValues | undefined; const [searchParams] = useSearchParams(); const location = useLocation(); const navigate = useNavigate(); + const dispatch = useDispatch(); const type = searchParams.get("type"); - const [fetchedItems, setFetchedItems] = useState([]); + const [fetchedItems, setFetchedItems] = useState([]); + const [isSubmitting, setIsSubmitting] = useState(false); useEffect(() => { @@ -48,27 +51,31 @@ const QuestionnaireEditor: React.FC = ({ mode }) => { console.log("Type:", type); - // the form values to the browser console. const onSubmit = async (values: QuestionnaireFormValues) => { values.instructor_id = auth.user.id; - //values.instructor = auth.user.name; - console.log("Submit:", values); const payload = transformQuestionnaireRequest(values); const endpoint = mode === "create" ? "/questionnaires" : `/questionnaires/${values.id}`; + setIsSubmitting(true); try { - const response = await axiosClient[mode === "create" ? "post" : "put"]( + await axiosClient[mode === "create" ? "post" : "put"]( endpoint, - payload, + payload, { headers: { Authorization: `Bearer ${token}` } } ); - console.log("Saved Questionnaire:", response.data); + dispatch(alertActions.showAlert({ + variant: "success", + message: `Questionnaire "${values.name}" ${mode === "create" ? "created" : "updated"} successfully!`, + })); navigate("/questionnaires"); - } catch (error) { - console.error("Error submitting form:", error); + } catch (error: any) { + const message = error?.response?.data?.error ?? "Failed to save questionnaire. Please try again."; + dispatch(alertActions.showAlert({ variant: "danger", message })); + } finally { + setIsSubmitting(false); } }; @@ -106,7 +113,7 @@ const QuestionnaireEditor: React.FC = ({ mode }) => {

{mode === "update" - ? `Update Questionnaire: ${questionnaire.name}` + ? `Update Questionnaire: ${questionnaire?.name}` : `Create ${type} Questionnaire`}


@@ -116,6 +123,7 @@ const QuestionnaireEditor: React.FC = ({ mode }) => {
diff --git a/src/pages/Questionnaires/QuestionnaireForm.tsx b/src/pages/Questionnaires/QuestionnaireForm.tsx index 5c911d53..10498fd5 100644 --- a/src/pages/Questionnaires/QuestionnaireForm.tsx +++ b/src/pages/Questionnaires/QuestionnaireForm.tsx @@ -1,21 +1,12 @@ - import React, { useEffect, useState } from "react"; + import React from "react"; import { Formik, Field, Form, ErrorMessage } from "formik"; import { Button } from 'react-bootstrap'; import QuestionnaireItemsFieldArray from "./QuestionnaireItemsFieldArray"; import * as Yup from "yup"; - import useAPI from "hooks/useAPI"; + const ITEM_TYPES = ["Criterion", "Scale", "dropdown", "multiple_choice"]; - const QuestionnaireForm = ({ initialValues, onSubmit }: any) => { - - const { data: itemTypes, sendRequest: fetchItemTypes } = useAPI(); - - - useEffect(() => { - - fetchItemTypes({ url: "/item_types" }); - console.log(itemTypes?.data); - }, [fetchItemTypes]); + const QuestionnaireForm = ({ initialValues, onSubmit, isSubmitting }: any) => { const itemFields = Yup.object().shape({ @@ -151,13 +142,14 @@
- + Private
-   ← Min     Item Score     Max →  - t.name) as string[]) ?? []} /> +
- )} diff --git a/src/pages/Questionnaires/QuestionnaireItemsFieldArray.tsx b/src/pages/Questionnaires/QuestionnaireItemsFieldArray.tsx index 062887a9..c60947da 100644 --- a/src/pages/Questionnaires/QuestionnaireItemsFieldArray.tsx +++ b/src/pages/Questionnaires/QuestionnaireItemsFieldArray.tsx @@ -45,6 +45,16 @@ const QuestionnaireItemsFieldArray: React.FC = ({ onDragEnd={(result: DropResult) => { if (!result.destination) return; move(result.source.index, result.destination.index); + // Recompute seq for all items to match the new visual order + const reordered = [...form.values.items]; + const [removed] = reordered.splice(result.source.index, 1); + reordered.splice(result.destination.index, 0, removed); + let seq = 1; + reordered.forEach((item: IItem, i: number) => { + if (!item._destroy) { + setFieldValue(`items[${i}].seq`, seq++); + } + }); }} > diff --git a/src/pages/Questionnaires/QuestionnaireUtils.tsx b/src/pages/Questionnaires/QuestionnaireUtils.tsx index e8a2bfa4..b32509c1 100644 --- a/src/pages/Questionnaires/QuestionnaireUtils.tsx +++ b/src/pages/Questionnaires/QuestionnaireUtils.tsx @@ -32,13 +32,17 @@ export interface IItem { question_type: string; size?: number | string; alternatives?: string; - min_label?: number; - max_label?: number; + min_label?: string; + max_label?: string; break_before?: boolean; questionnaire_id?: number; _destroy?: boolean; type?: string; - + textarea_width?: number | string; + textarea_height?: number | string; + textbox_width?: number | string; + col_names?: string; + row_names?: string; }