diff --git a/app/controllers/concerns/course/statistics/submissions_concern.rb b/app/controllers/concerns/course/statistics/submissions_concern.rb index 5ad071ba997..f95aee33899 100644 --- a/app/controllers/concerns/course/statistics/submissions_concern.rb +++ b/app/controllers/concerns/course/statistics/submissions_concern.rb @@ -25,17 +25,6 @@ def fetch_hash_for_ancestor_assessment(submissions, students) def answer_statistics_hash submission_answer_statistics = Course::Assessment::Answer.find_by_sql(<<-SQL.squish WITH - attempt_count AS ( - SELECT - caa.question_id, - caa.submission_id, - COUNT(*) AS attempt_count - FROM course_assessment_answers caa - JOIN course_assessment_submissions cas ON caa.submission_id = cas.id - WHERE cas.assessment_id = #{assessment_params[:id]} - GROUP BY caa.question_id, caa.submission_id - ), - attempt_info AS ( SELECT caa_ranked.question_id, @@ -59,14 +48,25 @@ def answer_statistics_hash ) AS caa_ranked WHERE caa_ranked.row_num <= 2 GROUP BY caa_ranked.question_id, caa_ranked.submission_id + ), + + attempt_count AS ( + SELECT + caa.question_id, + caa.submission_id, + COUNT(*) AS attempt_count + FROM course_assessment_answers caa + JOIN course_assessment_submissions cas ON caa.submission_id = cas.id + WHERE cas.assessment_id = #{assessment_params[:id]} AND caa.workflow_state != 'attempting' + GROUP BY caa.question_id, caa.submission_id ) SELECT CASE WHEN jsonb_array_length(attempt_info.submission_info) = 1 OR attempt_info.submission_info->0->>3 != 'attempting' THEN attempt_info.submission_info->0->>0 ELSE attempt_info.submission_info->1->>0 END AS last_attempt_answer_id, - attempt_count.question_id, - attempt_count.submission_id, + attempt_info.question_id, + attempt_info.submission_id, attempt_count.attempt_count, CASE WHEN jsonb_array_length(attempt_info.submission_info) = 1 OR attempt_info.submission_info->0->>3 != 'attempting' THEN attempt_info.submission_info->0->>1 ELSE attempt_info.submission_info->1->>1 @@ -74,8 +74,8 @@ def answer_statistics_hash CASE WHEN jsonb_array_length(attempt_info.submission_info) = 1 OR attempt_info.submission_info->0->>3 != 'attempting' THEN attempt_info.submission_info->0->>2 ELSE attempt_info.submission_info->1->>2 END AS correct - FROM attempt_count - JOIN attempt_info + FROM attempt_info + LEFT JOIN attempt_count ON attempt_count.question_id = attempt_info.question_id AND attempt_count.submission_id = attempt_info.submission_id SQL ) diff --git a/app/controllers/course/statistics/answers_controller.rb b/app/controllers/course/statistics/answers_controller.rb index 905218e2435..71a39a4bbd1 100644 --- a/app/controllers/course/statistics/answers_controller.rb +++ b/app/controllers/course/statistics/answers_controller.rb @@ -2,10 +2,37 @@ class Course::Statistics::AnswersController < Course::Statistics::Controller helper Course::Assessment::Submission::SubmissionsHelper.name.sub(/Helper$/, '') + MAX_ANSWERS_COUNT = 10 + def question_answer_details @answer = Course::Assessment::Answer.find(answer_params[:id]) @submission = @answer.submission @assessment = @submission.assessment + + @submission_question = Course::Assessment::SubmissionQuestion. + where(submission_id: @answer.submission_id, question_id: @answer.question_id). + includes({ discussion_topic: :posts }).first + + @all_answers = fetch_all_answers(@answer.submission_id, @answer.question_id) + end + + def all_answers + @submission_question = Course::Assessment::SubmissionQuestion.find(submission_question_params[:id]) + question_id = @submission_question.question_id + submission_id = @submission_question.submission_id + + @question = Course::Assessment::Question.find(question_id) + @submission = Course::Assessment::Submission.find(submission_id) + @assessment = @submission.assessment + + @submission_question = Course::Assessment::SubmissionQuestion. + where(submission_id: submission_id, question_id: question_id). + includes({ discussion_topic: :posts }).first + @question_index = question_index(question_id) + @all_answers = Course::Assessment::Answer. + unscope(:order). + order(:created_at). + where(submission_id: submission_id, question_id: question_id) end private @@ -13,4 +40,30 @@ def question_answer_details def answer_params params.permit(:id) end + + def submission_question_params + params.permit(:id) + end + + def question_index(question_id) + question_ids = Course::QuestionAssessment. + where(assessment_id: @assessment.id). + order(:weight). + pluck(:question_id) + + question_ids.index(question_id) + end + + def fetch_all_answers(submission_id, question_id) + answers = Course::Assessment::Answer. + unscope(:order). + order(created_at: :desc). + where(submission_id: submission_id, question_id: question_id) + + current_answer = answers.find(&:current_answer?) + past_answers = answers.where(current_answer: false).limit(MAX_ANSWERS_COUNT - 1).to_a + past_answers.unshift(current_answer) + + past_answers + end end diff --git a/app/views/course/statistics/answers/_answer.json.jbuilder b/app/views/course/statistics/answers/_answer.json.jbuilder new file mode 100644 index 00000000000..b7f7679a3ce --- /dev/null +++ b/app/views/course/statistics/answers/_answer.json.jbuilder @@ -0,0 +1,7 @@ +# frozen_string_literal: true +specific_answer = answer.specific + +json.id answer.id +json.grade answer.grade +json.questionType question.question_type +json.partial! specific_answer, answer: specific_answer, can_grade: false diff --git a/app/views/course/statistics/answers/all_answers.json.jbuilder b/app/views/course/statistics/answers/all_answers.json.jbuilder new file mode 100644 index 00000000000..e59345f13c8 --- /dev/null +++ b/app/views/course/statistics/answers/all_answers.json.jbuilder @@ -0,0 +1,26 @@ +# frozen_string_literal: true +json.question do + json.id @question.id + json.title @question.title + json.maximumGrade @question.maximum_grade + json.description format_ckeditor_rich_text(@question.description) + json.type @question.question_type + json.questionNumber @question_index + 1 + + json.partial! @question, question: @question.specific, can_grade: false, answer: @all_answers.first +end + +json.allAnswers @all_answers do |answer| + json.partial! 'answer', answer: answer, question: @question + json.createdAt answer.created_at&.iso8601 + json.currentAnswer answer.current_answer + json.workflowState answer.workflow_state +end + +json.submissionId @submission.id + +posts = @submission_question.discussion_topic.posts + +json.comments posts do |post| + json.partial! post, post: post if post.published? +end diff --git a/app/views/course/statistics/answers/question_answer_details.json.jbuilder b/app/views/course/statistics/answers/question_answer_details.json.jbuilder index 935573e71c2..4b19f721d01 100644 --- a/app/views/course/statistics/answers/question_answer_details.json.jbuilder +++ b/app/views/course/statistics/answers/question_answer_details.json.jbuilder @@ -11,10 +11,22 @@ json.question do json.partial! question, question: question.specific, can_grade: false, answer: @answer end -specific_answer = @answer.specific json.answer do - json.id @answer.id - json.grade @answer.grade - json.questionType question.question_type - json.partial! specific_answer, answer: specific_answer, can_grade: false + json.partial! 'answer', answer: @answer, question: question end + +json.allAnswers @all_answers do |answer| + json.partial! 'answer', answer: answer, question: question + json.createdAt answer.created_at&.iso8601 + json.currentAnswer answer.current_answer + json.workflowState answer.workflow_state +end + +posts = @submission_question.discussion_topic.posts + +json.comments posts do |post| + json.partial! post, post: post if post.published? +end + +json.submissionId @submission.id +json.submissionQuestionId @submission_question.id diff --git a/app/views/course/statistics/assessments/main_statistics.json.jbuilder b/app/views/course/statistics/assessments/main_statistics.json.jbuilder index 1b01a0d7140..3026c28ecbf 100644 --- a/app/views/course/statistics/assessments/main_statistics.json.jbuilder +++ b/app/views/course/statistics/assessments/main_statistics.json.jbuilder @@ -22,6 +22,7 @@ json.submissions @student_submissions_hash.each do |course_user, (submission, an json.submissionExists !submission.nil? unless submission.nil? + json.id submission.id json.workflowState submission.workflow_state json.submittedAt submission.submitted_at&.iso8601 json.endAt end_at&.iso8601 @@ -32,7 +33,7 @@ json.submissions @student_submissions_hash.each do |course_user, (submission, an json.lastAttemptAnswerId answer.last_attempt_answer_id json.isAutograded auto_gradable - json.attemptCount answer.attempt_count + json.attemptCount answer.attempt_count || 0 json.correct answer.correct end diff --git a/client/app/api/course/Statistics/AllAnswerStatistics.ts b/client/app/api/course/Statistics/AllAnswerStatistics.ts new file mode 100644 index 00000000000..44ceb2c1550 --- /dev/null +++ b/client/app/api/course/Statistics/AllAnswerStatistics.ts @@ -0,0 +1,18 @@ +import { QuestionType } from 'types/course/assessment/question'; +import { QuestionAllAnswerDisplayDetails } from 'types/course/statistics/assessmentStatistics'; + +import { APIResponse } from 'api/types'; + +import BaseCourseAPI from '../Base'; + +export default class AllAnswerStatisticsAPI extends BaseCourseAPI { + get #urlPrefix(): string { + return `/courses/${this.courseId}/statistics/submission_question`; + } + + fetchAllAnswers( + submissionQuestionId: number, + ): APIResponse> { + return this.client.get(`${this.#urlPrefix}/${submissionQuestionId}`); + } +} diff --git a/client/app/api/course/Statistics/index.ts b/client/app/api/course/Statistics/index.ts index 9bd3484fd67..8e0748e20e5 100644 --- a/client/app/api/course/Statistics/index.ts +++ b/client/app/api/course/Statistics/index.ts @@ -1,3 +1,4 @@ +import AllAnswerStatisticsAPI from './AllAnswerStatistics'; import AnswerStatisticsAPI from './AnswerStatistics'; import AssessmentStatisticsAPI from './AssessmentStatistics'; import CourseStatisticsAPI from './CourseStatistics'; @@ -6,6 +7,7 @@ import UserStatisticsAPI from './UserStatistics'; const StatisticsAPI = { assessment: new AssessmentStatisticsAPI(), answer: new AnswerStatisticsAPI(), + allAnswer: new AllAnswerStatisticsAPI(), course: new CourseStatisticsAPI(), user: new UserStatisticsAPI(), }; diff --git a/client/app/bundles/course/assessment/operations/statistics.ts b/client/app/bundles/course/assessment/operations/statistics.ts index 33878eafbae..52cd603ea71 100644 --- a/client/app/bundles/course/assessment/operations/statistics.ts +++ b/client/app/bundles/course/assessment/operations/statistics.ts @@ -2,6 +2,7 @@ import { Operation } from 'store'; import { QuestionType } from 'types/course/assessment/question'; import { AncestorAssessmentStats, + QuestionAllAnswerDisplayDetails, QuestionAnswerDetails, } from 'types/course/statistics/assessmentStatistics'; @@ -45,3 +46,12 @@ export const fetchQuestionAnswerDetails = async ( return response.data; }; + +export const fetchAllAnswers = async ( + submissionQuestionId: number, +): Promise> => { + const response = + await CourseAPI.statistics.allAnswer.fetchAllAnswers(submissionQuestionId); + + return response.data; +}; diff --git a/client/app/bundles/course/assessment/pages/AssessmentStatistics/AnswerDetails/FileUploadDetails.tsx b/client/app/bundles/course/assessment/pages/AssessmentStatistics/AnswerDetails/FileUploadDetails.tsx index d2efcc18c8e..619bdc0e672 100644 --- a/client/app/bundles/course/assessment/pages/AssessmentStatistics/AnswerDetails/FileUploadDetails.tsx +++ b/client/app/bundles/course/assessment/pages/AssessmentStatistics/AnswerDetails/FileUploadDetails.tsx @@ -1,9 +1,9 @@ -import { QuestionAnswerDetails } from 'types/course/statistics/assessmentStatistics'; +import { QuestionAnswerDisplayDetails } from 'types/course/statistics/assessmentStatistics'; import AttachmentDetails from './AttachmentDetails'; const FileUploadDetails = ( - props: QuestionAnswerDetails<'FileUpload'>, + props: QuestionAnswerDisplayDetails<'FileUpload'>, ): JSX.Element => { const { answer } = props; diff --git a/client/app/bundles/course/assessment/pages/AssessmentStatistics/AnswerDetails/ForumPostResponseDetails.tsx b/client/app/bundles/course/assessment/pages/AssessmentStatistics/AnswerDetails/ForumPostResponseDetails.tsx index 9c24f63068e..2c063a3be09 100644 --- a/client/app/bundles/course/assessment/pages/AssessmentStatistics/AnswerDetails/ForumPostResponseDetails.tsx +++ b/client/app/bundles/course/assessment/pages/AssessmentStatistics/AnswerDetails/ForumPostResponseDetails.tsx @@ -1,6 +1,6 @@ import { defineMessages, FormattedMessage } from 'react-intl'; import { Typography } from '@mui/material'; -import { QuestionAnswerDetails } from 'types/course/statistics/assessmentStatistics'; +import { QuestionAnswerDisplayDetails } from 'types/course/statistics/assessmentStatistics'; import PostPack from './ForumPostResponseComponent/PostPack'; @@ -13,7 +13,7 @@ const translations = defineMessages({ }); const ForumPostResponseDetails = ( - props: QuestionAnswerDetails<'ForumPostResponse'>, + props: QuestionAnswerDisplayDetails<'ForumPostResponse'>, ): JSX.Element => { const { answer } = props; const postPacks = answer.fields.selected_post_packs; diff --git a/client/app/bundles/course/assessment/pages/AssessmentStatistics/AnswerDetails/MultipleChoiceDetails.tsx b/client/app/bundles/course/assessment/pages/AssessmentStatistics/AnswerDetails/MultipleChoiceDetails.tsx index 0758d2caa3e..d88f84ec2bd 100644 --- a/client/app/bundles/course/assessment/pages/AssessmentStatistics/AnswerDetails/MultipleChoiceDetails.tsx +++ b/client/app/bundles/course/assessment/pages/AssessmentStatistics/AnswerDetails/MultipleChoiceDetails.tsx @@ -1,9 +1,9 @@ import { FormControlLabel, Radio, Typography } from '@mui/material'; import { green } from '@mui/material/colors'; -import { QuestionAnswerDetails } from 'types/course/statistics/assessmentStatistics'; +import { QuestionAnswerDisplayDetails } from 'types/course/statistics/assessmentStatistics'; const MultipleChoiceDetails = ( - props: QuestionAnswerDetails<'MultipleChoice'>, + props: QuestionAnswerDisplayDetails<'MultipleChoice'>, ): JSX.Element => { const { question, answer } = props; return ( diff --git a/client/app/bundles/course/assessment/pages/AssessmentStatistics/AnswerDetails/MultipleResponseDetails.tsx b/client/app/bundles/course/assessment/pages/AssessmentStatistics/AnswerDetails/MultipleResponseDetails.tsx index 3147eb10d4f..2f81db899f6 100644 --- a/client/app/bundles/course/assessment/pages/AssessmentStatistics/AnswerDetails/MultipleResponseDetails.tsx +++ b/client/app/bundles/course/assessment/pages/AssessmentStatistics/AnswerDetails/MultipleResponseDetails.tsx @@ -1,9 +1,9 @@ import { Checkbox, FormControlLabel, Typography } from '@mui/material'; import { green } from '@mui/material/colors'; -import { QuestionAnswerDetails } from 'types/course/statistics/assessmentStatistics'; +import { QuestionAnswerDisplayDetails } from 'types/course/statistics/assessmentStatistics'; const MultipleResponseDetails = ( - props: QuestionAnswerDetails<'MultipleResponse'>, + props: QuestionAnswerDisplayDetails<'MultipleResponse'>, ): JSX.Element => { const { question, answer } = props; return ( diff --git a/client/app/bundles/course/assessment/pages/AssessmentStatistics/AnswerDetails/ProgrammingAnswerDetails.tsx b/client/app/bundles/course/assessment/pages/AssessmentStatistics/AnswerDetails/ProgrammingAnswerDetails.tsx index 451dffc8ad3..299523136e0 100644 --- a/client/app/bundles/course/assessment/pages/AssessmentStatistics/AnswerDetails/ProgrammingAnswerDetails.tsx +++ b/client/app/bundles/course/assessment/pages/AssessmentStatistics/AnswerDetails/ProgrammingAnswerDetails.tsx @@ -1,12 +1,12 @@ import { Annotation } from 'types/course/statistics/answer'; -import { QuestionAnswerDetails } from 'types/course/statistics/assessmentStatistics'; +import { QuestionAnswerDisplayDetails } from 'types/course/statistics/assessmentStatistics'; import CodaveriFeedbackStatus from './ProgrammingComponent/CodaveriFeedbackStatus'; import FileContent from './ProgrammingComponent/FileContent'; import TestCases from './ProgrammingComponent/TestCases'; const ProgrammingAnswerDetails = ( - props: QuestionAnswerDetails<'Programming'>, + props: QuestionAnswerDisplayDetails<'Programming'>, ): JSX.Element => { const { answer } = props; const annotations = answer.latestAnswer?.annotations ?? ([] as Annotation[]); diff --git a/client/app/bundles/course/assessment/pages/AssessmentStatistics/AnswerDetails/ProgrammingComponent/TestCases.tsx b/client/app/bundles/course/assessment/pages/AssessmentStatistics/AnswerDetails/ProgrammingComponent/TestCases.tsx index d862064771a..e9a0297d178 100644 --- a/client/app/bundles/course/assessment/pages/AssessmentStatistics/AnswerDetails/ProgrammingComponent/TestCases.tsx +++ b/client/app/bundles/course/assessment/pages/AssessmentStatistics/AnswerDetails/ProgrammingComponent/TestCases.tsx @@ -1,6 +1,6 @@ import { FC } from 'react'; import { defineMessages, FormattedMessage } from 'react-intl'; -import { Done } from '@mui/icons-material'; +import { Close, Done } from '@mui/icons-material'; import { Chip, Table, @@ -34,6 +34,14 @@ const translations = defineMessages({ id: 'course.assessment.submission.TestCaseView.allPassed', defaultMessage: 'All passed', }, + allFailed: { + id: 'course.assessment.submission.TestCaseView.allFailed', + defaultMessage: 'All failed', + }, + testCasesPassed: { + id: 'course.assessment.submission.TestCaseView.testCasesPassed', + defaultMessage: '{numPassed}/{numTestCases} passed', + }, publicTestCases: { id: 'course.assessment.submission.TestCaseView.publicTestCases', defaultMessage: 'Public Test Cases', @@ -64,32 +72,86 @@ interface Props { testCase: TestCase; } -const TestCaseComponent = ( - testCaseResults: TestCaseResult[], - testCaseType: string, -): JSX.Element => { +interface TestCaseComponentProps { + testCaseResults: TestCaseResult[]; + testCaseType: string; +} + +interface OutputStreamProps { + outputStreamType: 'standardOutput' | 'standardError'; + output?: string; +} + +const TestCaseComponent: FC = (props) => { + const { testCaseResults, testCaseType } = props; const { t } = useTranslation(); - const passedTestCases = testCaseResults.reduce( - (passed, testCase) => passed && testCase?.passed, - true, + const numPassedTestCases = testCaseResults.filter( + (result) => result.passed, + ).length; + const numTestCases = testCaseResults.length; + + const AllTestCasesPassedChip: FC = () => ( + } + label={t(translations.allPassed)} + size="small" + variant="outlined" + /> ); + const SomeTestCasesPassedChip: FC = () => ( + + ); + + const NoTestCasesPassedChip: FC = () => ( + } + label={t(translations.allFailed)} + size="small" + variant="outlined" + /> + ); + + const TestCasesIndicatorChip: FC = () => { + if (numPassedTestCases === numTestCases) { + return ; + } + + if (numPassedTestCases > 0) { + return ; + } + + return ; + }; + + const testCaseComponentClassName = (): string => { + if (numPassedTestCases === numTestCases) { + return 'border-success'; + } + + if (numPassedTestCases > 0) { + return 'border-warning'; + } + + return 'border-error'; + }; + return ( } - label={t(translations.allPassed)} - size="small" - variant="outlined" - /> - ) - } + icon={} id={testCaseType} title={t(translations[testCaseType])} > @@ -122,10 +184,8 @@ const TestCaseComponent = ( ); }; -const OutputStream = ( - outputStreamType: 'standardOutput' | 'standardError', - output?: string, -): JSX.Element => { +const OutputStream: FC = (props) => { + const { outputStreamType, output } = props; const { t } = useTranslation(); return ( = (props) => { return (
- {testCase.public_test && - testCase.public_test.length > 0 && - TestCaseComponent(testCase.public_test, 'publicTestCases')} - - {testCase.private_test && - testCase.private_test.length > 0 && - TestCaseComponent(testCase.private_test, 'privateTestCases')} - - {testCase.evaluation_test && - testCase.evaluation_test.length > 0 && - TestCaseComponent(testCase.evaluation_test, 'evaluationTestCases')} - - {OutputStream('standardOutput', testCase.stdout)} - {OutputStream('standardError', testCase.stderr)} + {testCase.public_test && testCase.public_test.length > 0 && ( + + )} + + {testCase.private_test && testCase.private_test.length > 0 && ( + + )} + + {testCase.evaluation_test && testCase.evaluation_test.length > 0 && ( + + )} + + + +
); }; diff --git a/client/app/bundles/course/assessment/pages/AssessmentStatistics/AnswerDetails/TextResponseDetails.tsx b/client/app/bundles/course/assessment/pages/AssessmentStatistics/AnswerDetails/TextResponseDetails.tsx index 8f245980227..b08dbd44609 100644 --- a/client/app/bundles/course/assessment/pages/AssessmentStatistics/AnswerDetails/TextResponseDetails.tsx +++ b/client/app/bundles/course/assessment/pages/AssessmentStatistics/AnswerDetails/TextResponseDetails.tsx @@ -1,10 +1,10 @@ import { Typography } from '@mui/material'; -import { QuestionAnswerDetails } from 'types/course/statistics/assessmentStatistics'; +import { QuestionAnswerDisplayDetails } from 'types/course/statistics/assessmentStatistics'; import AttachmentDetails from './AttachmentDetails'; const TextResponseDetails = ( - props: QuestionAnswerDetails<'TextResponse'>, + props: QuestionAnswerDisplayDetails<'TextResponse'>, ): JSX.Element => { const { question, answer } = props; diff --git a/client/app/bundles/course/assessment/pages/AssessmentStatistics/AnswerDisplay/AllAttempts.tsx b/client/app/bundles/course/assessment/pages/AssessmentStatistics/AnswerDisplay/AllAttempts.tsx new file mode 100644 index 00000000000..cde6b2033de --- /dev/null +++ b/client/app/bundles/course/assessment/pages/AssessmentStatistics/AnswerDisplay/AllAttempts.tsx @@ -0,0 +1,104 @@ +import { FC } from 'react'; +import { defineMessages } from 'react-intl'; +import { useParams } from 'react-router-dom'; +import { Typography } from '@mui/material'; +import { QuestionType } from 'types/course/assessment/question'; +import { QuestionAnswerDetails } from 'types/course/statistics/assessmentStatistics'; + +import { fetchQuestionAnswerDetails } from 'course/assessment/operations/statistics'; +import Link from 'lib/components/core/Link'; +import LoadingIndicator from 'lib/components/core/LoadingIndicator'; +import Preload from 'lib/components/wrappers/Preload'; +import { + getEditSubmissionQuestionURL, + getPastAnswersURL, +} from 'lib/helpers/url-builders'; +import useTranslation from 'lib/hooks/useTranslation'; + +import AllAttemptsDisplay from './AllAttemptsDisplay'; +import Comment from './Comment'; + +const translations = defineMessages({ + questionTitle: { + id: 'course.assessment.statistics.questionTitle', + defaultMessage: 'Question {index}', + }, + gradeDisplay: { + id: 'course.assessment.statistics.gradeDisplay', + defaultMessage: 'Grade: {grade} / {maxGrade}', + }, + morePastAnswers: { + id: 'course.assessment.statistics.morePastAnswers', + defaultMessage: 'View All Past Answers', + }, + currentAnswer: { + id: 'course.assessment.statistics.currentAnswer', + defaultMessage: 'Most Recent Answer', + }, + pastAnswerTitle: { + id: 'course.assessment.statistics.pastAnswerTitle', + defaultMessage: 'Submitted At: {submittedAt}', + }, + submissionPage: { + id: 'course.assessment.statistics.submissionPage', + defaultMessage: 'Go to Answer Page', + }, +}); + +interface Props { + curAnswerId: number; + index: number; +} + +const AllAttemptsIndex: FC = (props) => { + const { curAnswerId, index } = props; + const { t } = useTranslation(); + const { courseId, assessmentId } = useParams(); + + const fetchQuestionAndCurrentAnswerDetails = (): Promise< + QuestionAnswerDetails + > => { + return fetchQuestionAnswerDetails(curAnswerId); + }; + + return ( + } + while={fetchQuestionAndCurrentAnswerDetails} + > + {(data): JSX.Element => { + const pastAnswersURL = getPastAnswersURL( + courseId, + assessmentId, + data.submissionQuestionId, + ); + + return ( + <> + + + + + {t(translations.morePastAnswers)} + + + + {data.comments.length > 0 && } + + ); + }} + + ); +}; + +export default AllAttemptsIndex; diff --git a/client/app/bundles/course/assessment/pages/AssessmentStatistics/AnswerDisplay/AllAttemptsDisplay.tsx b/client/app/bundles/course/assessment/pages/AssessmentStatistics/AnswerDisplay/AllAttemptsDisplay.tsx new file mode 100644 index 00000000000..00c8c9cacdb --- /dev/null +++ b/client/app/bundles/course/assessment/pages/AssessmentStatistics/AnswerDisplay/AllAttemptsDisplay.tsx @@ -0,0 +1,155 @@ +import { FC, useState } from 'react'; +import { defineMessages } from 'react-intl'; +import { Slider, Typography } from '@mui/material'; +import { QuestionType } from 'types/course/assessment/question'; +import { + AllAnswerDetails, + QuestionDetails, +} from 'types/course/statistics/assessmentStatistics'; + +import { workflowStates } from 'course/assessment/submission/constants'; +import Accordion from 'lib/components/core/layouts/Accordion'; +import Link from 'lib/components/core/Link'; +import useTranslation from 'lib/hooks/useTranslation'; +import { formatLongDateTime } from 'lib/moment'; + +import AnswerDetails from '../AnswerDetails/AnswerDetails'; + +interface Props { + allAnswers: AllAnswerDetails[]; + question: QuestionDetails; + questionNumber: number; + submissionEditUrl: string; +} + +const translations = defineMessages({ + questionTitle: { + id: 'course.assessment.statistics.questionTitle', + defaultMessage: 'Question {index}', + }, + pastAnswerTitle: { + id: 'course.assessment.statistics.pastAnswerTitle', + defaultMessage: 'Submitted At: {submittedAt}', + }, + mostRecentAnswer: { + id: 'course.assessment.statistics.mostRecentAnswer', + defaultMessage: 'Current Answer (Attempting)', + }, + submissionPage: { + id: 'course.assessment.statistics.submissionPage', + defaultMessage: 'Go to Answer Page', + }, +}); + +// only used as a dummy buffer time if the submission's state is attempting +// this is due to the time record for attempting answer being lower than any other +// states' answer, while it's actually the latest version. +const BUFFER_TIME = 100; + +const AllAttemptsDisplay: FC = (props) => { + const { allAnswers, question, questionNumber, submissionEditUrl } = props; + + const { t } = useTranslation(); + + let currentAnswer = allAnswers.find((answer) => answer.currentAnswer); + const sortedAnswers = allAnswers.filter((answer) => !answer.currentAnswer); + + if ( + sortedAnswers.length > 0 && + currentAnswer?.workflowState === workflowStates.Attempting + ) { + currentAnswer = { + ...currentAnswer, + createdAt: new Date( + sortedAnswers[sortedAnswers.length - 1].createdAt.getTime() + + BUFFER_TIME, + ), + }; + } + + sortedAnswers.push(currentAnswer!); + + const answerSubmittedTimes = sortedAnswers.map((answer, idx) => { + return { + value: idx, + label: + idx === 0 || idx === sortedAnswers.length - 1 + ? formatLongDateTime(answer.createdAt) + : '', + }; + }); + + const currentAnswerMarker = + answerSubmittedTimes[answerSubmittedTimes.length - 1]; + + const earliestAnswerMarker = answerSubmittedTimes[0]; + const [displayedIndex, setDisplayedIndex] = useState( + currentAnswerMarker.value, + ); + + const isCurrentAnswerStillAttempting = + sortedAnswers[answerSubmittedTimes.length - 1].workflowState === + workflowStates.Attempting; + + return ( + <> + + + {t(translations.submissionPage)} + + + +
+ {question.title} + +
+
+ {answerSubmittedTimes.length > 1 && ( +
+ { + setDisplayedIndex(Array.isArray(value) ? value[0] : value); + }} + step={null} + valueLabelDisplay="off" + /> +
+ )} + + + {(!displayedIndex || + displayedIndex === answerSubmittedTimes.length - 1) && + isCurrentAnswerStillAttempting + ? t(translations.mostRecentAnswer) + : t(translations.pastAnswerTitle, { + submittedAt: formatLongDateTime( + sortedAnswers[displayedIndex ?? answerSubmittedTimes.length - 1] + .createdAt, + ), + })} + + + + ); +}; + +export default AllAttemptsDisplay; diff --git a/client/app/bundles/course/assessment/pages/AssessmentStatistics/AnswerDisplay/Comment.tsx b/client/app/bundles/course/assessment/pages/AssessmentStatistics/AnswerDisplay/Comment.tsx new file mode 100644 index 00000000000..2b48bc58e97 --- /dev/null +++ b/client/app/bundles/course/assessment/pages/AssessmentStatistics/AnswerDisplay/Comment.tsx @@ -0,0 +1,70 @@ +import { FC } from 'react'; +import { defineMessages } from 'react-intl'; +import { Avatar, Box, CardHeader, Typography } from '@mui/material'; +import { CommentItem } from 'types/course/statistics/assessmentStatistics'; + +import useTranslation from 'lib/hooks/useTranslation'; +import { formatLongDateTime } from 'lib/moment'; + +interface Props { + comments: CommentItem[]; +} + +const translations = defineMessages({ + comments: { + id: 'course.assessment.statistics.comments', + defaultMessage: 'Comments', + }, +}); + +const Comment: FC = (props) => { + const { comments } = props; + const { t } = useTranslation(); + + return ( +
+ + {t(translations.comments)} + + + {comments.map((comment) => ( + +
+ + } + className="p-6" + subheader={`${formatLongDateTime(comment.createdAt)}${ + comment.isDelayed ? ' (delayed comment)' : '' + }`} + subheaderTypographyProps={{ display: 'block' }} + title={comment.creator.name} + titleTypographyProps={{ display: 'block', marginright: 20 }} + /> +
+
+ +
+
+ ))} +
+ ); +}; + +export default Comment; diff --git a/client/app/bundles/course/assessment/pages/AssessmentStatistics/AnswerDisplay.tsx b/client/app/bundles/course/assessment/pages/AssessmentStatistics/AnswerDisplay/LastAttempt.tsx similarity index 67% rename from client/app/bundles/course/assessment/pages/AssessmentStatistics/AnswerDisplay.tsx rename to client/app/bundles/course/assessment/pages/AssessmentStatistics/AnswerDisplay/LastAttempt.tsx index 9dc0b3ce695..7481e9f95ce 100644 --- a/client/app/bundles/course/assessment/pages/AssessmentStatistics/AnswerDisplay.tsx +++ b/client/app/bundles/course/assessment/pages/AssessmentStatistics/AnswerDisplay/LastAttempt.tsx @@ -1,17 +1,22 @@ import { FC } from 'react'; import { defineMessages } from 'react-intl'; +import { useParams } from 'react-router-dom'; import { Chip, Typography } from '@mui/material'; import { QuestionType } from 'types/course/assessment/question'; import { QuestionAnswerDetails } from 'types/course/statistics/assessmentStatistics'; import { fetchQuestionAnswerDetails } from 'course/assessment/operations/statistics'; import Accordion from 'lib/components/core/layouts/Accordion'; +import Link from 'lib/components/core/Link'; import LoadingIndicator from 'lib/components/core/LoadingIndicator'; import Preload from 'lib/components/wrappers/Preload'; +import { getEditSubmissionQuestionURL } from 'lib/helpers/url-builders'; import useTranslation from 'lib/hooks/useTranslation'; -import AnswerDetails from './AnswerDetails/AnswerDetails'; -import { getClassNameForMarkCell } from './classNameUtils'; +import AnswerDetails from '../AnswerDetails/AnswerDetails'; +import { getClassNameForMarkCell } from '../classNameUtils'; + +import Comment from './Comment'; const translations = defineMessages({ questionTitle: { @@ -22,6 +27,14 @@ const translations = defineMessages({ id: 'course.assessment.statistics.gradeDisplay', defaultMessage: 'Grade: {grade} / {maxGrade}', }, + morePastAnswers: { + id: 'course.assessment.statistics.morePastAnswers', + defaultMessage: 'View All Past Answers', + }, + submissionPage: { + id: 'course.assessment.statistics.submissionPage', + defaultMessage: 'Go to Answer Page', + }, }); interface Props { @@ -29,8 +42,9 @@ interface Props { index: number; } -const AnswerDisplay: FC = (props) => { +const LastAttemptIndex: FC = (props) => { const { curAnswerId, index } = props; + const { courseId, assessmentId } = useParams(); const { t } = useTranslation(); const fetchQuestionAndCurrentAnswerDetails = (): Promise< @@ -51,6 +65,19 @@ const AnswerDisplay: FC = (props) => { ); return ( <> + + + {t(translations.submissionPage)} + + = (props) => { })} variant="filled" /> + {data.comments.length > 0 && } ); }} @@ -81,4 +109,4 @@ const AnswerDisplay: FC = (props) => { ); }; -export default AnswerDisplay; +export default LastAttemptIndex; diff --git a/client/app/bundles/course/assessment/pages/AssessmentStatistics/StudentAttemptCountTable.tsx b/client/app/bundles/course/assessment/pages/AssessmentStatistics/StudentAttemptCountTable.tsx index 3360eb5c5a3..ee21b3433f9 100644 --- a/client/app/bundles/course/assessment/pages/AssessmentStatistics/StudentAttemptCountTable.tsx +++ b/client/app/bundles/course/assessment/pages/AssessmentStatistics/StudentAttemptCountTable.tsx @@ -15,10 +15,11 @@ import Note from 'lib/components/core/Note'; import GhostIcon from 'lib/components/icons/GhostIcon'; import Table, { ColumnTemplate } from 'lib/components/table'; import { DEFAULT_TABLE_ROWS_PER_PAGE } from 'lib/constants/sharedConstants'; +import { getEditSubmissionURL } from 'lib/helpers/url-builders'; import { useAppSelector } from 'lib/hooks/store'; import useTranslation from 'lib/hooks/useTranslation'; -import AnswerDisplay from './AnswerDisplay'; +import AllAttemptsIndex from './AnswerDisplay/AllAttempts'; import { getClassNameForAttemptCountCell } from './classNameUtils'; import { getAssessmentStatistics } from './selectors'; @@ -80,7 +81,7 @@ const statusTranslations = { const StudentAttemptCountTable: FC = (props) => { const { t } = useTranslation(); - const { courseId } = useParams(); + const { courseId, assessmentId } = useParams(); const { includePhantom } = props; const statistics = useAppSelector(getAssessmentStatistics); @@ -132,7 +133,7 @@ const StudentAttemptCountTable: FC = (props) => { setOpenPastAnswers(true); setAnswerInfo({ index: index + 1, - answerId: datum.answers![index].lastAttemptAnswerId, + answerId: datum.attemptStatus![index].lastAttemptAnswerId, studentName: datum.courseUser.name, }); }} @@ -232,19 +233,20 @@ const StudentAttemptCountTable: FC = (props) => { title: t(translations.workflowState), sortable: true, cell: (datum) => ( - + + ] + } + variant="filled" + /> + ), className: 'center', }, @@ -281,7 +283,7 @@ const StudentAttemptCountTable: FC = (props) => { open={openPastAnswers} title={answerInfo.studentName} > - diff --git a/client/app/bundles/course/assessment/pages/AssessmentStatistics/StudentMarksPerQuestionTable.tsx b/client/app/bundles/course/assessment/pages/AssessmentStatistics/StudentMarksPerQuestionTable.tsx index 30a140bf3a4..0b90e9dac44 100644 --- a/client/app/bundles/course/assessment/pages/AssessmentStatistics/StudentMarksPerQuestionTable.tsx +++ b/client/app/bundles/course/assessment/pages/AssessmentStatistics/StudentMarksPerQuestionTable.tsx @@ -11,10 +11,11 @@ import Link from 'lib/components/core/Link'; import GhostIcon from 'lib/components/icons/GhostIcon'; import Table, { ColumnTemplate } from 'lib/components/table'; import { DEFAULT_TABLE_ROWS_PER_PAGE } from 'lib/constants/sharedConstants'; +import { getEditSubmissionURL } from 'lib/helpers/url-builders'; import { useAppSelector } from 'lib/hooks/store'; import useTranslation from 'lib/hooks/useTranslation'; -import AnswerDisplay from './AnswerDisplay'; +import LastAttemptIndex from './AnswerDisplay/LastAttempt'; import { getClassNameForMarkCell } from './classNameUtils'; import { getAssessmentStatistics } from './selectors'; @@ -79,7 +80,7 @@ const statusTranslations = { const StudentMarksPerQuestionTable: FC = (props) => { const { t } = useTranslation(); - const { courseId } = useParams(); + const { courseId, assessmentId } = useParams(); const { includePhantom } = props; const statistics = useAppSelector(getAssessmentStatistics); @@ -233,19 +234,20 @@ const StudentMarksPerQuestionTable: FC = (props) => { title: t(translations.workflowState), sortable: true, cell: (datum) => ( - + + ] + } + variant="filled" + /> + ), className: 'center', }, @@ -322,7 +324,7 @@ const StudentMarksPerQuestionTable: FC = (props) => { open={openAnswer} title={answerDisplayInfo.studentName} > - diff --git a/client/app/bundles/course/assessment/submission/containers/TestCaseView/index.jsx b/client/app/bundles/course/assessment/submission/containers/TestCaseView/index.jsx index dce43b701f4..8f3360c86d8 100644 --- a/client/app/bundles/course/assessment/submission/containers/TestCaseView/index.jsx +++ b/client/app/bundles/course/assessment/submission/containers/TestCaseView/index.jsx @@ -1,7 +1,7 @@ import { Component, Fragment } from 'react'; import { defineMessages, FormattedMessage } from 'react-intl'; import { connect } from 'react-redux'; -import { Done } from '@mui/icons-material'; +import { Close, Done } from '@mui/icons-material'; import Clear from '@mui/icons-material/Clear'; import { Alert, @@ -47,6 +47,14 @@ const translations = defineMessages({ id: 'course.assessment.submission.TestCaseView.allPassed', defaultMessage: 'All passed', }, + allFailed: { + id: 'course.assessment.submission.TestCaseView.allFailed', + defaultMessage: 'All failed', + }, + testCasesPassed: { + id: 'course.assessment.submission.TestCaseView.testCasesPassed', + defaultMessage: '{numPassed}/{numTestCases} passed', + }, publicTestCases: { id: 'course.assessment.submission.TestCaseView.publicTestCases', defaultMessage: 'Public Test Cases', @@ -191,29 +199,77 @@ export class VisibleTestCaseView extends Component { return null; } - const passedTestCases = testCases.reduce( - (passed, testCase) => passed && testCase?.passed, - true, + const numPassedTestCases = testCases.filter( + (testCase) => testCase.passed, + ).length; + + const AllTestCasesPassedChip = () => ( + } + label={} + size="small" + variant="outlined" + /> + ); + + const SomeTestCasesPassedChip = () => ( + + } + size="small" + variant="outlined" + /> + ); + + const NoTestCasesPassedChip = () => ( + } + label={} + size="small" + variant="outlined" + /> ); - const shouldShowAllPassed = !isDraftAnswer && passedTestCases; + const TestCasesIndicatorChip = () => { + if (numPassedTestCases === testCases.length) { + return ; + } + + if (numPassedTestCases > 0) { + return ; + } + + return ; + }; + + const testCaseComponentClassName = () => { + if (numPassedTestCases === testCases.length) { + return 'border-success'; + } + + if (numPassedTestCases > 0) { + return 'border-warning'; + } + + return 'border-error'; + }; return ( } - label={} - size="small" - variant="outlined" - /> - ) - } + icon={!isDraftAnswer && } id={testCaseType} subtitle={ warn && diff --git a/client/app/bundles/course/assessment/submission/pages/QuestionIndex/PastAttempts.tsx b/client/app/bundles/course/assessment/submission/pages/QuestionIndex/PastAttempts.tsx new file mode 100644 index 00000000000..171e331a966 --- /dev/null +++ b/client/app/bundles/course/assessment/submission/pages/QuestionIndex/PastAttempts.tsx @@ -0,0 +1,50 @@ +import { FC } from 'react'; +import { useParams } from 'react-router-dom'; +import { QuestionType } from 'types/course/assessment/question'; +import { QuestionAllAnswerDisplayDetails } from 'types/course/statistics/assessmentStatistics'; + +import { fetchAllAnswers } from 'course/assessment/operations/statistics'; +import AllAttemptsDisplay from 'course/assessment/pages/AssessmentStatistics/AnswerDisplay/AllAttemptsDisplay'; +import Comment from 'course/assessment/pages/AssessmentStatistics/AnswerDisplay/Comment'; +import LoadingIndicator from 'lib/components/core/LoadingIndicator'; +import Preload from 'lib/components/wrappers/Preload'; +import { getEditSubmissionQuestionURL } from 'lib/helpers/url-builders'; +import { getSubmissionQuestionId } from 'lib/helpers/url-helpers'; + +const PastAnswers: FC = () => { + const submissionQuestionId = getSubmissionQuestionId(); + const { courseId, assessmentId } = useParams(); + if (!submissionQuestionId) { + return null; + } + + const parsedSubmissionQuestionId = parseInt(submissionQuestionId, 10); + + const fetchAnswers = (): Promise< + QuestionAllAnswerDisplayDetails + > => { + return fetchAllAnswers(parsedSubmissionQuestionId); + }; + + return ( + } while={fetchAnswers}> + {(data): JSX.Element => ( + <> + + {data.comments.length > 0 && } + + )} + + ); +}; +export default PastAnswers; diff --git a/client/app/lib/helpers/url-builders.js b/client/app/lib/helpers/url-builders.js index 3878ae8ca84..bf3fe29c807 100644 --- a/client/app/lib/helpers/url-builders.js +++ b/client/app/lib/helpers/url-builders.js @@ -12,6 +12,14 @@ export const getAchievementURL = (courseId, achievementId) => export const getEditSubmissionURL = (courseId, assessmentId, submissionId) => `/courses/${courseId}/assessments/${assessmentId}/submissions/${submissionId}/edit`; +export const getEditSubmissionQuestionURL = ( + courseId, + assessmentId, + submissionId, + questionNumber, +) => + `/courses/${courseId}/assessments/${assessmentId}/submissions/${submissionId}/edit?step=${questionNumber}`; + export const getSubmissionLogsURL = (courseId, assessmentId, submissionId) => `/courses/${courseId}/assessments/${assessmentId}/submissions/${submissionId}/logs`; @@ -32,6 +40,13 @@ export const getSurveyResponseURL = (courseId, surveyId, responseId) => export const getAssessmentURL = (courseId, assessmentId) => `/courses/${courseId}/assessments/${assessmentId}`; +export const getPastAnswersURL = ( + courseId, + assessmentId, + submissionQuestionId, +) => + `/courses/${courseId}/assessments/${assessmentId}/submission_questions/${submissionQuestionId}/past_attempts`; + export const getAssessmentSubmissionURL = (courseId, assessmentId) => `/courses/${courseId}/assessments/${assessmentId}/submissions`; diff --git a/client/app/lib/helpers/url-helpers.js b/client/app/lib/helpers/url-helpers.js index 5a5645c38eb..20f1e892c01 100644 --- a/client/app/lib/helpers/url-helpers.js +++ b/client/app/lib/helpers/url-helpers.js @@ -113,6 +113,13 @@ function getCourseUserId() { return match && match[1]; } +function getSubmissionQuestionId() { + const match = window.location.pathname.match( + /^\/courses\/\d+\/assessments\/\d+\/submission_questions\/(\d+)/, + ); + return match && match[1]; +} + /** * Get the video submission id from URL. * @@ -144,6 +151,7 @@ export { getCurrentPath, getScribingId, getSubmissionId, + getSubmissionQuestionId, getSurveyId, getUrlParameter, getVideoId, diff --git a/client/app/routers/AuthenticatedApp.tsx b/client/app/routers/AuthenticatedApp.tsx index c9860cf915d..6ee4d2e14cc 100644 --- a/client/app/routers/AuthenticatedApp.tsx +++ b/client/app/routers/AuthenticatedApp.tsx @@ -114,6 +114,7 @@ import { questionHandle, } from 'course/assessment/handles'; import QuestionFormOutlet from 'course/assessment/question/components/QuestionFormOutlet'; +import PastAttempts from 'course/assessment/submission/pages/QuestionIndex/PastAttempts'; import { CourseContainer } from 'course/container'; import { forumHandle, forumTopicHandle } from 'course/forum/handles'; import { folderHandle } from 'course/material/folders/handles'; @@ -591,6 +592,20 @@ const authenticatedRouter: Translated = (t) => }, ], }, + { + path: 'submission_questions', + children: [ + { + path: ':submissionQuestionId', + children: [ + { + path: 'past_attempts', + element: , + }, + ], + }, + ], + }, { path: 'question', element: , diff --git a/client/app/theme/palette.js b/client/app/theme/palette.js index 4f5bde2b9e5..faed622ccc1 100644 --- a/client/app/theme/palette.js +++ b/client/app/theme/palette.js @@ -81,6 +81,14 @@ const palette = { [workflowStates.Published]: colors.green[100], }, + submissionStatusClassName: { + [workflowStates.Unstarted]: 'bg-red-200', + [workflowStates.Attempting]: 'bg-yellow-200', + [workflowStates.Submitted]: 'bg-grey-200', + [workflowStates.Graded]: 'bg-blue-200', + [workflowStates.Published]: 'bg-green-200', + }, + groupRole: { [groupRole.Normal]: colors.green[100], [groupRole.Manager]: colors.red[100], diff --git a/client/app/types/course/statistics/assessmentStatistics.ts b/client/app/types/course/statistics/assessmentStatistics.ts index 692fc991bc0..66dbb4ad5f5 100644 --- a/client/app/types/course/statistics/assessmentStatistics.ts +++ b/client/app/types/course/statistics/assessmentStatistics.ts @@ -1,6 +1,7 @@ import { QuestionType } from '../assessment/question'; import { SpecificQuestionDataMap } from '../assessment/submission/question/types'; import { WorkflowState } from '../assessment/submission/submission'; +import { CourseUserBasicListData } from '../courseUsers'; import { AnswerDetailsMap } from './answer'; @@ -44,6 +45,7 @@ export interface AttemptInfo { } interface SubmissionInfo { + id: number; courseUser: StudentInfo; workflowState?: WorkflowState; submittedAt?: string; @@ -94,13 +96,49 @@ interface QuestionBasicDetails { title: string; description: string; type: T; + questionNumber?: number; maximumGrade: number; } +export interface CommentItem { + id: number; + createdAt: Date; + creator: CourseUserBasicListData; + isDelayed: boolean; + text: string; +} + export type QuestionDetails = QuestionBasicDetails & SpecificQuestionDataMap[T]; +export type AllAnswerDetails = + AnswerDetailsMap[T] & { + createdAt: Date; + currentAnswer: boolean; + workflowState: WorkflowState; + }; + export interface QuestionAnswerDetails { question: QuestionDetails; answer: AnswerDetailsMap[T]; + allAnswers: AllAnswerDetails[]; + comments: CommentItem[]; + submissionId: number; + submissionQuestionId: number; +} + +export interface QuestionAnswerDisplayDetails< + T extends keyof typeof QuestionType, +> { + question: QuestionDetails; + answer: AnswerDetailsMap[T]; +} + +export interface QuestionAllAnswerDisplayDetails< + T extends keyof typeof QuestionType, +> { + question: QuestionDetails; + allAnswers: AllAnswerDetails[]; + submissionId: number; + comments: CommentItem[]; } diff --git a/config/routes.rb b/config/routes.rb index c06bf12ec0d..046223decfd 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -438,6 +438,7 @@ get 'course/staff' => 'aggregate#all_staff' get 'course/course/progression' => 'aggregate#course_progression' get 'course/course/performance' => 'aggregate#course_performance' + get 'submission_question/:id' => 'answers#all_answers' get 'user/:user_id/learning_rate_records' => 'users#learning_rate_records' end diff --git a/spec/controllers/course/statistics/answer_controller_spec.rb b/spec/controllers/course/statistics/answer_controller_spec.rb new file mode 100644 index 00000000000..63fc6833902 --- /dev/null +++ b/spec/controllers/course/statistics/answer_controller_spec.rb @@ -0,0 +1,118 @@ +# frozen_string_literal: true +require 'rails_helper' + +RSpec.describe Course::Statistics::AnswersController, type: :controller do + let(:instance) { Instance.default } + + with_tenant(:instance) do + let(:course) { create(:course, :published) } + let(:course_student) { create(:course_student, course: course) } + let(:assessment) { create(:assessment, :published, :with_all_question_types, course: course) } + let(:submission) do + create(:submission, :published, + assessment: assessment, course: course, creator: course_student.user) + end + let!(:answer) { submission.answers.first } + let!(:submission_question) do + create(:submission_question, :with_post, submission_id: answer.submission_id, question_id: answer.question_id) + end + + describe '#question_answer_details' do + render_views + subject { get :question_answer_details, as: :json, params: { course_id: course, id: answer.id } } + + context 'when the Normal User get the question answer details' do + let(:user) { create(:user) } + before { sign_in(user) } + it { expect { subject }.to raise_exception(CanCan::AccessDenied) } + end + + context 'when the Course Student get the question answer details' do + let(:user) { course_student.user } + before { sign_in(user) } + it { expect { subject }.to raise_exception(CanCan::AccessDenied) } + end + + context 'when the Course Manager get the question answer details' do + let(:user) { create(:course_manager, course: course).user } + before { sign_in(user) } + + it 'returns OK with right number of answers and comments' do + expect(subject).to have_http_status(:success) + json_result = JSON.parse(response.body) + + # expect only one allAnswers + expect(json_result['allAnswers'].count).to eq(1) + + # expect only one comment + expect(json_result['comments'].count).to eq(1) + end + end + + context 'when the Course Teaching Assistant get the question answer details' do + let(:user) { create(:course_teaching_assistant, course: course).user } + before { sign_in(user) } + + it 'returns OK with right number of answers and comments' do + expect(subject).to have_http_status(:success) + json_result = JSON.parse(response.body) + + # expect only one allAnswers + expect(json_result['allAnswers'].count).to eq(1) + + # expect only one comment + expect(json_result['comments'].count).to eq(1) + end + end + end + + describe '#all_answers' do + render_views + subject { get :all_answers, as: :json, params: { course_id: course, id: submission_question.id } } + + context 'when the Normal User get the question answer details' do + let(:user) { create(:user) } + before { sign_in(user) } + it { expect { subject }.to raise_exception(CanCan::AccessDenied) } + end + + context 'when the Course Student get the question answer details' do + let(:user) { course_student.user } + before { sign_in(user) } + it { expect { subject }.to raise_exception(CanCan::AccessDenied) } + end + + context 'when the Course Manager get the question answer details' do + let(:user) { create(:course_manager, course: course).user } + before { sign_in(user) } + + it 'returns OK with right number of answers and comments' do + expect(subject).to have_http_status(:success) + json_result = JSON.parse(response.body) + + # expect only one allAnswers + expect(json_result['allAnswers'].count).to eq(1) + + # expect only one comment + expect(json_result['comments'].count).to eq(1) + end + end + + context 'when the Course Teaching Assistant get the question answer details' do + let(:user) { create(:course_teaching_assistant, course: course).user } + before { sign_in(user) } + + it 'returns OK with right number of answers and comments' do + expect(subject).to have_http_status(:success) + json_result = JSON.parse(response.body) + + # expect only one allAnswers + expect(json_result['allAnswers'].count).to eq(1) + + # expect only one comment + expect(json_result['comments'].count).to eq(1) + end + end + end + end +end