From bdd4a111982380bf980c6243ecaf883ee817f212 Mon Sep 17 00:00:00 2001 From: bivanalhar Date: Tue, 30 Jan 2024 02:17:14 +0800 Subject: [PATCH 01/14] refactor(statistics): emphasis on type name (display details) --- .../AnswerDetails/FileUploadDetails.tsx | 4 ++-- .../ForumPostResponseDetails.tsx | 4 ++-- .../AnswerDetails/MultipleChoiceDetails.tsx | 4 ++-- .../AnswerDetails/MultipleResponseDetails.tsx | 4 ++-- .../ProgrammingAnswerDetails.tsx | 4 ++-- .../AnswerDetails/TextResponseDetails.tsx | 4 ++-- .../course/statistics/assessmentStatistics.ts | 20 +++++++++++++++++++ 7 files changed, 32 insertions(+), 12 deletions(-) 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/TextResponseDetails.tsx b/client/app/bundles/course/assessment/pages/AssessmentStatistics/AnswerDetails/TextResponseDetails.tsx index 09faacf07ce..c0a404c8e62 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/types/course/statistics/assessmentStatistics.ts b/client/app/types/course/statistics/assessmentStatistics.ts index 68df9d28393..f4cbf352ec8 100644 --- a/client/app/types/course/statistics/assessmentStatistics.ts +++ b/client/app/types/course/statistics/assessmentStatistics.ts @@ -44,6 +44,7 @@ export interface AttemptInfo { } interface SubmissionInfo { + id: number; courseUser: StudentInfo; workflowState?: WorkflowState; submittedAt?: string; @@ -95,7 +96,26 @@ interface QuestionBasicDetails { export type QuestionDetails = QuestionBasicDetails & SpecificQuestionDataMap[T]; +export type AllAnswerDetails = + AnswerDetailsMap[T] & { createdAt: Date; currentAnswer: boolean }; + export interface QuestionAnswerDetails { question: QuestionDetails; answer: AnswerDetailsMap[T]; + allAnswers: AllAnswerDetails[]; + 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[]; } From 556f2c4e4d460abc925e76df62dc58868ac738df Mon Sep 17 00:00:00 2001 From: bivanalhar Date: Wed, 21 Feb 2024 16:03:34 +0800 Subject: [PATCH 02/14] feat(past_answers): page for all past answers - per submission question id --- .../course/statistics/answers_controller.rb | 38 ++++++++ .../statistics/answers/_answer.json.jbuilder | 7 ++ .../answers/all_answers.json.jbuilder | 16 ++++ .../question_answer_details.json.jbuilder | 14 +-- .../course/Statistics/AllAnswerStatistics.ts | 18 ++++ client/app/api/course/Statistics/index.ts | 2 + .../assessment/operations/statistics.ts | 10 +++ .../pages/QuestionIndex/PastAnswers.tsx | 87 +++++++++++++++++++ client/app/routers/AuthenticatedApp.tsx | 15 ++++ config/routes.rb | 5 +- 10 files changed, 205 insertions(+), 7 deletions(-) create mode 100644 app/views/course/statistics/answers/_answer.json.jbuilder create mode 100644 app/views/course/statistics/answers/all_answers.json.jbuilder create mode 100644 client/app/api/course/Statistics/AllAnswerStatistics.ts create mode 100644 client/app/bundles/course/assessment/submission/pages/QuestionIndex/PastAnswers.tsx diff --git a/app/controllers/course/statistics/answers_controller.rb b/app/controllers/course/statistics/answers_controller.rb index 905218e2435..1e6cb458eca 100644 --- a/app/controllers/course/statistics/answers_controller.rb +++ b/app/controllers/course/statistics/answers_controller.rb @@ -2,10 +2,33 @@ 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 + + fetch_all_answers(@answer.submission_id, @answer.question_id) + end + + def all_answers + @submission_question = Course::Assessment::SubmissionQuestion.find(submission_question_params[:id]) + submission_id = @submission_question.submission_id + question_id = @submission_question.question_id + + @question = Course::Assessment::Question.find(question_id) + @submission = Course::Assessment::Submission.find(submission_id) + @assessment = @submission.assessment + + @all_answers = Course::Assessment::Answer. + unscope(:order). + order(created_at: :desc). + where(submission_id: submission_id, question_id: question_id) end private @@ -13,4 +36,19 @@ def question_answer_details def answer_params params.permit(:id) end + + def submission_question_params + params.permit(: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?) + @all_answers = answers.where(current_answer: false).limit(MAX_ANSWERS_COUNT - 1).to_a.reverse + @all_answers.unshift(current_answer) + 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..279e9d8315b --- /dev/null +++ b/app/views/course/statistics/answers/all_answers.json.jbuilder @@ -0,0 +1,16 @@ +# 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.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 +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..aa40111a983 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,14 @@ 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 +end + +json.submissionQuestionId @submission_question.id 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 727cd98982b..b7e1f92211f 100644 --- a/client/app/bundles/course/assessment/operations/statistics.ts +++ b/client/app/bundles/course/assessment/operations/statistics.ts @@ -3,6 +3,7 @@ import { dispatch } from 'store'; import { QuestionType } from 'types/course/assessment/question'; import { AncestorAssessmentStats, + QuestionAllAnswerDisplayDetails, QuestionAnswerDetails, } from 'types/course/statistics/assessmentStatistics'; @@ -48,3 +49,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/submission/pages/QuestionIndex/PastAnswers.tsx b/client/app/bundles/course/assessment/submission/pages/QuestionIndex/PastAnswers.tsx new file mode 100644 index 00000000000..d99dcc211c0 --- /dev/null +++ b/client/app/bundles/course/assessment/submission/pages/QuestionIndex/PastAnswers.tsx @@ -0,0 +1,87 @@ +import { FC } from 'react'; +import { defineMessages } from 'react-intl'; +import { QuestionType } from 'types/course/assessment/question'; +import { QuestionAllAnswerDisplayDetails } from 'types/course/statistics/assessmentStatistics'; + +import { fetchAllAnswers } from 'course/assessment/operations/statistics'; +import AnswerDetails from 'course/assessment/pages/AssessmentStatistics/AnswerDetails/AnswerDetails'; +import Accordion from 'lib/components/core/layouts/Accordion'; +import LoadingIndicator from 'lib/components/core/LoadingIndicator'; +import Preload from 'lib/components/wrappers/Preload'; +import { getSubmissionQuestionId } from 'lib/helpers/url-helpers'; +import useTranslation from 'lib/hooks/useTranslation'; +import { formatLongDateTime } from 'lib/moment'; + +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: '#{index}) Submitted At: {submittedAt}', + }, +}); + +const PastAnswers: FC = () => { + const submissionQuestionId = getSubmissionQuestionId(); + const { t } = useTranslation(); + if (!submissionQuestionId) { + return null; + } + + const parsedSubmissionQuestionId = parseInt(submissionQuestionId, 10); + + const fetchAnswers = (): Promise< + QuestionAllAnswerDisplayDetails + > => { + return fetchAllAnswers(parsedSubmissionQuestionId); + }; + + return ( + } while={fetchAnswers}> + {(data): JSX.Element => { + const { question, allAnswers } = data; + const currentAnswer = allAnswers.find((answer) => answer.currentAnswer); + const otherAnswers = allAnswers.filter( + (answer) => !answer.currentAnswer, + ); + + return ( + <> + + + + {otherAnswers.length > 0 && + otherAnswers.map((answer, index) => ( + + + + ))} + + ); + }} + + ); +}; +export default PastAnswers; diff --git a/client/app/routers/AuthenticatedApp.tsx b/client/app/routers/AuthenticatedApp.tsx index c7a4823888b..5733bee789c 100644 --- a/client/app/routers/AuthenticatedApp.tsx +++ b/client/app/routers/AuthenticatedApp.tsx @@ -118,6 +118,7 @@ import { } from 'course/assessment/handles'; import GenerateProgrammingQuestionPage from 'course/assessment/pages/AssessmentGenerate/GenerateProgrammingQuestionPage'; import QuestionFormOutlet from 'course/assessment/question/components/QuestionFormOutlet'; +import PastAnswers from 'course/assessment/submission/pages/QuestionIndex/PastAnswers'; import { CourseContainer } from 'course/container'; import { commentHandle } from 'course/discussion/topics/handles'; import { @@ -638,6 +639,20 @@ const authenticatedRouter: Translated = (t) => }, ], }, + { + path: 'submission_questions', + children: [ + { + path: ':submissionQuestionId', + children: [ + { + path: 'past_answers', + element: , + }, + ], + }, + ], + }, { path: 'question', element: , diff --git a/config/routes.rb b/config/routes.rb index de104679e10..81064d91bfe 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -433,10 +433,11 @@ get 'answer/:id' => 'answers#question_answer_details' get 'assessment/:id/main_statistics' => 'assessments#main_statistics' get 'assessment/:id/ancestor_statistics' => 'assessments#ancestor_statistics' - get 'course/progression' => 'aggregate#course_progression' - get 'course/performance' => 'aggregate#course_performance' get 'students' => 'aggregate#all_students' get 'staff' => 'aggregate#all_staff' + get 'course/progression' => 'aggregate#course_progression' + get 'course/performance' => 'aggregate#course_performance' + get 'submission_question/:id' => 'answers#all_answers' get 'user/:user_id/learning_rate_records' => 'users#learning_rate_records' end From efee42d5bc9cca39d0a0e935f979ec17f485e93a Mon Sep 17 00:00:00 2001 From: bivanalhar Date: Wed, 21 Feb 2024 16:04:58 +0800 Subject: [PATCH 03/14] fix(attempt_count): count only graded answers --- .../course/statistics/submissions_concern.rb | 29 ++++++++++--------- 1 file changed, 15 insertions(+), 14 deletions(-) diff --git a/app/controllers/concerns/course/statistics/submissions_concern.rb b/app/controllers/concerns/course/statistics/submissions_concern.rb index 2a79e9c4999..ddf2fc478bf 100644 --- a/app/controllers/concerns/course/statistics/submissions_concern.rb +++ b/app/controllers/concerns/course/statistics/submissions_concern.rb @@ -25,16 +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, @@ -58,13 +48,24 @@ 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 @@ -72,8 +73,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 ) From 908f9f91a0d641c67439459dddb7388afce21d0f Mon Sep 17 00:00:00 2001 From: bivanalhar Date: Wed, 21 Feb 2024 16:08:17 +0800 Subject: [PATCH 04/14] refactor(statistics): minor refactoring - split pages for answerDisplay between attemptCount and marksPerQuestion - create URL for past answers - workflow state chip clickable to submission edit page - className for using tailwind color for workflow state chip --- .../assessments/_submission.json.jbuilder | 1 + .../AnswerDisplay/AllAttempts.tsx | 128 ++++++++++++++++++ .../LastAttempt.tsx} | 12 +- .../StudentAttemptCountTable.tsx | 34 ++--- .../StudentMarksPerQuestionTable.tsx | 32 +++-- client/app/lib/helpers/url-builders.js | 7 + client/app/lib/helpers/url-helpers.js | 8 ++ client/app/theme/palette.js | 8 ++ 8 files changed, 195 insertions(+), 35 deletions(-) create mode 100644 client/app/bundles/course/assessment/pages/AssessmentStatistics/AnswerDisplay/AllAttempts.tsx rename client/app/bundles/course/assessment/pages/AssessmentStatistics/{AnswerDisplay.tsx => AnswerDisplay/LastAttempt.tsx} (88%) diff --git a/app/views/course/statistics/assessments/_submission.json.jbuilder b/app/views/course/statistics/assessments/_submission.json.jbuilder index e4b77c3a54c..500567ec24b 100644 --- a/app/views/course/statistics/assessments/_submission.json.jbuilder +++ b/app/views/course/statistics/assessments/_submission.json.jbuilder @@ -2,6 +2,7 @@ if submission.nil? json.workflowState 'unstarted' else + json.id submission.id json.workflowState submission.workflow_state json.submittedAt submission.submitted_at&.iso8601 json.endAt end_at&.iso8601 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..33b12581bcb --- /dev/null +++ b/client/app/bundles/course/assessment/pages/AssessmentStatistics/AnswerDisplay/AllAttempts.tsx @@ -0,0 +1,128 @@ +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 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 { getPastAnswersURL } from 'lib/helpers/url-builders'; +import useTranslation from 'lib/hooks/useTranslation'; +import { formatLongDateTime } from 'lib/moment'; + +import AnswerDetails from '../AnswerDetails/AnswerDetails'; + +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: '#{index}) Submitted At: {submittedAt}', + }, +}); + +interface Props { + curAnswerId: number; + index: number; +} + +const MAX_DISPLAYED_ANSWERS = 10; + +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, + ); + + const currentAnswer = data.allAnswers.find( + (answer) => answer.currentAnswer, + ); + const displayedAnswers = data.allAnswers + .filter((answer) => !answer.currentAnswer) + .slice(0, MAX_DISPLAYED_ANSWERS - 1); + + return ( + <> + +
+ {data.question.title} + +
+
+ + + + {displayedAnswers.length > 0 && + displayedAnswers.map((answer, answerIndex) => ( + + + + ))} + + + {t(translations.morePastAnswers)} + + + + ); + }} +
+ ); +}; + +export default AllAttemptsIndex; 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 88% 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..4c45f259014 100644 --- a/client/app/bundles/course/assessment/pages/AssessmentStatistics/AnswerDisplay.tsx +++ b/client/app/bundles/course/assessment/pages/AssessmentStatistics/AnswerDisplay/LastAttempt.tsx @@ -10,8 +10,8 @@ import LoadingIndicator from 'lib/components/core/LoadingIndicator'; import Preload from 'lib/components/wrappers/Preload'; import useTranslation from 'lib/hooks/useTranslation'; -import AnswerDetails from './AnswerDetails/AnswerDetails'; -import { getClassNameForMarkCell } from './classNameUtils'; +import AnswerDetails from '../AnswerDetails/AnswerDetails'; +import { getClassNameForMarkCell } from '../classNameUtils'; const translations = defineMessages({ questionTitle: { @@ -22,6 +22,10 @@ const translations = defineMessages({ id: 'course.assessment.statistics.gradeDisplay', defaultMessage: 'Grade: {grade} / {maxGrade}', }, + morePastAnswers: { + id: 'course.assessment.statistics.morePastAnswers', + defaultMessage: 'View All Past Answers', + }, }); interface Props { @@ -29,7 +33,7 @@ interface Props { index: number; } -const AnswerDisplay: FC = (props) => { +const LastAttemptIndex: FC = (props) => { const { curAnswerId, index } = props; const { t } = useTranslation(); @@ -81,4 +85,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 93b05f0781f..0ebfb23e8ac 100644 --- a/client/app/bundles/course/assessment/pages/AssessmentStatistics/StudentAttemptCountTable.tsx +++ b/client/app/bundles/course/assessment/pages/AssessmentStatistics/StudentAttemptCountTable.tsx @@ -16,10 +16,11 @@ 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 TableLegends from 'lib/containers/TableLegends'; +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'; @@ -93,7 +94,7 @@ const statusTranslations = { const StudentAttemptCountTable: FC = (props) => { const { t } = useTranslation(); - const { courseId } = useParams(); + const { courseId, assessmentId } = useParams(); const { includePhantom } = props; const statistics = useAppSelector(getAssessmentStatistics); @@ -145,7 +146,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, }); }} @@ -245,19 +246,20 @@ const StudentAttemptCountTable: FC = (props) => { title: t(translations.workflowState), sortable: true, cell: (datum) => ( - + + ] + } + variant="filled" + /> + ), className: 'center', }, @@ -312,7 +314,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 9bb9e655b6f..0b6e9b37d37 100644 --- a/client/app/bundles/course/assessment/pages/AssessmentStatistics/StudentMarksPerQuestionTable.tsx +++ b/client/app/bundles/course/assessment/pages/AssessmentStatistics/StudentMarksPerQuestionTable.tsx @@ -12,10 +12,11 @@ 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 TableLegends from 'lib/containers/TableLegends'; +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'; @@ -88,7 +89,7 @@ const statusTranslations = { const StudentMarksPerQuestionTable: FC = (props) => { const { t } = useTranslation(); - const { courseId } = useParams(); + const { courseId, assessmentId } = useParams(); const { includePhantom } = props; const statistics = useAppSelector(getAssessmentStatistics); @@ -242,19 +243,20 @@ const StudentMarksPerQuestionTable: FC = (props) => { title: t(translations.workflowState), sortable: true, cell: (datum) => ( - + + ] + } + variant="filled" + /> + ), className: 'center', }, @@ -344,7 +346,7 @@ const StudentMarksPerQuestionTable: FC = (props) => { open={openAnswer} title={answerDisplayInfo.studentName} > - diff --git a/client/app/lib/helpers/url-builders.js b/client/app/lib/helpers/url-builders.js index 8ce9ec657f4..92431600662 100644 --- a/client/app/lib/helpers/url-builders.js +++ b/client/app/lib/helpers/url-builders.js @@ -35,6 +35,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_answers`; + 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/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], From 404688ff779c330a35e20f3f046b555ba4a1a21c Mon Sep 17 00:00:00 2001 From: bivanalhar Date: Sun, 25 Feb 2024 17:34:40 +0800 Subject: [PATCH 05/14] feat(statistics): add comment and link to past answers - provide link to past answers and also submission edit page in answer box and past answer page - Slider to show timeline of answers (from earliest to most recent one) - add comment component into answer box and past answer --- .../course/statistics/answers_controller.rb | 21 ++- .../answers/all_answers.json.jbuilder | 10 ++ .../question_answer_details.json.jbuilder | 8 + .../AnswerDisplay/AllAttempts.tsx | 74 +++------ .../AnswerDisplay/AllAttemptsDisplay.tsx | 155 ++++++++++++++++++ .../AnswerDisplay/Comment.tsx | 70 ++++++++ .../AnswerDisplay/LastAttempt.tsx | 24 +++ .../pages/QuestionIndex/PastAnswers.tsx | 87 ---------- .../pages/QuestionIndex/PastAttempts.tsx | 50 ++++++ client/app/lib/helpers/url-builders.js | 10 +- client/app/routers/AuthenticatedApp.tsx | 6 +- .../course/statistics/assessmentStatistics.ts | 20 ++- 12 files changed, 390 insertions(+), 145 deletions(-) create mode 100644 client/app/bundles/course/assessment/pages/AssessmentStatistics/AnswerDisplay/AllAttemptsDisplay.tsx create mode 100644 client/app/bundles/course/assessment/pages/AssessmentStatistics/AnswerDisplay/Comment.tsx delete mode 100644 client/app/bundles/course/assessment/submission/pages/QuestionIndex/PastAnswers.tsx create mode 100644 client/app/bundles/course/assessment/submission/pages/QuestionIndex/PastAttempts.tsx diff --git a/app/controllers/course/statistics/answers_controller.rb b/app/controllers/course/statistics/answers_controller.rb index 1e6cb458eca..f9f8c06236e 100644 --- a/app/controllers/course/statistics/answers_controller.rb +++ b/app/controllers/course/statistics/answers_controller.rb @@ -18,17 +18,21 @@ def question_answer_details def all_answers @submission_question = Course::Assessment::SubmissionQuestion.find(submission_question_params[:id]) - submission_id = @submission_question.submission_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: :desc). - where(submission_id: submission_id, question_id: question_id) + unscope(:order). + order(:created_at). + where(submission_id: submission_id, question_id: question_id) end private @@ -41,6 +45,15 @@ 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). diff --git a/app/views/course/statistics/answers/all_answers.json.jbuilder b/app/views/course/statistics/answers/all_answers.json.jbuilder index 279e9d8315b..e59345f13c8 100644 --- a/app/views/course/statistics/answers/all_answers.json.jbuilder +++ b/app/views/course/statistics/answers/all_answers.json.jbuilder @@ -5,6 +5,7 @@ json.question do 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 @@ -13,4 +14,13 @@ 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 aa40111a983..4b19f721d01 100644 --- a/app/views/course/statistics/answers/question_answer_details.json.jbuilder +++ b/app/views/course/statistics/answers/question_answer_details.json.jbuilder @@ -19,6 +19,14 @@ 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/client/app/bundles/course/assessment/pages/AssessmentStatistics/AnswerDisplay/AllAttempts.tsx b/client/app/bundles/course/assessment/pages/AssessmentStatistics/AnswerDisplay/AllAttempts.tsx index 33b12581bcb..cde6b2033de 100644 --- a/client/app/bundles/course/assessment/pages/AssessmentStatistics/AnswerDisplay/AllAttempts.tsx +++ b/client/app/bundles/course/assessment/pages/AssessmentStatistics/AnswerDisplay/AllAttempts.tsx @@ -6,15 +6,17 @@ 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 { getPastAnswersURL } from 'lib/helpers/url-builders'; +import { + getEditSubmissionQuestionURL, + getPastAnswersURL, +} from 'lib/helpers/url-builders'; import useTranslation from 'lib/hooks/useTranslation'; -import { formatLongDateTime } from 'lib/moment'; -import AnswerDetails from '../AnswerDetails/AnswerDetails'; +import AllAttemptsDisplay from './AllAttemptsDisplay'; +import Comment from './Comment'; const translations = defineMessages({ questionTitle: { @@ -35,7 +37,11 @@ const translations = defineMessages({ }, pastAnswerTitle: { id: 'course.assessment.statistics.pastAnswerTitle', - defaultMessage: '#{index}) Submitted At: {submittedAt}', + defaultMessage: 'Submitted At: {submittedAt}', + }, + submissionPage: { + id: 'course.assessment.statistics.submissionPage', + defaultMessage: 'Go to Answer Page', }, }); @@ -44,8 +50,6 @@ interface Props { index: number; } -const MAX_DISPLAYED_ANSWERS = 10; - const AllAttemptsIndex: FC = (props) => { const { curAnswerId, index } = props; const { t } = useTranslation(); @@ -69,55 +73,27 @@ const AllAttemptsIndex: FC = (props) => { data.submissionQuestionId, ); - const currentAnswer = data.allAnswers.find( - (answer) => answer.currentAnswer, - ); - const displayedAnswers = data.allAnswers - .filter((answer) => !answer.currentAnswer) - .slice(0, MAX_DISPLAYED_ANSWERS - 1); - return ( <> - -
- {data.question.title} - -
-
- - - - {displayedAnswers.length > 0 && - displayedAnswers.map((answer, answerIndex) => ( - - - - ))} + + {t(translations.morePastAnswers)} + + {data.comments.length > 0 && } ); }} 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/LastAttempt.tsx b/client/app/bundles/course/assessment/pages/AssessmentStatistics/AnswerDisplay/LastAttempt.tsx index 4c45f259014..7481e9f95ce 100644 --- a/client/app/bundles/course/assessment/pages/AssessmentStatistics/AnswerDisplay/LastAttempt.tsx +++ b/client/app/bundles/course/assessment/pages/AssessmentStatistics/AnswerDisplay/LastAttempt.tsx @@ -1,18 +1,23 @@ 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 Comment from './Comment'; + const translations = defineMessages({ questionTitle: { id: 'course.assessment.statistics.questionTitle', @@ -26,6 +31,10 @@ const translations = defineMessages({ id: 'course.assessment.statistics.morePastAnswers', defaultMessage: 'View All Past Answers', }, + submissionPage: { + id: 'course.assessment.statistics.submissionPage', + defaultMessage: 'Go to Answer Page', + }, }); interface Props { @@ -35,6 +44,7 @@ interface Props { const LastAttemptIndex: FC = (props) => { const { curAnswerId, index } = props; + const { courseId, assessmentId } = useParams(); const { t } = useTranslation(); const fetchQuestionAndCurrentAnswerDetails = (): Promise< @@ -55,6 +65,19 @@ const LastAttemptIndex: FC = (props) => { ); return ( <> + + + {t(translations.submissionPage)} + + = (props) => { })} variant="filled" /> + {data.comments.length > 0 && } ); }} diff --git a/client/app/bundles/course/assessment/submission/pages/QuestionIndex/PastAnswers.tsx b/client/app/bundles/course/assessment/submission/pages/QuestionIndex/PastAnswers.tsx deleted file mode 100644 index d99dcc211c0..00000000000 --- a/client/app/bundles/course/assessment/submission/pages/QuestionIndex/PastAnswers.tsx +++ /dev/null @@ -1,87 +0,0 @@ -import { FC } from 'react'; -import { defineMessages } from 'react-intl'; -import { QuestionType } from 'types/course/assessment/question'; -import { QuestionAllAnswerDisplayDetails } from 'types/course/statistics/assessmentStatistics'; - -import { fetchAllAnswers } from 'course/assessment/operations/statistics'; -import AnswerDetails from 'course/assessment/pages/AssessmentStatistics/AnswerDetails/AnswerDetails'; -import Accordion from 'lib/components/core/layouts/Accordion'; -import LoadingIndicator from 'lib/components/core/LoadingIndicator'; -import Preload from 'lib/components/wrappers/Preload'; -import { getSubmissionQuestionId } from 'lib/helpers/url-helpers'; -import useTranslation from 'lib/hooks/useTranslation'; -import { formatLongDateTime } from 'lib/moment'; - -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: '#{index}) Submitted At: {submittedAt}', - }, -}); - -const PastAnswers: FC = () => { - const submissionQuestionId = getSubmissionQuestionId(); - const { t } = useTranslation(); - if (!submissionQuestionId) { - return null; - } - - const parsedSubmissionQuestionId = parseInt(submissionQuestionId, 10); - - const fetchAnswers = (): Promise< - QuestionAllAnswerDisplayDetails - > => { - return fetchAllAnswers(parsedSubmissionQuestionId); - }; - - return ( - } while={fetchAnswers}> - {(data): JSX.Element => { - const { question, allAnswers } = data; - const currentAnswer = allAnswers.find((answer) => answer.currentAnswer); - const otherAnswers = allAnswers.filter( - (answer) => !answer.currentAnswer, - ); - - return ( - <> - - - - {otherAnswers.length > 0 && - otherAnswers.map((answer, index) => ( - - - - ))} - - ); - }} - - ); -}; -export default PastAnswers; 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 92431600662..66656961d30 100644 --- a/client/app/lib/helpers/url-builders.js +++ b/client/app/lib/helpers/url-builders.js @@ -15,6 +15,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`; @@ -40,7 +48,7 @@ export const getPastAnswersURL = ( assessmentId, submissionQuestionId, ) => - `/courses/${courseId}/assessments/${assessmentId}/submission_questions/${submissionQuestionId}/past_answers`; + `/courses/${courseId}/assessments/${assessmentId}/submission_questions/${submissionQuestionId}/past_attempts`; export const getAssessmentSubmissionURL = (courseId, assessmentId) => `/courses/${courseId}/assessments/${assessmentId}/submissions`; diff --git a/client/app/routers/AuthenticatedApp.tsx b/client/app/routers/AuthenticatedApp.tsx index 5733bee789c..ed5ab11e8ff 100644 --- a/client/app/routers/AuthenticatedApp.tsx +++ b/client/app/routers/AuthenticatedApp.tsx @@ -118,7 +118,7 @@ import { } from 'course/assessment/handles'; import GenerateProgrammingQuestionPage from 'course/assessment/pages/AssessmentGenerate/GenerateProgrammingQuestionPage'; import QuestionFormOutlet from 'course/assessment/question/components/QuestionFormOutlet'; -import PastAnswers from 'course/assessment/submission/pages/QuestionIndex/PastAnswers'; +import PastAttempts from 'course/assessment/submission/pages/QuestionIndex/PastAttempts'; import { CourseContainer } from 'course/container'; import { commentHandle } from 'course/discussion/topics/handles'; import { @@ -646,8 +646,8 @@ const authenticatedRouter: Translated = (t) => path: ':submissionQuestionId', children: [ { - path: 'past_answers', - element: , + path: 'past_attempts', + element: , }, ], }, diff --git a/client/app/types/course/statistics/assessmentStatistics.ts b/client/app/types/course/statistics/assessmentStatistics.ts index f4cbf352ec8..9d2d39c8fe9 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'; @@ -90,19 +91,34 @@ 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 }; + AnswerDetailsMap[T] & { + createdAt: Date; + currentAnswer: boolean; + workflowState: WorkflowState; + }; export interface QuestionAnswerDetails { question: QuestionDetails; answer: AnswerDetailsMap[T]; allAnswers: AllAnswerDetails[]; + comments: CommentItem[]; + submissionId: number; submissionQuestionId: number; } @@ -118,4 +134,6 @@ export interface QuestionAllAnswerDisplayDetails< > { question: QuestionDetails; allAnswers: AllAnswerDetails[]; + submissionId: number; + comments: CommentItem[]; } From ca331c8928b35f762e281dd463e00ce62e70318e Mon Sep 17 00:00:00 2001 From: bivanalhar Date: Sun, 25 Feb 2024 17:35:29 +0800 Subject: [PATCH 06/14] refactor(test-cases): refurnish test cases indicator chip - differentiate between all passed, some passed and none passed - give more information for how many test cases passed - fix all indentation and formatting error in BE code --- .../ProgrammingComponent/TestCases.tsx | 155 ++++++++++++++---- .../containers/TestCaseView/index.jsx | 101 ++++++++++-- 2 files changed, 204 insertions(+), 52 deletions(-) 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..7963e767d42 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,98 @@ 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 isProgrammingAnswerEvaluated = + testCaseResults.filter((result) => !!result.output).length > 0; + + 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 (!isProgrammingAnswerEvaluated) { + return
; + } + + if (numPassedTestCases === numTestCases) { + return ; + } + + if (numPassedTestCases > 0) { + return ; + } + + return ; + }; + + const testCaseComponentClassName = (): string => { + if (!isProgrammingAnswerEvaluated) { + return ''; + } + + 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 +196,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.public_test && testCase.public_test.length > 0 && ( + + )} + + {testCase.private_test && testCase.private_test.length > 0 && ( + + )} - {testCase.private_test && - testCase.private_test.length > 0 && - TestCaseComponent(testCase.private_test, 'privateTestCases')} + {testCase.evaluation_test && testCase.evaluation_test.length > 0 && ( + + )} - {testCase.evaluation_test && - testCase.evaluation_test.length > 0 && - TestCaseComponent(testCase.evaluation_test, 'evaluationTestCases')} + - {OutputStream('standardOutput', testCase.stdout)} - {OutputStream('standardError', testCase.stderr)} +
); }; 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..f31c4e8be48 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,88 @@ export class VisibleTestCaseView extends Component { return null; } - const passedTestCases = testCases.reduce( - (passed, testCase) => passed && testCase?.passed, - true, + const isProgrammingAnswerEvaluated = + testCases.filter((testCase) => !!testCase.output).length > 0; + + const numPassedTestCases = testCases.filter( + (testCase) => testCase.passed, + ).length; + + const AllTestCasesPassedChip = () => ( + } + label={} + size="small" + variant="outlined" + /> + ); + + const SomeTestCasesPassedChip = () => ( + + } + size="small" + variant="outlined" + /> ); - const shouldShowAllPassed = !isDraftAnswer && passedTestCases; + const NoTestCasesPassedChip = () => ( + } + label={} + size="small" + variant="outlined" + /> + ); + + const TestCasesIndicatorChip = () => { + if (!isProgrammingAnswerEvaluated) { + return
; + } + + if (numPassedTestCases === testCases.length) { + return ; + } + + if (numPassedTestCases > 0) { + return ; + } + + return ; + }; + + const testCaseComponentClassName = () => { + if (!isProgrammingAnswerEvaluated) { + return ''; + } + + 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 && From 7af71a6a6f551d87afdbabdcdcf26f96fe6009a2 Mon Sep 17 00:00:00 2001 From: bivanalhar Date: Wed, 6 Mar 2024 13:41:17 +0800 Subject: [PATCH 07/14] test(stats_answer): modify answers controller spec - test for API call question_answer_details - and also all_answers --- .../statistics/answers_controller_spec.rb | 64 +++++++++++++++++++ 1 file changed, 64 insertions(+) diff --git a/spec/controllers/course/statistics/answers_controller_spec.rb b/spec/controllers/course/statistics/answers_controller_spec.rb index d28ab8c42cb..05f9f4dd794 100644 --- a/spec/controllers/course/statistics/answers_controller_spec.rb +++ b/spec/controllers/course/statistics/answers_controller_spec.rb @@ -12,6 +12,9 @@ end let(:submission) { create(:submission, :graded, assessment: assessment, creator: course_user.user) } 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 @@ -39,6 +42,12 @@ expect(json_result['question']['id']).to eq(answer.question.id) expect(json_result['answer']['grade'].to_f).to eq(answer.grade) + + # 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 @@ -52,6 +61,61 @@ expect(json_result['question']['id']).to eq(answer.question.id) expect(json_result['answer']['grade'].to_f).to eq(answer.grade) + + # 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, format: :json, params: { course_id: course, id: submission_question.id } } + + context 'when the Normal User get the question answer details for the statistics' do + let(:user) { create(:user) } + before { controller_sign_in(controller, user) } + it { expect { subject }.to raise_exception(CanCan::AccessDenied) } + end + + context 'when the Course Student get the question answer details for the statistics' do + let(:user) { create(:course_student, course: course).user } + before { controller_sign_in(controller, user) } + it { expect { subject }.to raise_exception(CanCan::AccessDenied) } + end + + context 'when the Course Manager get the question answer details for the statistics' do + let(:user) { create(:course_manager, course: course).user } + before { controller_sign_in(controller, user) } + + it 'returns OK with right question id and answer grade being displayed' 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 administrator get the question answer details for the statistics' do + let(:administrator) { create(:administrator) } + before { controller_sign_in(controller, administrator) } + + it 'returns OK with right question id and answer grade being displayed' 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 From 9ebb0428fae15a04d1dcad798f619c0dbc03b438 Mon Sep 17 00:00:00 2001 From: bivanalhar Date: Thu, 5 Sep 2024 00:09:38 +0800 Subject: [PATCH 08/14] feat(Prompt): customisable maxWidth - for Answer Details, need to use 'lg' to display annotated comments as well --- .../pages/AssessmentStatistics/StudentAttemptCountTable.tsx | 1 + .../pages/AssessmentStatistics/StudentMarksPerQuestionTable.tsx | 1 + client/app/lib/components/core/dialogs/Prompt.tsx | 2 ++ 3 files changed, 4 insertions(+) diff --git a/client/app/bundles/course/assessment/pages/AssessmentStatistics/StudentAttemptCountTable.tsx b/client/app/bundles/course/assessment/pages/AssessmentStatistics/StudentAttemptCountTable.tsx index 0ebfb23e8ac..ccf5ab4da3f 100644 --- a/client/app/bundles/course/assessment/pages/AssessmentStatistics/StudentAttemptCountTable.tsx +++ b/client/app/bundles/course/assessment/pages/AssessmentStatistics/StudentAttemptCountTable.tsx @@ -310,6 +310,7 @@ const StudentAttemptCountTable: FC = (props) => { /> setOpenPastAnswers(false)} 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 0b6e9b37d37..5ff43a2d8e1 100644 --- a/client/app/bundles/course/assessment/pages/AssessmentStatistics/StudentMarksPerQuestionTable.tsx +++ b/client/app/bundles/course/assessment/pages/AssessmentStatistics/StudentMarksPerQuestionTable.tsx @@ -342,6 +342,7 @@ const StudentMarksPerQuestionTable: FC = (props) => { /> setOpenAnswer(false)} open={openAnswer} title={answerDisplayInfo.studentName} diff --git a/client/app/lib/components/core/dialogs/Prompt.tsx b/client/app/lib/components/core/dialogs/Prompt.tsx index 9a7799424a7..7b95b852dc3 100644 --- a/client/app/lib/components/core/dialogs/Prompt.tsx +++ b/client/app/lib/components/core/dialogs/Prompt.tsx @@ -19,6 +19,7 @@ interface BasePromptProps { onClosed?: () => void; disabled?: boolean; contentClassName?: string; + maxWidth?: 'xs' | 'sm' | 'md' | 'lg' | 'xl'; } type DefaultActionProps = { @@ -66,6 +67,7 @@ const Prompt = (props: PromptProps): JSX.Element => { return ( Date: Thu, 5 Sep 2024 01:14:18 +0800 Subject: [PATCH 09/14] feat(stats): include annotations for answer display - after annotation is loaded, set into Redux to reduce prop drilling --- .../course/statistics/answers_controller.rb | 4 +- .../statistics/answers/_answer.json.jbuilder | 11 +++++ .../ProgrammingAnswerDetails.tsx | 15 ++++++- .../ReadOnlyEditor/NarrowEditor.jsx | 41 ++++++++----------- .../ReadOnlyEditor/WideComments.jsx | 3 ++ .../components/ReadOnlyEditor/WideEditor.jsx | 23 +++++++---- .../components/comment/CommentCard.jsx | 7 +++- .../course/assessment/submission/constants.ts | 3 ++ .../submission/containers/Annotations.jsx | 7 +++- .../submission/containers/Comments.jsx | 1 + .../assessment/submission/reducers/posts.js | 1 + client/app/types/course/statistics/answer.ts | 16 ++++++++ 12 files changed, 95 insertions(+), 37 deletions(-) diff --git a/app/controllers/course/statistics/answers_controller.rb b/app/controllers/course/statistics/answers_controller.rb index f9f8c06236e..30dc6a90068 100644 --- a/app/controllers/course/statistics/answers_controller.rb +++ b/app/controllers/course/statistics/answers_controller.rb @@ -11,7 +11,9 @@ def question_answer_details @submission_question = Course::Assessment::SubmissionQuestion. where(submission_id: @answer.submission_id, question_id: @answer.question_id). - includes({ discussion_topic: :posts }).first + includes(actable: { files: { annotations: + { discussion_topic: { posts: :codaveri_feedback } } } }, + discussion_topic: :posts).first fetch_all_answers(@answer.submission_id, @answer.question_id) end diff --git a/app/views/course/statistics/answers/_answer.json.jbuilder b/app/views/course/statistics/answers/_answer.json.jbuilder index b7f7679a3ce..b00e6a00ca1 100644 --- a/app/views/course/statistics/answers/_answer.json.jbuilder +++ b/app/views/course/statistics/answers/_answer.json.jbuilder @@ -5,3 +5,14 @@ json.id answer.id json.grade answer.grade json.questionType question.question_type json.partial! specific_answer, answer: specific_answer, can_grade: false + +if answer.actable_type == Course::Assessment::Answer::Programming.name + files = answer.specific.files + json.partial! 'course/assessment/answer/programming/annotations', programming_files: files, + can_grade: false + posts = files.flat_map(&:annotations).map(&:discussion_topic).flat_map(&:posts) + + json.posts posts do |post| + json.partial! post, post: post if post.published? + end +end 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 299523136e0..e278e976e60 100644 --- a/client/app/bundles/course/assessment/pages/AssessmentStatistics/AnswerDetails/ProgrammingAnswerDetails.tsx +++ b/client/app/bundles/course/assessment/pages/AssessmentStatistics/AnswerDetails/ProgrammingAnswerDetails.tsx @@ -1,6 +1,10 @@ +import { useEffect } from 'react'; import { Annotation } from 'types/course/statistics/answer'; import { QuestionAnswerDisplayDetails } from 'types/course/statistics/assessmentStatistics'; +import actionTypes from 'course/assessment/submission/constants'; +import { useAppDispatch } from 'lib/hooks/store'; + import CodaveriFeedbackStatus from './ProgrammingComponent/CodaveriFeedbackStatus'; import FileContent from './ProgrammingComponent/FileContent'; import TestCases from './ProgrammingComponent/TestCases'; @@ -9,7 +13,16 @@ const ProgrammingAnswerDetails = ( props: QuestionAnswerDisplayDetails<'Programming'>, ): JSX.Element => { const { answer } = props; - const annotations = answer.latestAnswer?.annotations ?? ([] as Annotation[]); + const annotations = answer.annotations ?? ([] as Annotation[]); + + const dispatch = useAppDispatch(); + + useEffect(() => { + dispatch({ + type: actionTypes.FETCH_ANNOTATION_SUCCESS, + payload: { posts: answer.posts }, + }); + }); return ( <> diff --git a/client/app/bundles/course/assessment/submission/components/ReadOnlyEditor/NarrowEditor.jsx b/client/app/bundles/course/assessment/submission/components/ReadOnlyEditor/NarrowEditor.jsx index ed3e3c9f37b..e2b76258b5b 100644 --- a/client/app/bundles/course/assessment/submission/components/ReadOnlyEditor/NarrowEditor.jsx +++ b/client/app/bundles/course/assessment/submission/components/ReadOnlyEditor/NarrowEditor.jsx @@ -99,6 +99,7 @@ const LineNumberColumn = (props) => { annotation={annotation} answerId={answerId} fileId={fileId} + isUpdatingAnnotationAllowed={isUpdatingAnnotationAllowed} lineNumber={lineNumber} />
@@ -111,34 +112,28 @@ const LineNumberColumn = (props) => { return ( <> - {isUpdatingAnnotationAllowed ? ( -
toggleComment(lineNumber)} - onMouseOut={() => setLineHovered(0)} - onMouseOver={() => setLineHovered(lineNumber)} - style={ - annotation - ? styles.editorLineNumberWithComments - : styles.editorLineNumber +
{ + if (annotation || isUpdatingAnnotationAllowed) { + toggleComment(lineNumber); } - > -
{lineNumber}
+ }} + onMouseOut={() => setLineHovered(0)} + onMouseOver={() => setLineHovered(lineNumber)} + style={ + annotation + ? styles.editorLineNumberWithComments + : styles.editorLineNumber + } + > +
{lineNumber}
+ {(annotation || isUpdatingAnnotationAllowed) && ( expandComment(lineNumber)} /> -
- ) : ( -
-
{lineNumber}
-
- )} + )} +
{renderComments()} diff --git a/client/app/bundles/course/assessment/submission/components/ReadOnlyEditor/WideComments.jsx b/client/app/bundles/course/assessment/submission/components/ReadOnlyEditor/WideComments.jsx index 321c08999aa..8f7d011f219 100644 --- a/client/app/bundles/course/assessment/submission/components/ReadOnlyEditor/WideComments.jsx +++ b/client/app/bundles/course/assessment/submission/components/ReadOnlyEditor/WideComments.jsx @@ -33,6 +33,7 @@ export default class WideComments extends Component { expandLine, collapseLine, onClick, + isUpdatingAnnotationAllowed, } = this.props; if (expanded[lineNumber - 1]) { @@ -60,6 +61,7 @@ export default class WideComments extends Component { annotation={annotation} answerId={answerId} fileId={fileId} + isUpdatingAnnotationAllowed={isUpdatingAnnotationAllowed} lineNumber={lineNumber} />
@@ -103,6 +105,7 @@ WideComments.propTypes = { expandLine: PropTypes.func, collapseLine: PropTypes.func, onClick: PropTypes.func, + isUpdatingAnnotationAllowed: PropTypes.bool, }; WideComments.defaultProps = { diff --git a/client/app/bundles/course/assessment/submission/components/ReadOnlyEditor/WideEditor.jsx b/client/app/bundles/course/assessment/submission/components/ReadOnlyEditor/WideEditor.jsx index 60a2d4e4478..a3d26724870 100644 --- a/client/app/bundles/course/assessment/submission/components/ReadOnlyEditor/WideEditor.jsx +++ b/client/app/bundles/course/assessment/submission/components/ReadOnlyEditor/WideEditor.jsx @@ -75,8 +75,14 @@ export default class WideEditor extends Component { renderComments() { const { activeComment } = this.state; - const { answerId, fileId, expanded, annotations, collapseLine } = - this.props; + const { + answerId, + fileId, + expanded, + annotations, + collapseLine, + isUpdatingAnnotationAllowed, + } = this.props; return ( this.expandComment(lineNumber)} fileId={fileId} + isUpdatingAnnotationAllowed={isUpdatingAnnotationAllowed} onClick={(lineNumber) => this.setState({ activeComment: lineNumber })} /> ); @@ -143,7 +150,7 @@ export default class WideEditor extends Component { renderLineNumberColumn(lineNumber) { const { lineHovered } = this.state; - const { annotations, isUpdatingAnnotationAllowed } = this.props; + const { annotations } = this.props; const annotation = annotations.find((a) => a.line === lineNumber); return ( @@ -158,12 +165,10 @@ export default class WideEditor extends Component { } > {lineNumber} - {isUpdatingAnnotationAllowed && ( - this.expandComment(lineNumber)} - /> - )} + this.expandComment(lineNumber)} + /> ); } diff --git a/client/app/bundles/course/assessment/submission/components/comment/CommentCard.jsx b/client/app/bundles/course/assessment/submission/components/comment/CommentCard.jsx index 47132cb07fe..670f2a8b70d 100644 --- a/client/app/bundles/course/assessment/submission/components/comment/CommentCard.jsx +++ b/client/app/bundles/course/assessment/submission/components/comment/CommentCard.jsx @@ -171,6 +171,8 @@ export default class CommentCard extends Component { isDelayed, } = this.props.post; + const { isUpdatingAnnotationAllowed } = this.props; + return (
@@ -185,7 +187,7 @@ export default class CommentCard extends Component { titleTypographyProps={{ display: 'block', marginright: 20 }} />
- {canUpdate ? ( + {canUpdate && isUpdatingAnnotationAllowed ? ( ) : null} - {canDestroy ? ( + {canDestroy && isUpdatingAnnotationAllowed ? (