Skip to content

feat(RubricBasedResponse): add RubricBasedResponse to submission CSV and statistics table #7951

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 2 commits into from
Jun 6, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,10 @@
true
end

def csv_downloadable?
true

Check warning on line 43 in app/models/course/assessment/question/rubric_based_response.rb

View check run for this annotation

Codecov / codecov/patch

app/models/course/assessment/question/rubric_based_response.rb#L43

Added line #L43 was not covered by tests
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
Expand Down
Original file line number Diff line number Diff line change
@@ -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
Expand Down
Original file line number Diff line number Diff line change
@@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
@@ -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
Expand Down
Original file line number Diff line number Diff line change
@@ -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
Expand Down
Original file line number Diff line number Diff line change
@@ -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
Expand Down
Original file line number Diff line number Diff line change
@@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,6 @@ const StudentGradesPerQuestionTable: FC<Props> = (props) => {
const { t } = useTranslation();
const { courseId, assessmentId } = useParams();
const { includePhantom } = props;

const statistics = useAppSelector(getAssessmentStatistics);
const [openAnswer, setOpenAnswer] = useState(false);
const [answerDisplayInfo, setAnswerDisplayInfo] = useState<AnswerInfoState>({
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand Down Expand Up @@ -58,10 +59,10 @@ export const AnswerDetailsMapper = {
Programming: (props: AnswerDetailsProps<'Programming'>): JSX.Element => (
<ProgrammingAnswerDetails {...props} />
),
// TODO: define component for Voice Response, Scribing, Rubric Based Response
RubricBasedResponse: (
_props: AnswerDetailsProps<'RubricBasedResponse'>,
): JSX.Element => <AnswerNotImplemented />,
props: AnswerDetailsProps<'RubricBasedResponse'>,
): JSX.Element => <RubricBasedResponseDetails {...props} />,
// TODO: define component for Voice Response, Scribing
VoiceResponse: (_props: AnswerDetailsProps<'VoiceResponse'>): JSX.Element => (
<AnswerNotImplemented />
),
Expand Down
Original file line number Diff line number Diff line change
@@ -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<QuestionType.RubricBasedResponse>,
): JSX.Element => {
const { question, answer } = props;
return (
<>
<Typography
dangerouslySetInnerHTML={{ __html: answer.fields.answer_text }}
variant="body2"
/>
<RubricPanel
answerCategoryGrades={answer.categoryGrades}
answerId={answer.id}
question={question}
setIsFirstRendering={() => {}} // Placeholder function since RubricPanel is not editable here
/>
</>
);
};

export default RubricBasedResponseDetails;
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -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,
Expand All @@ -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';

Expand Down Expand Up @@ -55,7 +60,6 @@ const QuestionGrade: FC<QuestionGradeProps> = (props) => {
const submission = useAppSelector(getSubmission);
const questions = useAppSelector(getQuestions);
const questionWithGrades = useAppSelector(getQuestionWithGrades);

const submissionId = getSubmissionId();

const { submittedAt, bonusEndAt, graderView, bonusPoints, workflowState } =
Expand All @@ -69,21 +73,24 @@ const QuestionGrade: FC<QuestionGradeProps> = (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,
Expand Down Expand Up @@ -278,12 +285,18 @@ const QuestionGrade: FC<QuestionGradeProps> = (props) => {
</Typography>
);

const answerCategoryGrades = (
isRubricVisible && grading ? answerCategoryGradesFromStore : undefined
) as AnswerDetailsMap['RubricBasedResponse']['categoryGrades'] | undefined;

return (
(editable || published) && (
<>
{isRubricBasedResponse && isRubricVisible && (
{isRubricVisible && answerCategoryGrades && (
<RubricPanel
questionId={questionId}
answerCategoryGrades={answerCategoryGrades!}
answerId={grading.id}
question={question as SubmissionQuestionData<'RubricBasedResponse'>}
setIsFirstRendering={setIsFirstRendering}
/>
)}
Expand Down
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -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,
Expand All @@ -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';

Expand All @@ -45,6 +47,8 @@ const RubricExplanation: FC<RubricExplanationProps> = (props) => {
updateGrade,
} = props;

const { t } = useTranslation();

const questionWithGrades = useAppSelector(getQuestionWithGrades);
const submission = useAppSelector(getSubmission);

Expand Down Expand Up @@ -129,18 +133,36 @@ const RubricExplanation: FC<RubricExplanationProps> = (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 (
<Typography
className="text-wrap"
dangerouslySetInnerHTML={{ __html: selected?.explanation ?? '' }}
variant="body2"
/>
);
}}
value={categoryGrades[category.id].gradeId}
variant="outlined"
>
{category.grades.map((grade) => (
<MenuItem key={grade.id} value={grade.id}>
<Typography
className="w-full text-wrap"
dangerouslySetInnerHTML={{
__html: grade.explanation,
}}
variant="body2"
/>
<div className="flex items-center justify-between w-full">
<Typography
className="text-wrap"
dangerouslySetInnerHTML={{ __html: grade.explanation }}
variant="body2"
/>
<Chip
label={t(translations.gradeDisplay, {
grade: grade?.grade ?? '--',
})}
size="small"
variant="filled"
/>
</div>
</MenuItem>
))}
</Select>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -113,6 +113,11 @@ const RubricGrade: FC<RubricGradeProps> = (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"
Expand All @@ -131,7 +136,7 @@ const RubricGrade: FC<RubricGradeProps> = (props) => {
>
{category.grades.map((catGrade) => (
<MenuItem key={catGrade.id} value={catGrade.id}>
{catGrade.grade}
{catGrade.grade} / {category.maximumGrade}
</MenuItem>
))}
</Select>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<RubricPanelProps> = (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]: {
Expand All @@ -68,7 +55,7 @@ const RubricPanel: FC<RubricPanelProps> = (props) => {
}),
{},
);
}, [categoryGrade, question.categories]);
}, [answerCategoryGrades, question.categories]);

return (
<div className="w-full p-2">
Expand All @@ -84,20 +71,24 @@ const RubricPanel: FC<RubricPanelProps> = (props) => {
<TableCell className="w-[80%] text-wrap">
{t(translations.explanation)}
</TableCell>
<TableCell className="w-[10%] text-wrap">
<TableCell className="w-[5%] text-wrap px-0 text-center">
{t(translations.grade)}
</TableCell>
<TableCell className="px-0 text-center">/</TableCell>
<TableCell className="w-[5%] text-wrap px-0 text-center">
{t(translations.max)}
</TableCell>
</TableRow>
</TableHead>

<TableBody>
{question.categories.map((category) => (
{question?.categories.map((category) => (
<RubricPanelRow
key={category.id}
answerId={answerId}
category={category}
categoryGrades={categoryGrades}
questionId={questionId}
question={question}
setIsFirstRendering={setIsFirstRendering}
/>
))}
Expand Down
Loading