diff --git a/.gitignore b/.gitignore index 1578f516..b9e86c8e 100644 --- a/.gitignore +++ b/.gitignore @@ -11,6 +11,7 @@ # production /build +/dist # misc .DS_Store diff --git a/src/pages/Assignments/AssignmentEditor.test.tsx b/src/pages/Assignments/AssignmentEditor.test.tsx index 353b76e5..1a7626b4 100644 --- a/src/pages/Assignments/AssignmentEditor.test.tsx +++ b/src/pages/Assignments/AssignmentEditor.test.tsx @@ -3,7 +3,17 @@ import { render, screen, within } from "@testing-library/react"; import "@testing-library/jest-dom"; import { vi, beforeEach, describe, expect, it } from "vitest"; import AssignmentEditor from "./AssignmentEditor"; -import { transformAssignmentRequest, IAssignmentFormValues } from "./AssignmentUtil"; +import { + AUTHOR_FEEDBACK_ASSIGNMENT_QUESTIONNAIRE_ID_FIELD, + AUTHOR_FEEDBACK_QUESTIONNAIRE_FIELD, + AUTHOR_FEEDBACK_RUBRIC_ROW_KEY, + IAssignmentFormValues, + TEAMMATE_REVIEW_ASSIGNMENT_QUESTIONNAIRE_ID_FIELD, + TEAMMATE_REVIEW_QUESTIONNAIRE_FIELD, + TEAMMATE_REVIEW_RUBRIC_ROW_KEY, + transformAssignmentRequest, + transformAssignmentResponse, +} from "./AssignmentUtil"; // Mock useAPI to avoid real network calls const sendRequestMock = vi.fn(); @@ -93,6 +103,40 @@ describe("AssignmentEditor rubrics tab", () => { expect(allOptions).toContain("Unlinked Rubric"); }); + it("uses distinct row keys for special rubric field names and control ids", () => { + render(); + + const getRow = (label: string) => { + const row = screen.getByText(label).closest("tr"); + expect(row).not.toBeNull(); + return row as HTMLElement; + }; + + const expectRubricFields = ( + row: HTMLElement, + questionnaireName: string, + rowKey: number + ) => { + const questionnaire = within(row).getByRole("combobox") as HTMLSelectElement; + const numericInputs = within(row).getAllByRole("spinbutton") as HTMLInputElement[]; + + expect(questionnaire.name).toBe(questionnaireName); + expect(questionnaire.id).toBe(`assignment-questionnaire_${rowKey}`); + expect(numericInputs.map((input) => input.name)).toEqual([ + `weights[${rowKey}]`, + `notification_limits[${rowKey}]`, + ]); + expect(numericInputs.map((input) => input.id)).toEqual([ + `assignment-weight_${rowKey}`, + `assignment-notification_limit_${rowKey}`, + ]); + }; + + expectRubricFields(getRow("Review round 2:"), "questionnaire_round_2", 2); + expectRubricFields(getRow("Author feedback:"), AUTHOR_FEEDBACK_QUESTIONNAIRE_FIELD, AUTHOR_FEEDBACK_RUBRIC_ROW_KEY); + expectRubricFields(getRow("Teammate review:"), TEAMMATE_REVIEW_QUESTIONNAIRE_FIELD, TEAMMATE_REVIEW_RUBRIC_ROW_KEY); + }); + }); describe("transformAssignmentRequest", () => { @@ -166,6 +210,50 @@ describe("transformAssignmentRequest", () => { ]); }); + it("serializes special rubric questionnaire fields", () => { + const values: IAssignmentFormValues = { + id: 1, + name: "Test Assignment", + directory_path: "assignment_1", + spec_location: "http://example.com", + private: false, + show_template_review: false, + require_quiz: false, + has_badge: false, + staggered_deadline: false, + is_calibrated: false, + review_rubric_varies_by_round: true, + number_of_review_rounds: 2, + questionnaire_round_1: 101, + [AUTHOR_FEEDBACK_QUESTIONNAIRE_FIELD]: 301, + [AUTHOR_FEEDBACK_ASSIGNMENT_QUESTIONNAIRE_ID_FIELD]: 30, + [TEAMMATE_REVIEW_QUESTIONNAIRE_FIELD]: 401, + weights: [], + notification_limits: [], + use_date_updater: [], + submission_allowed: [], + review_allowed: [], + teammate_allowed: [], + metareview_allowed: [], + reminder: [], + }; + + const payload = JSON.parse(transformAssignmentRequest(values)); + + expect(payload.assignment.assignment_questionnaires_attributes).toEqual([ + { questionnaire_id: 101, used_in_round: 1 }, + { + id: 30, + questionnaire_id: 301, + used_in_round: AUTHOR_FEEDBACK_RUBRIC_ROW_KEY, + }, + { + questionnaire_id: 401, + used_in_round: TEAMMATE_REVIEW_RUBRIC_ROW_KEY, + }, + ]); + }); + it("sets vary_by_round to false when checkbox is unchecked", () => { const values: IAssignmentFormValues = { id: 1, @@ -194,4 +282,94 @@ describe("transformAssignmentRequest", () => { expect(payload.assignment.vary_by_round).toBe(false); }); + + it("maps topic rubric variation to vary_by_topic", () => { + const values: IAssignmentFormValues = { + id: 1, + name: "Test Assignment", + directory_path: "assignment_1", + spec_location: "http://example.com", + private: false, + show_template_review: false, + require_quiz: false, + has_badge: false, + staggered_deadline: false, + is_calibrated: false, + review_rubric_varies_by_topic: true, + weights: [], + notification_limits: [], + use_date_updater: [], + submission_allowed: [], + review_allowed: [], + teammate_allowed: [], + metareview_allowed: [], + reminder: [], + }; + + const payload = JSON.parse(transformAssignmentRequest(values)); + + expect(payload.assignment.vary_by_topic).toBe(true); + }); +}); + +describe("transformAssignmentResponse", () => { + it("prefills topic rubric variation from vary_by_topic", () => { + const assignment = { + id: 1, + name: "Test Assignment", + directory_path: "assignment_1", + spec_location: "http://example.com", + private: false, + show_template_review: false, + require_quiz: false, + has_badge: false, + staggered_deadline: false, + is_calibrated: false, + vary_by_topic: true, + num_review_rounds: 1, + due_dates: [], + assignment_questionnaires: [], + }; + + const values = transformAssignmentResponse(JSON.stringify(assignment)); + + expect(values.review_rubric_varies_by_topic).toBe(true); + }); + + it("prefills special rubric questionnaire fields from assignment questionnaires", () => { + const assignment = { + id: 1, + name: "Test Assignment", + directory_path: "assignment_1", + spec_location: "http://example.com", + private: false, + show_template_review: false, + require_quiz: false, + has_badge: false, + staggered_deadline: false, + is_calibrated: false, + vary_by_topic: false, + num_review_rounds: 1, + due_dates: [], + assignment_questionnaires: [ + { + id: 30, + questionnaire_id: 301, + used_in_round: AUTHOR_FEEDBACK_RUBRIC_ROW_KEY, + }, + { + id: 40, + questionnaire_id: 401, + used_in_round: TEAMMATE_REVIEW_RUBRIC_ROW_KEY, + }, + ], + }; + + const values = transformAssignmentResponse(JSON.stringify(assignment)); + + expect(values[AUTHOR_FEEDBACK_QUESTIONNAIRE_FIELD]).toBe(301); + expect(values[AUTHOR_FEEDBACK_ASSIGNMENT_QUESTIONNAIRE_ID_FIELD]).toBe(30); + expect(values[TEAMMATE_REVIEW_QUESTIONNAIRE_FIELD]).toBe(401); + expect(values[TEAMMATE_REVIEW_ASSIGNMENT_QUESTIONNAIRE_ID_FIELD]).toBe(40); + }); }); diff --git a/src/pages/Assignments/AssignmentEditor.tsx b/src/pages/Assignments/AssignmentEditor.tsx index 1bd19dff..879c7743 100644 --- a/src/pages/Assignments/AssignmentEditor.tsx +++ b/src/pages/Assignments/AssignmentEditor.tsx @@ -2,9 +2,15 @@ import * as Yup from "yup"; import { Button, Modal } from "react-bootstrap"; import { Form, Formik, FormikHelpers } from "formik"; -import { IAssignmentFormValues, transformAssignmentRequest } from "./AssignmentUtil"; +import { + AUTHOR_FEEDBACK_RUBRIC_ROW_KEY, + IAssignmentFormValues, + TEAMMATE_REVIEW_RUBRIC_ROW_KEY, + getSpecialRubricQuestionnaireField, + transformAssignmentRequest, +} from "./AssignmentUtil"; import { IEditor } from "../../utils/interfaces"; -import React, { useCallback, useEffect, useState } from "react"; +import React, { useCallback, useEffect, useRef, useState } from "react"; import { useDispatch, useSelector } from "react-redux"; import { useLoaderData, useLocation, useNavigate, useParams } from "react-router-dom"; import FormInput from "../../components/Form/FormInput"; @@ -53,6 +59,17 @@ interface TopicData { updatedAt?: string; } +interface TopicRubricMapping { + id: number; + questionnaire_id: number; + questionnaire_name?: string; + project_topic_id: number | null; + used_in_round: number | null; +} + +const getTopicRubricMappingKey = (topicDatabaseId: number, usedInRound: number | null) => + `${topicDatabaseId}-${usedInRound ?? "default"}`; + const initialValues: IAssignmentFormValues = { name: "", directory_path: "", @@ -115,6 +132,8 @@ const AssignmentEditor: React.FC = ({ mode }) => { const [calibrationSubmissions, setCalibrationSubmissions] = useState([]); const { data: topicsResponse, error: topicsApiError, sendRequest: fetchTopics } = useAPI(); + const { data: topicRubricMappingsResponse, error: topicRubricMappingsError, sendRequest: fetchTopicRubricMappings } = useAPI(); + const { error: saveTopicRubricError, sendRequest: saveTopicRubricMapping } = useAPI(); const { data: updateResponse, error: updateError, sendRequest: updateAssignment } = useAPI(); const { data: deleteResponse, error: deleteError, sendRequest: deleteTopic } = useAPI(); const { data: createResponse, error: createError, sendRequest: createTopic } = useAPI(); @@ -165,6 +184,9 @@ const AssignmentEditor: React.FC = ({ mode }) => { const [assignmentDuties, setAssignmentDuties] = useState([]); const [selectedDutyIds, setSelectedDutyIds] = useState([]); const [roleBasedLocalError, setRoleBasedLocalError] = useState(null); + const [topicRubricMappings, setTopicRubricMappings] = useState([]); + const [pendingTopicRubricMappingKeys, setPendingTopicRubricMappingKeys] = useState>(new Set()); + const pendingTopicRubricMappingKeysRef = useRef>(new Set()); useEffect(() => { @@ -291,6 +313,15 @@ const AssignmentEditor: React.FC = ({ mode }) => { } }, [id, fetchTopics]); + const refreshTopicRubricMappings = useCallback(() => { + if (!id) return Promise.resolve(); + return fetchTopicRubricMappings({ url: `/assignment_questionnaires?assignment_id=${id}` }); + }, [fetchTopicRubricMappings, id]); + + useEffect(() => { + refreshTopicRubricMappings(); + }, [refreshTopicRubricMappings]); + const refreshAccessibleDuties = useCallback(() => { fetchAccessibleDuties({ url: `/duties/accessible_duties` }); }, [fetchAccessibleDuties]); @@ -343,6 +374,14 @@ const AssignmentEditor: React.FC = ({ mode }) => { setTopicsLoading(false); } }, [topicsResponse]); + + useEffect(() => { + if (topicRubricMappingsResponse?.data) { + const mappings = (topicRubricMappingsResponse.data || []) + .filter((mapping: any) => mapping.project_topic_id !== null && mapping.project_topic_id !== undefined); + setTopicRubricMappings(mappings); + } + }, [topicRubricMappingsResponse]); // Handle topics API errors useEffect(() => { @@ -352,6 +391,18 @@ const AssignmentEditor: React.FC = ({ mode }) => { } }, [topicsApiError]); + useEffect(() => { + if (topicRubricMappingsError) { + dispatch(alertActions.showAlert({ variant: "danger", message: topicRubricMappingsError })); + } + }, [topicRubricMappingsError, dispatch]); + + useEffect(() => { + if (saveTopicRubricError) { + dispatch(alertActions.showAlert({ variant: "danger", message: saveTopicRubricError })); + } + }, [saveTopicRubricError, dispatch]); + const toggleDutySelection = useCallback((dutyId: number) => { setSelectedDutyIds((prev) => prev.includes(dutyId) ? prev.filter((id) => id !== dutyId) : [...prev, dutyId] @@ -433,7 +484,6 @@ const AssignmentEditor: React.FC = ({ mode }) => { }, [dropTeamRequest]); const handleDeleteTopic = useCallback((topicIdentifier: string) => { - console.log(`Delete topic ${topicIdentifier}`); if (id) { deleteTopic({ url: `/project_topics`, @@ -447,7 +497,6 @@ const AssignmentEditor: React.FC = ({ mode }) => { }, [id, deleteTopic]); const handleEditTopic = useCallback((dbId: string, updatedData: any) => { - console.log(`Edit topic DB id ${dbId}`, updatedData); updateTopic({ url: `/project_topics/${dbId}`, method: 'PATCH', @@ -466,7 +515,6 @@ const AssignmentEditor: React.FC = ({ mode }) => { }, [id, updateTopic]); const handleCreateTopic = useCallback((topicData: any) => { - console.log(`Create topic`, topicData); if (id) { createTopic({ url: `/project_topics`, @@ -488,9 +536,85 @@ const AssignmentEditor: React.FC = ({ mode }) => { }, [id, createTopic]); const handleApplyPartnerAd = useCallback((topicId: string, applicationText: string) => { - console.log(`Applying to partner ad for topic ${topicId}: ${applicationText}`); // TODO: Implement partner ad application logic }, []); + + const findTopicRubricMapping = useCallback((topicDatabaseId: number, usedInRound: number | null) => { + return topicRubricMappings.find((mapping) => + Number(mapping.project_topic_id) === Number(topicDatabaseId) && + (mapping.used_in_round ?? null) === (usedInRound ?? null) + ); + }, [topicRubricMappings]); + + const updateTopicRubricMappingPending = useCallback((key: string, isPending: boolean) => { + const nextKeys = new Set(pendingTopicRubricMappingKeysRef.current); + if (isPending) { + nextKeys.add(key); + } else { + nextKeys.delete(key); + } + pendingTopicRubricMappingKeysRef.current = nextKeys; + setPendingTopicRubricMappingKeys(nextKeys); + }, []); + + const isTopicRubricMappingPending = useCallback((topicDatabaseId: number, usedInRound: number | null) => { + return pendingTopicRubricMappingKeys.has(getTopicRubricMappingKey(topicDatabaseId, usedInRound)); + }, [pendingTopicRubricMappingKeys]); + + const handleTopicRubricChange = useCallback(async ( + topicDatabaseId: number, + questionnaireId: number | null, + usedInRound: number | null + ) => { + if (!id) return; + + const mappingKey = getTopicRubricMappingKey(topicDatabaseId, usedInRound); + if (pendingTopicRubricMappingKeysRef.current.has(mappingKey)) return; + + updateTopicRubricMappingPending(mappingKey, true); + const existingMapping = findTopicRubricMapping(topicDatabaseId, usedInRound); + + try { + if (!questionnaireId) { + if (existingMapping) { + await saveTopicRubricMapping({ + url: `/assignment_questionnaires/${existingMapping.id}`, + method: HttpMethod.DELETE, + }); + } + } else if (existingMapping) { + await saveTopicRubricMapping({ + url: `/assignment_questionnaires/${existingMapping.id}`, + method: HttpMethod.PATCH, + data: { + assignment_questionnaire: { + questionnaire_id: questionnaireId, + }, + }, + }); + } else { + await saveTopicRubricMapping({ + url: `/assignment_questionnaires`, + method: HttpMethod.POST, + data: { + assignment_questionnaire: { + assignment_id: Number(id), + questionnaire_id: questionnaireId, + project_topic_id: topicDatabaseId, + used_in_round: usedInRound, + }, + }, + }); + } + + await refreshTopicRubricMappings(); + dispatch(alertActions.showAlert({ variant: "success", message: "Topic rubric mapping saved successfully" })); + } catch { + // useAPI surfaces the request error through saveTopicRubricError. + } finally { + updateTopicRubricMappingPending(mappingKey, false); + } + }, [dispatch, findTopicRubricMapping, id, refreshTopicRubricMappings, saveTopicRubricMapping, updateTopicRubricMappingPending]); @@ -580,10 +704,16 @@ const AssignmentEditor: React.FC = ({ mode }) => { return; } - // validate sum of weights = 100% - const totalWeight = values.weights?.reduce((acc: number, curr: number) => acc + curr, 0) || 0; + // validate sum of weights = 100% only when the user has actually entered weights + const filledWeights = (values.weights || []).filter( + (weight: any) => weight !== "" && weight !== null && weight !== undefined + ); + const totalWeight = filledWeights.reduce( + (acc: number, curr: any) => acc + Number(curr), + 0 + ); - const hasWeights = (values.weights?.length ?? 0) > 0; + const hasWeights = filledWeights.length > 0; if (hasWeights && totalWeight !== 100) { dispatch(alertActions.showAlert({ variant: "danger", message: "Sum of weights must be 100%" })); @@ -598,7 +728,6 @@ const AssignmentEditor: React.FC = ({ mode }) => { } // to be used to display message when assignment is created assignmentData.name = values.name; - console.log(values); sendRequest({ url: url, method: method, @@ -624,6 +753,13 @@ const AssignmentEditor: React.FC = ({ mode }) => { value: q.id, })); + const reviewRubricOptions = (assignmentData.questionnaires || []) + .filter((q: any) => q.questionnaire_type === "ReviewQuestionnaire") + .map((q: any) => ({ + label: q.name, + value: q.id, + })); + const reviewRounds = assignmentData.number_of_review_rounds; // Build initial form values from existing assignment data (update) or defaults (create) @@ -634,8 +770,19 @@ const AssignmentEditor: React.FC = ({ mode }) => { if (mode === "update") { // Prefill per-round questionnaire selections and ids (assignmentData.assignment_questionnaires || []).forEach((aq: any) => { - if (aq.used_in_round && aq.questionnaire) { - formInitialValues[`questionnaire_round_${aq.used_in_round}`] = aq.questionnaire.id; + const questionnaireId = aq.questionnaire_id ?? aq.questionnaire?.id; + if (!questionnaireId) return; + + const specialRubricField = getSpecialRubricQuestionnaireField( + aq.used_in_round, + aq.questionnaire?.questionnaire_type ?? aq.questionnaire_type + ); + + if (specialRubricField) { + formInitialValues[specialRubricField.questionnaireField] = questionnaireId; + formInitialValues[specialRubricField.assignmentQuestionnaireIdField] = aq.id; + } else if (aq.used_in_round) { + formInitialValues[`questionnaire_round_${aq.used_in_round}`] = questionnaireId; formInitialValues[`assignment_questionnaire_id_${aq.used_in_round}`] = aq.id; } }); @@ -746,12 +893,19 @@ const AssignmentEditor: React.FC = ({ mode }) => { topicsData={topicsData} topicsLoading={topicsLoading} topicsError={topicsError} + varyByTopic={!!formik.values.review_rubric_varies_by_topic} + varyByRound={!!formik.values.review_rubric_varies_by_round} + reviewRounds={formik.values.number_of_review_rounds || reviewRounds || 1} + reviewRubricOptions={reviewRubricOptions} + topicRubricMappings={topicRubricMappings} onTopicSettingChange={handleTopicSettingChange} onDropTeam={handleDropTeam} onDeleteTopic={handleDeleteTopic} onEditTopic={handleEditTopic} onCreateTopic={handleCreateTopic} onApplyPartnerAd={handleApplyPartnerAd} + isTopicRubricMappingPending={isTopicRubricMappingPending} + onTopicRubricChange={handleTopicRubricChange} onTopicsChanged={() => id && fetchTopics({ url: `/project_topics?assignment_id=${id}` })} /> @@ -784,6 +938,7 @@ const AssignmentEditor: React.FC = ({ mode }) => { return Array.from({ length: rounds }, (_, i) => ([ { id: i + 1, + rowKey: i + 1, title: `Review round ${i + 1}:`, questionnaire_options: questionnaireOptions, selected_questionnaire: roundSelections[i + 1]?.id, @@ -799,6 +954,7 @@ const AssignmentEditor: React.FC = ({ mode }) => { return [ { id: 0, + rowKey: 0, title: "Review rubric:", questionnaire_options: questionnaireOptions, selected_questionnaire: roundSelections[1]?.id, @@ -813,6 +969,7 @@ const AssignmentEditor: React.FC = ({ mode }) => { })(), { id: formik.values.number_of_review_rounds ?? 0, + rowKey: AUTHOR_FEEDBACK_RUBRIC_ROW_KEY, title: "Author feedback:", questionnaire_options: [{ label: 'Standard author feedback', value: 'Standard author feedback' }], questionnaire_type: 'dropdown', @@ -824,6 +981,7 @@ const AssignmentEditor: React.FC = ({ mode }) => { }, { id: (formik.values.number_of_review_rounds ?? 0) + 1, + rowKey: TEAMMATE_REVIEW_RUBRIC_ROW_KEY, title: "Teammate review:", questionnaire_options: [{ label: 'Review with Github metrics', value: 'Review with Github metrics' }], questionnaire_type: 'dropdown', @@ -841,12 +999,26 @@ const AssignmentEditor: React.FC = ({ mode }) => { }, { cell: ({ row }) =>
{row.original.questionnaire_type === 'dropdown' && + (() => { + const rubricRowKey = row.original.rowKey ?? row.original.id; + let questionnaireFieldName = `questionnaire_round_${row.original.id}`; + let questionnaireControlId = `assignment-questionnaire_${rubricRowKey}`; + + if (row.original.title === "Author feedback:") { + questionnaireFieldName = "author_feedback_questionnaire"; + } else if (row.original.title === "Teammate review:") { + questionnaireFieldName = "teammate_review_questionnaire"; + } + + return ( } + // Formik initialValues handles prefill for review and special rubric fields. + /> + ); + })()} {row.original.questionnaire_type === 'tag_prompts' &&
}
, @@ -858,24 +1030,14 @@ const AssignmentEditor: React.FC = ({ mode }) => { return
; } - // Use distinct indices in the weights array so that - // different rows (review rubric, author feedback, - // teammate review, etc.) do not overwrite each other. - let weightIndex: number; - if (row.original.title === "Author feedback:") { - weightIndex = 100; // separate slot for author feedback - } else if (row.original.title === "Teammate review:") { - weightIndex = 101; // separate slot for teammate review - } else { - weightIndex = row.original.id; - } + const rubricRowKey = row.original.rowKey ?? row.original.id; return (
% @@ -886,8 +1048,24 @@ const AssignmentEditor: React.FC = ({ mode }) => { accessorKey: `weights`, header: "Weight", enableSorting: false, enableColumnFilter: false }, { - cell: ({ row }) => <>{row.original.questionnaire_type === 'dropdown' && - <>
%
}, + cell: ({ row }) => { + if (row.original.questionnaire_type !== 'dropdown') { + return null; + } + + const rubricRowKey = row.original.rowKey ?? row.original.id; + + return ( +
+ + % +
+ ); + }, accessorKey: "notification_limits", header: "Notification Limit", enableSorting: false, enableColumnFilter: false }, ]} @@ -1265,7 +1443,7 @@ const AssignmentEditor: React.FC = ({ mode }) => { {/* Submit button */}
- | Back diff --git a/src/pages/Assignments/AssignmentUtil.ts b/src/pages/Assignments/AssignmentUtil.ts index d52fcdc9..bc3608f1 100644 --- a/src/pages/Assignments/AssignmentUtil.ts +++ b/src/pages/Assignments/AssignmentUtil.ts @@ -77,28 +77,91 @@ export interface IAssignmentFormValues { assignment_questionnaires?: { id: number; used_in_round?: number; - questionnaire?: { id: number; name: string }; + project_topic_id?: number | null; + questionnaire_id?: number; + questionnaire?: { id: number; name: string; questionnaire_type?: string }; }[]; [key: string]: any; } +export const AUTHOR_FEEDBACK_RUBRIC_ROW_KEY = 100; +export const TEAMMATE_REVIEW_RUBRIC_ROW_KEY = 101; +export const AUTHOR_FEEDBACK_QUESTIONNAIRE_FIELD = "author_feedback_questionnaire"; +export const TEAMMATE_REVIEW_QUESTIONNAIRE_FIELD = "teammate_review_questionnaire"; +export const AUTHOR_FEEDBACK_ASSIGNMENT_QUESTIONNAIRE_ID_FIELD = "author_feedback_assignment_questionnaire_id"; +export const TEAMMATE_REVIEW_ASSIGNMENT_QUESTIONNAIRE_ID_FIELD = "teammate_review_assignment_questionnaire_id"; + +export const getSpecialRubricQuestionnaireField = ( + usedInRound?: number | null, + questionnaireType?: string | null +) => { + const normalizedUsedInRound = + usedInRound === null || usedInRound === undefined ? null : Number(usedInRound); + const normalizedQuestionnaireType = questionnaireType?.replace(/\s+/g, "").toLowerCase(); + + if ( + normalizedUsedInRound === AUTHOR_FEEDBACK_RUBRIC_ROW_KEY || + normalizedQuestionnaireType?.includes("authorfeedback") + ) { + return { + rowKey: AUTHOR_FEEDBACK_RUBRIC_ROW_KEY, + questionnaireField: AUTHOR_FEEDBACK_QUESTIONNAIRE_FIELD, + assignmentQuestionnaireIdField: AUTHOR_FEEDBACK_ASSIGNMENT_QUESTIONNAIRE_ID_FIELD, + }; + } + + if ( + normalizedUsedInRound === TEAMMATE_REVIEW_RUBRIC_ROW_KEY || + normalizedQuestionnaireType?.includes("teammatereview") + ) { + return { + rowKey: TEAMMATE_REVIEW_RUBRIC_ROW_KEY, + questionnaireField: TEAMMATE_REVIEW_QUESTIONNAIRE_FIELD, + assignmentQuestionnaireIdField: TEAMMATE_REVIEW_ASSIGNMENT_QUESTIONNAIRE_ID_FIELD, + }; + } + + return null; +}; export const transformAssignmentRequest = (values: IAssignmentFormValues) => { // Build nested attributes for assignment_questionnaires from the per-round form fields to create or update corresponding rows const assignmentQuestionnaires: { id?: number; questionnaire_id: number; used_in_round: number }[] = []; + const addAssignmentQuestionnaire = ( + questionnaireField: string, + assignmentQuestionnaireIdField: string, + usedInRound: number + ) => { + const questionnaireId = values[questionnaireField]; + if (!questionnaireId) return; + + assignmentQuestionnaires.push({ + id: values[assignmentQuestionnaireIdField], + questionnaire_id: questionnaireId, + used_in_round: usedInRound, + }); + }; + const roundCount = values.number_of_review_rounds ?? 0; for (let i = 1; i <= roundCount; i += 1) { - const questionnaireId = values[`questionnaire_round_${i}`]; - if (questionnaireId) { - const existingId = values[`assignment_questionnaire_id_${i}`]; - assignmentQuestionnaires.push({ - id: existingId, - questionnaire_id: questionnaireId, - used_in_round: i, - }); - } + addAssignmentQuestionnaire( + `questionnaire_round_${i}`, + `assignment_questionnaire_id_${i}`, + i + ); } + addAssignmentQuestionnaire( + AUTHOR_FEEDBACK_QUESTIONNAIRE_FIELD, + AUTHOR_FEEDBACK_ASSIGNMENT_QUESTIONNAIRE_ID_FIELD, + AUTHOR_FEEDBACK_RUBRIC_ROW_KEY + ); + addAssignmentQuestionnaire( + TEAMMATE_REVIEW_QUESTIONNAIRE_FIELD, + TEAMMATE_REVIEW_ASSIGNMENT_QUESTIONNAIRE_ID_FIELD, + TEAMMATE_REVIEW_RUBRIC_ROW_KEY + ); + const assignment: IAssignmentRequest = { // Core fields name: values.name, @@ -177,6 +240,7 @@ export const transformAssignmentRequest = (values: IAssignmentFormValues) => { // Per-round rubric configuration vary_by_round: values.review_rubric_varies_by_round, + vary_by_topic: values.review_rubric_varies_by_topic, rounds_of_reviews: values.number_of_review_rounds, assignment_questionnaires_attributes: assignmentQuestionnaires, @@ -240,6 +304,7 @@ export const transformAssignmentResponse = (assignmentResponse: string) => { // review rounds / rubrics review_rubric_varies_by_round: assignment.varying_rubrics_by_round ?? assignment.vary_by_round, + review_rubric_varies_by_topic: (assignment as any).vary_by_topic ?? false, number_of_review_rounds: assignment.num_review_rounds, is_role_based: (assignment as any).is_role_based ?? false, @@ -250,6 +315,30 @@ export const transformAssignmentResponse = (assignmentResponse: string) => { due_dates: assignment.due_dates, assignment_questionnaires: assignment.assignment_questionnaires, }; + + (assignment.assignment_questionnaires || []).forEach((assignmentQuestionnaire: any) => { + const questionnaireId = + assignmentQuestionnaire.questionnaire_id ?? assignmentQuestionnaire.questionnaire?.id; + if (!questionnaireId) return; + + const specialRubricField = getSpecialRubricQuestionnaireField( + assignmentQuestionnaire.used_in_round, + assignmentQuestionnaire.questionnaire?.questionnaire_type ?? assignmentQuestionnaire.questionnaire_type + ); + + if (specialRubricField) { + assignmentValues[specialRubricField.questionnaireField] = questionnaireId; + assignmentValues[specialRubricField.assignmentQuestionnaireIdField] = assignmentQuestionnaire.id; + return; + } + + if (assignmentQuestionnaire.used_in_round) { + assignmentValues[`questionnaire_round_${assignmentQuestionnaire.used_in_round}`] = questionnaireId; + assignmentValues[`assignment_questionnaire_id_${assignmentQuestionnaire.used_in_round}`] = + assignmentQuestionnaire.id; + } + }); + return assignmentValues; }; diff --git a/src/pages/Assignments/tabs/TopicsTab.test.tsx b/src/pages/Assignments/tabs/TopicsTab.test.tsx new file mode 100644 index 00000000..4ad55c4b --- /dev/null +++ b/src/pages/Assignments/tabs/TopicsTab.test.tsx @@ -0,0 +1,103 @@ +import React from "react"; +import { fireEvent, render, screen, within } from "@testing-library/react"; +import "@testing-library/jest-dom"; +import { describe, expect, it, vi } from "vitest"; +import TopicsTab from "./TopicsTab"; + +const baseProps = { + assignmentName: "Program 4", + assignmentId: "1", + topicSettings: { + allowTopicSuggestions: false, + enableBidding: false, + enableAuthorsReview: true, + allowReviewerChoice: true, + allowBookmarks: false, + allowBiddingForReviewers: false, + allowAdvertiseForPartners: false, + }, + topicsData: [ + { + id: "T1", + databaseId: 10, + name: "Security Topic", + assignedTeams: [], + waitlistedTeams: [], + questionnaire: "Default rubric", + numSlots: 2, + availableSlots: 2, + bookmarks: [], + }, + ], + onTopicSettingChange: vi.fn(), + onDropTeam: vi.fn(), + onDeleteTopic: vi.fn(), + onEditTopic: vi.fn(), + onCreateTopic: vi.fn(), + onApplyPartnerAd: vi.fn(), +}; + +describe("TopicsTab topic rubric selectors", () => { + it("shows a review rubric dropdown for each topic when topic variation is enabled", () => { + render( + + ); + + const row = screen.getByText("Security Topic").closest("tr"); + expect(row).not.toBeNull(); + const rubricSelect = within(row as HTMLElement).getByLabelText("Rubric for Security Topic") as HTMLSelectElement; + + expect(rubricSelect.value).toBe("7"); + expect(within(rubricSelect).getByText("Use assignment default rubric")).toBeInTheDocument(); + expect(within(rubricSelect).getByText("Security Rubric")).toBeInTheDocument(); + }); + + it("sends topic, questionnaire, and round when a topic rubric changes", () => { + const onTopicRubricChange = vi.fn(); + + render( + + ); + + const roundTwoSelect = screen.getByLabelText("Round 2 for Security Topic"); + fireEvent.change(roundTwoSelect, { target: { value: "8" } }); + + expect(onTopicRubricChange).toHaveBeenCalledWith(10, 8, 2); + }); + + it("disables only the topic rubric selector currently being saved", () => { + render( + usedInRound === 2} + /> + ); + + expect(screen.getByLabelText("Round 1 for Security Topic")).not.toBeDisabled(); + expect(screen.getByLabelText("Round 2 for Security Topic")).toBeDisabled(); + }); +}); diff --git a/src/pages/Assignments/tabs/TopicsTab.tsx b/src/pages/Assignments/tabs/TopicsTab.tsx index 1a433ff8..20a4963d 100644 --- a/src/pages/Assignments/tabs/TopicsTab.tsx +++ b/src/pages/Assignments/tabs/TopicsTab.tsx @@ -37,7 +37,7 @@ interface BookmarkData { } // Updated TopicData interface -interface TopicData { +interface TopicData { id: string; // topic_identifier for display/selection databaseId: number; // Database ID for API calls name: string; // Topic Name @@ -53,11 +53,24 @@ interface TopicData { bookmarks: BookmarkData[]; // Array of bookmarks for this topic partnerAd?: PartnerAd; // Optional partner advertisement details createdAt?: string; - updatedAt?: string; -} - -// Same as before -interface TopicSettings { + updatedAt?: string; +} + +interface RubricOption { + label: string; + value: number; +} + +interface TopicRubricMapping { + id: number; + questionnaire_id: number; + questionnaire_name?: string; + project_topic_id: number | null; + used_in_round: number | null; +} + +// Same as before +interface TopicSettings { allowTopicSuggestions: boolean; enableBidding: boolean; enableAuthorsReview: boolean; @@ -71,19 +84,26 @@ interface TopicsTabProps { assignmentName?: string; assignmentId: string; topicSettings: TopicSettings; - topicsData: TopicData[]; // Ensure the data passed matches the updated TopicData interface - topicsLoading?: boolean; - topicsError?: string | null; - onTopicSettingChange: (setting: string, value: boolean) => void; + topicsData: TopicData[]; // Ensure the data passed matches the updated TopicData interface + topicsLoading?: boolean; + topicsError?: string | null; + varyByTopic?: boolean; + varyByRound?: boolean; + reviewRounds?: number; + reviewRubricOptions?: RubricOption[]; + topicRubricMappings?: TopicRubricMapping[]; + onTopicSettingChange: (setting: string, value: boolean) => void; // Add handlers for actions like drop team, delete topic, edit topic, create bookmark etc. onDropTeam: (topicId: string, teamId: string) => void; onDeleteTopic: (topicId: string) => void; onEditTopic: (topicId: string, updatedData?: any) => void; - onCreateTopic?: (topicData: any) => void; - // Handler for partner ad application submission - onApplyPartnerAd: (topicId: string, applicationText: string) => void; - onTopicsChanged?: () => void; -} + onCreateTopic?: (topicData: any) => void; + // Handler for partner ad application submission + onApplyPartnerAd: (topicId: string, applicationText: string) => void; + isTopicRubricMappingPending?: (topicDatabaseId: number, usedInRound: number | null) => boolean; + onTopicRubricChange?: (topicDatabaseId: number, questionnaireId: number | null, usedInRound: number | null) => void; + onTopicsChanged?: () => void; +} // --- Component Implementation --- @@ -91,17 +111,24 @@ const TopicsTab = ({ assignmentName = "Assignment", assignmentId, topicSettings, - topicsData, - topicsLoading = false, - topicsError = null, - onTopicSettingChange, + topicsData, + topicsLoading = false, + topicsError = null, + varyByTopic = false, + varyByRound = false, + reviewRounds = 1, + reviewRubricOptions = [], + topicRubricMappings = [], + onTopicSettingChange, onDropTeam, onDeleteTopic, onEditTopic, - onCreateTopic, - onApplyPartnerAd, - onTopicsChanged, -}: TopicsTabProps) => { + onCreateTopic, + onApplyPartnerAd, + isTopicRubricMappingPending, + onTopicRubricChange, + onTopicsChanged, +}: TopicsTabProps) => { const [showPartnerAdModal, setShowPartnerAdModal] = useState(false); const [selectedPartnerAdTopic, setSelectedPartnerAdTopic] = useState(null); const [partnerAdApplication, setPartnerAdApplication] = useState(""); @@ -308,8 +335,55 @@ const TopicsTab = ({ }; // Check if questionnaire varies across topics - const questionnaireVaries = topicsData.length > 0 && - topicsData.some(t => t.questionnaire !== topicsData[0].questionnaire); + const questionnaireVaries = topicsData.length > 0 && + topicsData.some(t => t.questionnaire !== topicsData[0].questionnaire); + + const rubricRounds = varyByRound + ? Array.from({ length: Math.max(reviewRounds || 1, 1) }, (_, index) => index + 1) + : [null]; + + const findTopicRubricMapping = (topicDatabaseId?: number, usedInRound?: number | null) => { + if (!topicDatabaseId) return undefined; + + return topicRubricMappings.find((mapping) => + Number(mapping.project_topic_id) === Number(topicDatabaseId) && + (mapping.used_in_round ?? null) === (usedInRound ?? null) + ); + }; + + const renderRubricSelector = (topic: TopicData, usedInRound: number | null) => { + const mapping = findTopicRubricMapping(topic.databaseId, usedInRound); + const label = usedInRound ? `Round ${usedInRound}` : "Rubric"; + const isSaving = isTopicRubricMappingPending?.(topic.databaseId, usedInRound) ?? false; + + return ( +
+ {varyByRound &&
{label}
} + { + const value = event.target.value; + onTopicRubricChange?.( + topic.databaseId, + value ? Number(value) : null, + usedInRound + ); + }} + > + + {reviewRubricOptions.map((option) => ( + + ))} + +
+ ); + }; // --- Render Helper Functions --- // removed: renderTeamMembers (moved to TopicsTable renderDetails inline rendering) @@ -320,7 +394,7 @@ const TopicsTab = ({

Topics for {assignmentName} assignment

{/* Topic Settings */} -
+
onTopicSettingChange('allowBiddingForReviewers', e.target.checked)} /> - +
{/* Error Message */} {topicsError && ( @@ -407,9 +481,27 @@ const TopicsTab = ({ isRowSelected={(id) => selectedTopics.has(id)} onToggleAll={handleSelectAll} onToggleRow={handleSelectTopic} - extraColumns={[ - ...(questionnaireVaries //displays the questionnaire column only if it varies across the topics - ? [ + extraColumns={[ + ...(varyByTopic + ? [ + { + id: "topicReviewRubric", + header: varyByRound ? "Review Rubrics by Round" : "Review Rubric", + cell: ({ row }: any) => { + const topic = topicsData.find(t => t.id === row.original.id); + if (!topic) return null; + + return ( +
+ {rubricRounds.map((round) => renderRubricSelector(topic, round))} +
+ ); + }, + }, + ] + : []), + ...(questionnaireVaries //displays the questionnaire column only if it varies across the topics + ? [ { id: "questionnaire", header: "Questionnaire", diff --git a/src/pages/Questionnaires/Questionnaire.tsx b/src/pages/Questionnaires/Questionnaire.tsx index 982d8989..ee40d5aa 100644 --- a/src/pages/Questionnaires/Questionnaire.tsx +++ b/src/pages/Questionnaires/Questionnaire.tsx @@ -48,19 +48,18 @@ const Questionnaires = () => { const onDeleteQuestionnaireHandler = useCallback(() => setShowDeleteConfirmation({ visible: false }), []); const onEditHandle = useCallback( - (row: TRow) => navigate(`edit/${row.original.id}`), + (row: TRow) => navigate(`/questionnaires/edit/${row.original.id}`), [navigate] ); const onDeleteHandle = useCallback( - (row: TRow) => { - console.log("Delete clicked:", row.original); - setSelectedQuestionnaire(null); - setTimeout(() => { - setShowDeleteConfirmation({ visible: true, data: row.original }); - }, 100); - }, - [] + (row: TRow) => { + setSelectedQuestionnaire(null); + setTimeout(() => { + setShowDeleteConfirmation({ visible: true, data: row.original }); + }, 100); + }, + [] ); diff --git a/src/pages/Questionnaires/QuestionnaireForm.tsx b/src/pages/Questionnaires/QuestionnaireForm.tsx index 5c911d53..56885233 100644 --- a/src/pages/Questionnaires/QuestionnaireForm.tsx +++ b/src/pages/Questionnaires/QuestionnaireForm.tsx @@ -1,21 +1,23 @@ - import React, { useEffect, useState } 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 QuestionnaireForm = ({ initialValues, onSubmit }: any) => { - - const { data: itemTypes, sendRequest: fetchItemTypes } = useAPI(); - - - useEffect(() => { - - fetchItemTypes({ url: "/item_types" }); - console.log(itemTypes?.data); - }, [fetchItemTypes]); + import React, { useEffect, useState } from "react"; + import { Formik, Field, Form, ErrorMessage } from "formik"; + import { Button } from 'react-bootstrap'; + import QuestionnaireItemsFieldArray from "./QuestionnaireItemsFieldArray"; + import { QuestionItemTypes } from "./QuestionnaireUtils"; + import * as Yup from "yup"; + import useAPI from "hooks/useAPI"; + + + const QuestionnaireForm = ({ initialValues, onSubmit }: any) => { + + const { data: itemTypes, sendRequest: fetchItemTypes } = useAPI(); + const availableItemTypes = (itemTypes?.data?.map((t: any) => t.name) as string[])?.length + ? (itemTypes.data.map((t: any) => t.name) as string[]) + : QuestionItemTypes; + + + useEffect(() => { + fetchItemTypes({ url: "/questions/types" }); + }, [fetchItemTypes]); const itemFields = Yup.object().shape({ @@ -177,7 +179,12 @@ {/* Allows users to input a variable number of questions / items */} - t.name) as string[]) ?? []} /> +