diff --git a/app/models/course/assessment/question/rubric_based_response.rb b/app/models/course/assessment/question/rubric_based_response.rb index e705825b45d..f1223375fab 100644 --- a/app/models/course/assessment/question/rubric_based_response.rb +++ b/app/models/course/assessment/question/rubric_based_response.rb @@ -39,6 +39,10 @@ def history_viewable? true end + def csv_downloadable? + true + end + def attempt(submission, last_attempt = nil) answer = Course::Assessment::Answer::RubricBasedResponse.new(submission: submission, question: question) answer.answer_text = last_attempt.answer_text if last_attempt diff --git a/app/views/course/assessment/answer/forum_post_responses/_forum_post_response.json.jbuilder b/app/views/course/assessment/answer/forum_post_responses/_forum_post_response.json.jbuilder index 16471ea37cd..aebe728bee7 100644 --- a/app/views/course/assessment/answer/forum_post_responses/_forum_post_response.json.jbuilder +++ b/app/views/course/assessment/answer/forum_post_responses/_forum_post_response.json.jbuilder @@ -1,4 +1,6 @@ # frozen_string_literal: true +json.questionType answer.question.question_type + json.fields do json.questionId answer.question_id json.id answer.acting_as.id diff --git a/app/views/course/assessment/answer/multiple_responses/_multiple_response.json.jbuilder b/app/views/course/assessment/answer/multiple_responses/_multiple_response.json.jbuilder index d41533fe4a6..08f49a6b75c 100644 --- a/app/views/course/assessment/answer/multiple_responses/_multiple_response.json.jbuilder +++ b/app/views/course/assessment/answer/multiple_responses/_multiple_response.json.jbuilder @@ -1,4 +1,6 @@ # frozen_string_literal: true +json.questionType answer.question.question_type + json.fields do json.questionId answer.question_id json.id answer.acting_as.id diff --git a/app/views/course/assessment/answer/programming/_programming.json.jbuilder b/app/views/course/assessment/answer/programming/_programming.json.jbuilder index 164641fb4a6..be7e88ee0e9 100644 --- a/app/views/course/assessment/answer/programming/_programming.json.jbuilder +++ b/app/views/course/assessment/answer/programming/_programming.json.jbuilder @@ -20,6 +20,8 @@ if is_current_answer && !latest_answer.current_answer? end end +json.questionType answer.question.question_type + json.fields do json.questionId answer.question_id json.id answer.acting_as.id diff --git a/app/views/course/assessment/answer/rubric_based_responses/_rubric_based_response.json.jbuilder b/app/views/course/assessment/answer/rubric_based_responses/_rubric_based_response.json.jbuilder index 71fb9c66af5..9ac501057f8 100644 --- a/app/views/course/assessment/answer/rubric_based_responses/_rubric_based_response.json.jbuilder +++ b/app/views/course/assessment/answer/rubric_based_responses/_rubric_based_response.json.jbuilder @@ -1,4 +1,6 @@ # frozen_string_literal: true +json.questionType answer.question.question_type + json.fields do json.questionId answer.question_id json.id answer.acting_as.id diff --git a/app/views/course/assessment/answer/scribing/_scribing.json.jbuilder b/app/views/course/assessment/answer/scribing/_scribing.json.jbuilder index 553d809c99a..3d6e4b7fad3 100644 --- a/app/views/course/assessment/answer/scribing/_scribing.json.jbuilder +++ b/app/views/course/assessment/answer/scribing/_scribing.json.jbuilder @@ -1,4 +1,6 @@ # frozen_string_literal: true +json.questionType answer.question.question_type + json.scribing_answer do json.image_url answer.question.actable.attachment_reference.generate_public_url json.user_id current_user.id diff --git a/app/views/course/assessment/answer/text_responses/_text_response.json.jbuilder b/app/views/course/assessment/answer/text_responses/_text_response.json.jbuilder index 9fe02416a8e..8bef8cd9244 100644 --- a/app/views/course/assessment/answer/text_responses/_text_response.json.jbuilder +++ b/app/views/course/assessment/answer/text_responses/_text_response.json.jbuilder @@ -1,4 +1,6 @@ # frozen_string_literal: true +json.questionType answer.question.question_type + json.fields do json.questionId answer.question_id json.id answer.acting_as.id diff --git a/app/views/course/assessment/answer/voice_responses/_voice_response.json.jbuilder b/app/views/course/assessment/answer/voice_responses/_voice_response.json.jbuilder index 119d6dc3929..26d45de9dc3 100644 --- a/app/views/course/assessment/answer/voice_responses/_voice_response.json.jbuilder +++ b/app/views/course/assessment/answer/voice_responses/_voice_response.json.jbuilder @@ -1,4 +1,6 @@ # frozen_string_literal: true +json.questionType answer.question.question_type + json.fields do json.questionId answer.question_id json.id answer.acting_as.id diff --git a/client/app/bundles/course/assessment/pages/AssessmentStatistics/StudentGradesPerQuestionTable.tsx b/client/app/bundles/course/assessment/pages/AssessmentStatistics/StudentGradesPerQuestionTable.tsx index 06ceed35173..c1d5456e87e 100644 --- a/client/app/bundles/course/assessment/pages/AssessmentStatistics/StudentGradesPerQuestionTable.tsx +++ b/client/app/bundles/course/assessment/pages/AssessmentStatistics/StudentGradesPerQuestionTable.tsx @@ -43,7 +43,6 @@ const StudentGradesPerQuestionTable: FC = (props) => { const { t } = useTranslation(); const { courseId, assessmentId } = useParams(); const { includePhantom } = props; - const statistics = useAppSelector(getAssessmentStatistics); const [openAnswer, setOpenAnswer] = useState(false); const [answerDisplayInfo, setAnswerDisplayInfo] = useState({ diff --git a/client/app/bundles/course/assessment/submission/components/AnswerDetails/AnswerDetails.tsx b/client/app/bundles/course/assessment/submission/components/AnswerDetails/AnswerDetails.tsx index f9d9210c18a..30c44ae2e8b 100644 --- a/client/app/bundles/course/assessment/submission/components/AnswerDetails/AnswerDetails.tsx +++ b/client/app/bundles/course/assessment/submission/components/AnswerDetails/AnswerDetails.tsx @@ -15,6 +15,7 @@ import ForumPostResponseDetails from './ForumPostResponseDetails'; import MultipleChoiceDetails from './MultipleChoiceDetails'; import MultipleResponseDetails from './MultipleResponseDetails'; import ProgrammingAnswerDetails from './ProgrammingAnswerDetails'; +import RubricBasedResponseDetails from './RubricBasedResponseDetails'; import TextResponseDetails from './TextResponseDetails'; const translations = defineMessages({ @@ -58,10 +59,10 @@ export const AnswerDetailsMapper = { Programming: (props: AnswerDetailsProps<'Programming'>): JSX.Element => ( ), - // TODO: define component for Voice Response, Scribing, Rubric Based Response RubricBasedResponse: ( - _props: AnswerDetailsProps<'RubricBasedResponse'>, - ): JSX.Element => , + props: AnswerDetailsProps<'RubricBasedResponse'>, + ): JSX.Element => , + // TODO: define component for Voice Response, Scribing VoiceResponse: (_props: AnswerDetailsProps<'VoiceResponse'>): JSX.Element => ( ), diff --git a/client/app/bundles/course/assessment/submission/components/AnswerDetails/RubricBasedResponseDetails.tsx b/client/app/bundles/course/assessment/submission/components/AnswerDetails/RubricBasedResponseDetails.tsx new file mode 100644 index 00000000000..e2cc18dfa39 --- /dev/null +++ b/client/app/bundles/course/assessment/submission/components/AnswerDetails/RubricBasedResponseDetails.tsx @@ -0,0 +1,27 @@ +import { Typography } from '@mui/material'; +import { QuestionType } from 'types/course/assessment/question'; + +import RubricPanel from '../../containers/RubricPanel'; +import { AnswerDetailsProps } from '../../types'; + +const RubricBasedResponseDetails = ( + props: AnswerDetailsProps, +): JSX.Element => { + const { question, answer } = props; + return ( + <> + + {}} // Placeholder function since RubricPanel is not editable here + /> + + ); +}; + +export default RubricBasedResponseDetails; diff --git a/client/app/bundles/course/assessment/submission/containers/QuestionGrade.tsx b/client/app/bundles/course/assessment/submission/containers/QuestionGrade.tsx index ba5d1c2031b..a93a53683ab 100644 --- a/client/app/bundles/course/assessment/submission/containers/QuestionGrade.tsx +++ b/client/app/bundles/course/assessment/submission/containers/QuestionGrade.tsx @@ -1,7 +1,10 @@ import { FC, useState } from 'react'; import { Chip, Paper, TextField, Tooltip, Typography } from '@mui/material'; import { QuestionType } from 'types/course/assessment/question'; -import { SubmissionQuestionBaseData } from 'types/course/assessment/submission/question/types'; +import { + SubmissionQuestionBaseData, + SubmissionQuestionData, +} from 'types/course/assessment/submission/question/types'; import { FIELD_LONG_DEBOUNCE_DELAY_MS } from 'lib/constants/sharedConstants'; import { getSubmissionId } from 'lib/helpers/url-helpers'; @@ -13,6 +16,7 @@ import { saveGrade, updateGrade } from '../actions/answers'; import { workflowStates } from '../constants'; import { computeExp } from '../reducers/grading'; import { QuestionGradeData } from '../reducers/grading/types'; +import { getRubricCategoryGradesForAnswerId } from '../selectors/answers'; import { getAssessment } from '../selectors/assessments'; import { getBasePoints, @@ -23,6 +27,7 @@ import { import { getQuestions } from '../selectors/questions'; import { getSubmission } from '../selectors/submissions'; import translations from '../translations'; +import { AnswerDetailsMap } from '../types'; import RubricPanel from './RubricPanel'; @@ -55,7 +60,6 @@ const QuestionGrade: FC = (props) => { const submission = useAppSelector(getSubmission); const questions = useAppSelector(getQuestions); const questionWithGrades = useAppSelector(getQuestionWithGrades); - const submissionId = getSubmissionId(); const { submittedAt, bonusEndAt, graderView, bonusPoints, workflowState } = @@ -69,21 +73,24 @@ const QuestionGrade: FC = (props) => { const basePoints = useAppSelector(getBasePoints); const expMultiplier = useAppSelector(getExpMultiplier); const maximumGrade = useAppSelector(getMaximumGrade); + const answerCategoryGradesFromStore = useAppSelector((state) => + grading ? getRubricCategoryGradesForAnswerId(state, grading.id) : [], + ); const attempting = workflowState === workflowStates.Attempting; const published = workflowState === workflowStates.Published; - const isRubricBasedResponse = - question.type === QuestionType.RubricBasedResponse; - const editable = !attempting && graderView; const isNotGradedAndNotPublished = workflowState !== workflowStates.Graded && workflowState !== workflowStates.Published; + const isRubricBasedResponse = + question.type === QuestionType.RubricBasedResponse; const isRubricVisible = - !submission.isStudent || assessment.showRubricToStudents; + isRubricBasedResponse && + (!submission.isStudent || assessment.showRubricToStudents); const handleSaveGrade = ( newGrade: string | number | null, @@ -278,12 +285,18 @@ const QuestionGrade: FC = (props) => { ); + const answerCategoryGrades = ( + isRubricVisible && grading ? answerCategoryGradesFromStore : undefined + ) as AnswerDetailsMap['RubricBasedResponse']['categoryGrades'] | undefined; + return ( (editable || published) && ( <> - {isRubricBasedResponse && isRubricVisible && ( + {isRubricVisible && answerCategoryGrades && ( } setIsFirstRendering={setIsFirstRendering} /> )} diff --git a/client/app/bundles/course/assessment/submission/containers/RubricExplanation.tsx b/client/app/bundles/course/assessment/submission/containers/RubricExplanation.tsx index ce774831172..9cdac3ace2a 100644 --- a/client/app/bundles/course/assessment/submission/containers/RubricExplanation.tsx +++ b/client/app/bundles/course/assessment/submission/containers/RubricExplanation.tsx @@ -1,5 +1,5 @@ -import { FC } from 'react'; -import { MenuItem, Select, Typography } from '@mui/material'; +import { FC, useState } from 'react'; +import { Chip, MenuItem, Select, Typography } from '@mui/material'; import { AnswerRubricGradeData } from 'types/course/assessment/question/rubric-based-responses'; import { RubricBasedResponseCategoryQuestionData, @@ -8,6 +8,7 @@ import { import TextField from 'lib/components/core/fields/TextField'; import { useAppDispatch, useAppSelector } from 'lib/hooks/store'; +import useTranslation from 'lib/hooks/useTranslation'; import { updateGrade as updateGradeState, @@ -19,6 +20,7 @@ import { getQuestionFlags } from '../selectors/questionFlags'; import { getQuestions } from '../selectors/questions'; import { getSubmissionFlags } from '../selectors/submissionFlags'; import { getSubmission } from '../selectors/submissions'; +import translations from '../translations'; import { GradeWithPrefilledStatus } from '../types'; import { transformRubric } from '../utils/rubrics'; @@ -45,6 +47,8 @@ const RubricExplanation: FC = (props) => { updateGrade, } = props; + const { t } = useTranslation(); + const questionWithGrades = useAppSelector(getQuestionWithGrades); const submission = useAppSelector(getSubmission); @@ -129,18 +133,36 @@ const RubricExplanation: FC = (props) => { disabled={isAutograding} id={`category-${category.id}`} onChange={handleOnChange} + renderValue={(selectedId) => { + // Display the selected grade explanation only, excluding the grade chip + const selected = category.grades.find((g) => g.id === selectedId); + return ( + + ); + }} value={categoryGrades[category.id].gradeId} variant="outlined" > {category.grades.map((grade) => ( - +
+ + +
))} diff --git a/client/app/bundles/course/assessment/submission/containers/RubricGrade.tsx b/client/app/bundles/course/assessment/submission/containers/RubricGrade.tsx index 50d8d851d33..f51914fcc17 100644 --- a/client/app/bundles/course/assessment/submission/containers/RubricGrade.tsx +++ b/client/app/bundles/course/assessment/submission/containers/RubricGrade.tsx @@ -113,6 +113,11 @@ const RubricGrade: FC = (props) => { className="w-full h-20 max-w-3xl" disabled={isAutograding} id={`category-${category.id}`} + InputProps={{ + classes: { + input: 'text-center', + }, + }} onChange={(event) => handleOnChange(event, category.isBonusCategory)} value={categoryGrades[category.id].grade} variant="outlined" @@ -131,7 +136,7 @@ const RubricGrade: FC = (props) => { > {category.grades.map((catGrade) => ( - {catGrade.grade} + {catGrade.grade} / {category.maximumGrade} ))} diff --git a/client/app/bundles/course/assessment/submission/containers/RubricPanel.tsx b/client/app/bundles/course/assessment/submission/containers/RubricPanel.tsx index 114ff9d5e69..4432c2ca0db 100644 --- a/client/app/bundles/course/assessment/submission/containers/RubricPanel.tsx +++ b/client/app/bundles/course/assessment/submission/containers/RubricPanel.tsx @@ -9,40 +9,27 @@ import { } from '@mui/material'; import { SubmissionQuestionData } from 'types/course/assessment/submission/question/types'; -import { useAppSelector } from 'lib/hooks/store'; import useTranslation from 'lib/hooks/useTranslation'; -import { getRubricCategoryGradesForAnswerId } from '../selectors/answers'; -import { getQuestionWithGrades } from '../selectors/grading'; -import { getQuestions } from '../selectors/questions'; import translations from '../translations'; +import { AnswerDetailsMap } from '../types'; import RubricPanelRow from './RubricPanelRow'; interface RubricPanelProps { + answerId: number; + answerCategoryGrades: AnswerDetailsMap['RubricBasedResponse']['categoryGrades']; + question: SubmissionQuestionData<'RubricBasedResponse'>; setIsFirstRendering: (isFirstRendering: boolean) => void; - questionId: number; } const RubricPanel: FC = (props) => { - const { setIsFirstRendering, questionId } = props; - const { t } = useTranslation(); - const questions = useAppSelector(getQuestions); - - const question = questions[ - questionId - ] as SubmissionQuestionData<'RubricBasedResponse'>; - const questionWithGrades = useAppSelector(getQuestionWithGrades); - - const answerId = questionWithGrades[questionId].id; - - const categoryGrade = useAppSelector((state) => - getRubricCategoryGradesForAnswerId(state, answerId), - ); + const { answerId, answerCategoryGrades, question, setIsFirstRendering } = + props; const categoryGrades = useMemo(() => { - const categoryGradeHash = categoryGrade.reduce( + const categoryGradeHash = answerCategoryGrades.reduce( (obj, category) => ({ ...obj, [category.categoryId]: { @@ -68,7 +55,7 @@ const RubricPanel: FC = (props) => { }), {}, ); - }, [categoryGrade, question.categories]); + }, [answerCategoryGrades, question.categories]); return (
@@ -84,20 +71,24 @@ const RubricPanel: FC = (props) => { {t(translations.explanation)} - + {t(translations.grade)} + / + + {t(translations.max)} + - {question.categories.map((category) => ( + {question?.categories.map((category) => ( ))} diff --git a/client/app/bundles/course/assessment/submission/containers/RubricPanelRow.tsx b/client/app/bundles/course/assessment/submission/containers/RubricPanelRow.tsx index d1a5e465c9e..b6d91d25172 100644 --- a/client/app/bundles/course/assessment/submission/containers/RubricPanelRow.tsx +++ b/client/app/bundles/course/assessment/submission/containers/RubricPanelRow.tsx @@ -1,4 +1,4 @@ -import { FC } from 'react'; +import { FC, useMemo } from 'react'; import { TableCell, TableRow, Typography } from '@mui/material'; import { AnswerRubricGradeData } from 'types/course/assessment/question/rubric-based-responses'; import { @@ -19,7 +19,6 @@ import { getExpMultiplier, getMaximumGrade, } from '../selectors/grading'; -import { getQuestions } from '../selectors/questions'; import { getSubmission } from '../selectors/submissions'; import { GradeWithPrefilledStatus } from '../types'; @@ -28,25 +27,129 @@ import RubricGrade from './RubricGrade'; interface RubricPanelRowProps { answerId: number; - questionId: number; + question: SubmissionQuestionData<'RubricBasedResponse'>; category: RubricBasedResponseCategoryQuestionData; categoryGrades: Record; setIsFirstRendering: (isFirstRendering: boolean) => void; } -const RubricPanelRow: FC = (props) => { - const { answerId, questionId, category, categoryGrades } = props; +function buildCategoryGradeExplanationMap( + categories: RubricBasedResponseCategoryQuestionData[], +): Record> { + return categories.reduce( + (acc, cat) => ({ + ...acc, + [cat.id]: cat.grades.reduce( + (explanationAcc, catGrade) => ({ + ...explanationAcc, + [catGrade.grade]: catGrade.explanation, + }), + {}, + ), + }), + {}, + ); +} - const dispatch = useAppDispatch(); +const ExplanationCell: FC<{ + editable: boolean; + category: RubricBasedResponseCategoryQuestionData; + categoryGrades: Record; + explanationMap: Record>; + updateGrade: ( + catGrades: Record, + qId: number, + oldQuestions: Record, + ) => void; + questionId: number; + props: RubricPanelRowProps; +}> = ({ + editable, + category, + categoryGrades, + explanationMap, + updateGrade, + questionId, + props, +}) => { + const explanation = + explanationMap[category.id]?.[categoryGrades[category.id].grade] ?? + categoryGrades[category.id].explanation; - const submission = useAppSelector(getSubmission); - const questions = useAppSelector(getQuestions); + return ( + + {editable ? ( + + ) : ( + + )} + + ); +}; + +const GradeCell: FC<{ + editable: boolean; + category: RubricBasedResponseCategoryQuestionData; + categoryGrades: Record; + updateGrade: ( + catGrades: Record, + qId: number, + oldQuestions: Record, + ) => void; + questionId: number; + props: RubricPanelRowProps; +}> = ({ + editable, + category, + categoryGrades, + updateGrade, + questionId, + props, +}) => { + const grade = categoryGrades[category.id].grade; + return ( + + {category.isBonusCategory && editable ? ( + + ) : ( + {grade} + )} + + ); +}; - const { graderView, workflowState } = submission; - const question = questions[ - questionId - ] as SubmissionQuestionData<'RubricBasedResponse'>; +const GradeSlashCell: FC<{ maxGrade?: number }> = ({ maxGrade }) => ( + + {maxGrade ? '/' : ''} + +); +const MaxGradeCell: FC<{ maxGrade?: number }> = ({ maxGrade }) => ( + + {maxGrade ?? ''} + +); + +const RubricPanelRow: FC = (props) => { + const { answerId, question, category, categoryGrades } = props; + + const dispatch = useAppDispatch(); + const submission = useAppSelector(getSubmission); + const { graderView, workflowState, submittedAt, bonusEndAt, bonusPoints } = + submission; const submissionId = getSubmissionId(); const maximumGrade = useAppSelector(getMaximumGrade); @@ -57,26 +160,18 @@ const RubricPanelRow: FC = (props) => { const published = workflowState === workflowStates.Published; const editable = !attempting && graderView; - const { submittedAt, bonusEndAt, bonusPoints } = submission; - const bonusAwarded = new Date(submittedAt) < new Date(bonusEndAt) ? bonusPoints : 0; - const categoryGradeExplanationMap = question.categories.reduce( - (acc, cat) => ({ - ...acc, - [cat.id]: cat.grades.reduce( - (explanationAcc, catGrade) => ({ - ...explanationAcc, - [catGrade.grade]: catGrade.explanation, - }), - {}, - ), - }), - {}, + const categoryIds = useMemo( + () => question.categories.map((cat) => cat.id), + [question.categories], ); - const categoryIds = question.categories.map((cat) => cat.id); + const categoryGradeExplanationMap = useMemo( + () => buildCategoryGradeExplanationMap(question.categories), + [question.categories], + ); const handleSaveRubricAndGrade = ( catGrades: Record, @@ -108,12 +203,12 @@ const RubricPanelRow: FC = (props) => { saveRubricAndGrade( submissionId, answerId, - questionId, + question.id, categoryIds, newExpPoints, published, catGrades, - question.maximumGrade, + question?.maximumGrade, ), ); }; @@ -127,38 +222,25 @@ const RubricPanelRow: FC = (props) => { return ( {category.name} - - {editable ? ( - - ) : ( - - )} - - - {editable ? ( - - ) : ( - - {categoryGrades[category.id].grade} - - )} - + + + + ); }; diff --git a/client/app/bundles/course/assessment/submission/translations.ts b/client/app/bundles/course/assessment/submission/translations.ts index 0a8a3b07282..e752fe3ac05 100644 --- a/client/app/bundles/course/assessment/submission/translations.ts +++ b/client/app/bundles/course/assessment/submission/translations.ts @@ -611,6 +611,14 @@ const translations = defineMessages({ id: 'course.assessment.submission.grade', defaultMessage: 'Grade', }, + gradeDisplay: { + id: 'course.assessment.submission.gradeDisplay', + defaultMessage: 'Grade: {grade}', + }, + max: { + id: 'course.assessment.submission.max', + defaultMessage: 'Max', + }, group: { id: 'course.assessment.submission.group', defaultMessage: 'Group', diff --git a/client/app/types/course/assessment/submission/answer/rubricBasedResponse.ts b/client/app/types/course/assessment/submission/answer/rubricBasedResponse.ts index 7a49a1c6661..d6fef78b27c 100644 --- a/client/app/types/course/assessment/submission/answer/rubricBasedResponse.ts +++ b/client/app/types/course/assessment/submission/answer/rubricBasedResponse.ts @@ -25,11 +25,12 @@ export interface RubricBasedResponseAnswerData extends AnswerBaseData { path?: string; }; latestAnswer?: RubricBasedResponseAnswerData; - categoryScores: { - canReadRubric: boolean; + categoryGrades: { id: number | null | undefined; categoryId: number; - score: number; + grade: number; + gradeId: number; + explanation: string | null; }[]; } diff --git a/client/app/types/course/assessment/submission/question/types.ts b/client/app/types/course/assessment/submission/question/types.ts index 4adddf6334c..dd2efcd7eb9 100644 --- a/client/app/types/course/assessment/submission/question/types.ts +++ b/client/app/types/course/assessment/submission/question/types.ts @@ -99,6 +99,7 @@ export interface SubmissionQuestionBaseData extends QuestionData { questionTitle: string; submissionQuestionId: number; topicId: number; + type: QuestionType; answerId?: number; isCodaveri?: boolean; // Derived within redux reducer