diff --git a/app/controllers/concerns/course/statistics/live_feedback_concern.rb b/app/controllers/concerns/course/statistics/live_feedback_concern.rb new file mode 100644 index 00000000000..ef40865d8f8 --- /dev/null +++ b/app/controllers/concerns/course/statistics/live_feedback_concern.rb @@ -0,0 +1,62 @@ +# frozen_string_literal: true +module Course::Statistics::LiveFeedbackConcern + private + + def find_submitter_course_user(submission) + submission.creator.course_users.find { |u| u.course_id == @assessment.course_id } + end + + def find_live_feedback_course_user(live_feedback) + live_feedback.creator.course_users.find { |u| u.course_id == @assessment.course_id } + end + + def initialize_feedback_count + Array.new(@question_order_hash.size, 0) + end + + def find_user_assessment_live_feedback(submitter_course_user, assessment_live_feedbacks) + assessment_live_feedbacks.select do |live_feedback| + find_live_feedback_course_user(live_feedback) == submitter_course_user + end + end + + def update_feedback_count(feedback_count, user_assessment_live_feedback) + user_assessment_live_feedback.each do |feedback| + index = @ordered_questions.index(feedback.question_id) + feedback.code.each do |code| + unless code.comments.empty? + feedback_count[index] += 1 + break + end + end + end + end + + def initialize_student_hash(students) + students.to_h { |student| [student, nil] } + end + + def fetch_hash_for_live_feedback_assessment(submissions, assessment_live_feedbacks) + students = @all_students + student_hash = initialize_student_hash(students) + + populate_hash(submissions, student_hash, assessment_live_feedbacks) + student_hash + end + + def populate_hash(submissions, student_hash, assessment_live_feedbacks) + submissions.each do |submission| + submitter_course_user = find_submitter_course_user(submission) + next unless submitter_course_user&.student? + + feedback_count = initialize_feedback_count + + user_assessment_live_feedback = find_user_assessment_live_feedback(submitter_course_user, + assessment_live_feedbacks) + + update_feedback_count(feedback_count, user_assessment_live_feedback) + + student_hash[submitter_course_user] = [submission, feedback_count] + end + end +end diff --git a/app/controllers/course/assessment/submission/live_feedback_controller.rb b/app/controllers/course/assessment/submission/live_feedback_controller.rb new file mode 100644 index 00000000000..2ff32fa8a4d --- /dev/null +++ b/app/controllers/course/assessment/submission/live_feedback_controller.rb @@ -0,0 +1,21 @@ +# frozen_string_literal: true + +class Course::Assessment::Submission::LiveFeedbackController < + Course::Assessment::Submission::Controller + def save_live_feedback + live_feedback = Course::Assessment::LiveFeedback.find_by(id: params[:live_feedback_id]) + return head :bad_request if live_feedback.nil? + + feedback_files = params[:feedback_files] + feedback_files.each do |file| + filename = file[:path] + file[:feedbackLines].each do |feedback_line| + Course::Assessment::LiveFeedbackComment.create( + code_id: live_feedback.code.find_by(filename: filename).id, + line_number: feedback_line[:linenum], + comment: feedback_line[:feedback] + ) + end + end + end +end diff --git a/app/controllers/course/assessment/submission/submissions_controller.rb b/app/controllers/course/assessment/submission/submissions_controller.rb index 0a0d8fef400..46a31e80539 100644 --- a/app/controllers/course/assessment/submission/submissions_controller.rb +++ b/app/controllers/course/assessment/submission/submissions_controller.rb @@ -1,5 +1,5 @@ # frozen_string_literal: true -class Course::Assessment::Submission::SubmissionsController < \ +class Course::Assessment::Submission::SubmissionsController < # rubocop:disable Metrics/ClassLength Course::Assessment::Submission::Controller include Course::Assessment::Submission::SubmissionsControllerServiceConcern include Signals::EmissionConcern @@ -103,6 +103,21 @@ def generate_live_feedback response_status, response_body = @answer.generate_live_feedback response_body['feedbackUrl'] = ENV.fetch('CODAVERI_URL') + live_feedback = Course::Assessment::LiveFeedback.create_with_codes( + @submission.assessment_id, + @answer.question_id, + @submission.creator, + response_body['transactionId'], + @answer.actable.files + ) + + if response_status == 200 + params[:live_feedback_id] = live_feedback.id + params[:feedback_files] = response_body['data']['feedbackFiles'] + save_live_feedback + end + + response_body['liveFeedbackId'] = live_feedback.id render json: response_body, status: response_status end diff --git a/app/controllers/course/statistics/assessments_controller.rb b/app/controllers/course/statistics/assessments_controller.rb index 9ce717d7f8c..e6db1756f67 100644 --- a/app/controllers/course/statistics/assessments_controller.rb +++ b/app/controllers/course/statistics/assessments_controller.rb @@ -3,10 +3,12 @@ class Course::Statistics::AssessmentsController < Course::Statistics::Controller include Course::UsersHelper include Course::Statistics::SubmissionsConcern include Course::Statistics::UsersConcern + include Course::Statistics::LiveFeedbackConcern def main_statistics @assessment = Course::Assessment.where(id: assessment_params[:id]). calculated(:maximum_grade, :question_count). + includes(programming_questions: [:language]). preload(course: :course_users).first submissions = Course::Assessment::Submission.where(assessment_id: assessment_params[:id]). calculated(:grade, :grader_ids). @@ -35,8 +37,36 @@ def ancestor_statistics @student_submissions_hash = fetch_hash_for_ancestor_assessment(submissions, @all_students).compact end + def live_feedback_statistics + @assessment = Course::Assessment.where(id: assessment_params[:id]). + calculated(:question_count). + preload(course: :course_users).first + submissions = Course::Assessment::Submission.where(assessment_id: assessment_params[:id]). + preload(creator: :course_users) + assessment_live_feedbacks = Course::Assessment::LiveFeedback.where(assessment_id: assessment_params[:id]). + preload(:creator, creator: :course_users, code: :comments) + + @course_users_hash = preload_course_users_hash(@assessment.course) + + load_course_user_students_info + load_ordered_questions + + @student_live_feedback_hash = fetch_hash_for_live_feedback_assessment(submissions, + assessment_live_feedbacks) + end + + def live_feedback_history + fetch_live_feedbacks + @live_feedback_details_hash = build_live_feedback_details_hash(@live_feedbacks) + @question = Course::Assessment::Question.find(params[:question_id]) + end + private + def load_ordered_questions + @ordered_questions = create_question_order_hash.keys.sort_by { |question_id| @question_order_hash[question_id] } + end + def assessment_params params.permit(:id) end @@ -59,11 +89,44 @@ def fetch_all_ancestor_assessments end def create_question_related_hash + create_question_order_hash + @question_hash = @assessment.questions.to_h do |q| + [q.id, [q.maximum_grade, q.question_type, q.auto_gradable?]] + end + end + + def create_question_order_hash @question_order_hash = @assessment.question_assessments.to_h do |q| [q.question_id, q.weight] end - @question_hash = @assessment.questions.to_h do |q| - [q.id, [q.maximum_grade, q.question_type, q.auto_gradable?]] + end + + def fetch_live_feedbacks + user = current_course.course_users.find_by(id: params[:course_user_id]).user + @live_feedbacks = Course::Assessment::LiveFeedback.where(assessment_id: assessment_params[:id], + creator: user, + question_id: params[:question_id]). + order(created_at: :asc).includes(:code, code: :comments) + end + + def build_live_feedback_details_hash(live_feedbacks) + live_feedbacks.each_with_object({}) do |live_feedback, hash| + hash[live_feedback.id] = live_feedback.code.map do |code| + { + code: { + id: code.id, + filename: code.filename, + content: code.content + }, + comments: code.comments.map do |comment| + { + id: comment.id, + line_number: comment.line_number, + comment: comment.comment + } + end + } + end end end end diff --git a/app/models/course/assessment.rb b/app/models/course/assessment.rb index d3f8b81c9bb..67dc4a4fa8d 100644 --- a/app/models/course/assessment.rb +++ b/app/models/course/assessment.rb @@ -72,6 +72,8 @@ class Course::Assessment < ApplicationRecord inverse_of: :assessment, dependent: :destroy has_one :duplication_traceable, class_name: 'DuplicationTraceable::Assessment', inverse_of: :assessment, dependent: :destroy + has_many :live_feedbacks, class_name: 'Course::Assessment::LiveFeedback', + inverse_of: :assessment, dependent: :destroy validate :tab_in_same_course validate :selected_test_type_for_grading diff --git a/app/models/course/assessment/assessment_ability.rb b/app/models/course/assessment/assessment_ability.rb index 59f95bd6918..8fa54ac0849 100644 --- a/app/models/course/assessment/assessment_ability.rb +++ b/app/models/course/assessment/assessment_ability.rb @@ -75,7 +75,8 @@ def allow_read_material def allow_create_assessment_submission can :create, Course::Assessment::Submission, experience_points_record: { course_user: { user_id: user.id } } - can [:update, :generate_live_feedback], Course::Assessment::Submission, assessment_submission_attempting_hash(user) + can [:update, :generate_live_feedback, :save_live_feedback], Course::Assessment::Submission, + assessment_submission_attempting_hash(user) end def allow_update_own_assessment_answer diff --git a/app/models/course/assessment/live_feedback.rb b/app/models/course/assessment/live_feedback.rb new file mode 100644 index 00000000000..18c5fcc48c5 --- /dev/null +++ b/app/models/course/assessment/live_feedback.rb @@ -0,0 +1,38 @@ +# frozen_string_literal: true +class Course::Assessment::LiveFeedback < ApplicationRecord + belongs_to :assessment, class_name: 'Course::Assessment', foreign_key: 'assessment_id', inverse_of: :live_feedbacks + belongs_to :question, class_name: 'Course::Assessment::Question', foreign_key: 'question_id', + inverse_of: :live_feedbacks + has_many :code, class_name: 'Course::Assessment::LiveFeedbackCode', foreign_key: 'feedback_id', + inverse_of: :feedback, dependent: :destroy + + validates :assessment, presence: true + validates :question, presence: true + validates :creator, presence: true + + def self.create_with_codes(assessment_id, question_id, user, feedback_id, files) + live_feedback = new( + assessment_id: assessment_id, + question_id: question_id, + creator: user, + feedback_id: feedback_id + ) + + if live_feedback.save + files.each do |file| + live_feedback_code = Course::Assessment::LiveFeedbackCode.new( + feedback_id: live_feedback.id, + filename: file.filename, + content: file.content + ) + unless live_feedback_code.save + Rails.logger.error "Failed to save live_feedback_code: #{live_feedback_code.errors.full_messages.join(', ')}" + end + end + live_feedback + else + Rails.logger.error "Failed to save live_feedback: #{live_feedback.errors.full_messages.join(', ')}" + nil + end + end +end diff --git a/app/models/course/assessment/live_feedback_code.rb b/app/models/course/assessment/live_feedback_code.rb new file mode 100644 index 00000000000..73fd30a6874 --- /dev/null +++ b/app/models/course/assessment/live_feedback_code.rb @@ -0,0 +1,10 @@ +# frozen_string_literal: true +class Course::Assessment::LiveFeedbackCode < ApplicationRecord + self.table_name = 'course_assessment_live_feedback_code' + belongs_to :feedback, class_name: 'Course::Assessment::LiveFeedback', foreign_key: 'feedback_id', inverse_of: :code + has_many :comments, class_name: 'Course::Assessment::LiveFeedbackComment', foreign_key: 'code_id', + dependent: :destroy, inverse_of: :code + + validates :filename, presence: true + validates :content, presence: true +end diff --git a/app/models/course/assessment/live_feedback_comment.rb b/app/models/course/assessment/live_feedback_comment.rb new file mode 100644 index 00000000000..8a507d1f079 --- /dev/null +++ b/app/models/course/assessment/live_feedback_comment.rb @@ -0,0 +1,7 @@ +# frozen_string_literal: true +class Course::Assessment::LiveFeedbackComment < ApplicationRecord + belongs_to :code, class_name: 'Course::Assessment::LiveFeedbackCode', foreign_key: 'code_id', inverse_of: :comments + + validates :line_number, presence: true + validates :comment, presence: true +end diff --git a/app/models/course/assessment/question.rb b/app/models/course/assessment/question.rb index 18e1f83bf8a..383c8b55a36 100644 --- a/app/models/course/assessment/question.rb +++ b/app/models/course/assessment/question.rb @@ -25,6 +25,8 @@ class Course::Assessment::Question < ApplicationRecord has_many :question_bundle_questions, class_name: 'Course::Assessment::QuestionBundleQuestion', foreign_key: :question_id, dependent: :destroy, inverse_of: :question has_many :question_bundles, through: :question_bundle_questions, class_name: 'Course::Assessment::QuestionBundle' + has_many :live_feedbacks, class_name: 'Course::Assessment::LiveFeedback', + dependent: :destroy, inverse_of: :question delegate :to_partial_path, to: :actable delegate :question_type, to: :actable @@ -38,7 +40,7 @@ class Course::Assessment::Question < ApplicationRecord # # @return [Boolean] True if the question supports auto grading. def auto_gradable? - actable.present? && actable.self_respond_to?(:auto_gradable?) ? actable.auto_gradable? : false + (actable.present? && actable.self_respond_to?(:auto_gradable?)) ? actable.auto_gradable? : false end # Gets an instance of the auto grader suitable for use with this question. diff --git a/app/models/course/assessment/question/programming.rb b/app/models/course/assessment/question/programming.rb index abf103e52da..513c665489a 100644 --- a/app/models/course/assessment/question/programming.rb +++ b/app/models/course/assessment/question/programming.rb @@ -263,8 +263,8 @@ def validate_codaveri_question # TODO: Move this validation logic to frontend, to prevent user from submitting in the first place. if !CodaveriAsyncApiService.language_valid_for_codaveri?(language) - errors.add(:base, 'Language type must be Python 3 and above to activate either codaveri '\ - 'evaluator or get help') + errors.add(:base, 'Language type must be Python 3 and above to activate either codaveri ' \ + 'evaluator or live feedback') elsif !question_assessments.empty? && !question_assessments.first.assessment.course.component_enabled?(Course::CodaveriComponent) errors.add(:base, diff --git a/app/services/course/assessment/answer/programming_codaveri_async_feedback_service.rb b/app/services/course/assessment/answer/programming_codaveri_async_feedback_service.rb index cb444df0599..4a136dbb56c 100644 --- a/app/services/course/assessment/answer/programming_codaveri_async_feedback_service.rb +++ b/app/services/course/assessment/answer/programming_codaveri_async_feedback_service.rb @@ -69,7 +69,7 @@ def self.language_from_locale(locale) # @param [Course::Assessment::Answer] answer The answer to be graded. # @return [Course::Assessment::Answer] The graded answer. Note that this answer is not persisted # yet. - def construct_feedback_object # rubocop:disable Metrics/AbcSize + def construct_feedback_object return unless @question.codaveri_id @answer_object[:problemId] = @question.codaveri_id @@ -77,8 +77,6 @@ def construct_feedback_object # rubocop:disable Metrics/AbcSize @answer_object[:languageVersion][:language] = @question.polyglot_language_name @answer_object[:languageVersion][:version] = @question.polyglot_language_version - # @answer_object[:is_only_itsp] = true if @course.codaveri_itsp_enabled? - @answer_files.each do |file| file_template = default_codaveri_student_file_template file_template[:path] = file.filename diff --git a/app/views/course/statistics/assessments/_live_feedback_history_details.json.jbuilder b/app/views/course/statistics/assessments/_live_feedback_history_details.json.jbuilder new file mode 100644 index 00000000000..e0ca185fc5b --- /dev/null +++ b/app/views/course/statistics/assessments/_live_feedback_history_details.json.jbuilder @@ -0,0 +1,11 @@ +# frozen_string_literal: true +json.files @live_feedback_details_hash[live_feedback_id].each do |live_feedback_details| + json.id live_feedback_details[:code][:id] + json.filename live_feedback_details[:code][:filename] + json.content live_feedback_details[:code][:content] + json.language @question.specific.language[:name] + json.comments live_feedback_details[:comments].map do |comment| + json.lineNumber comment[:line_number] + json.comment comment[:comment] + end +end diff --git a/app/views/course/statistics/assessments/live_feedback_history.json.jbuilder b/app/views/course/statistics/assessments/live_feedback_history.json.jbuilder new file mode 100644 index 00000000000..9403fc5b0c8 --- /dev/null +++ b/app/views/course/statistics/assessments/live_feedback_history.json.jbuilder @@ -0,0 +1,14 @@ +# frozen_string_literal: true +json.liveFeedbackHistory do + json.array! @live_feedbacks.map do |live_feedback| + json.id live_feedback.id + json.createdAt live_feedback.created_at&.iso8601 + json.partial! 'live_feedback_history_details', live_feedback_id: live_feedback.id + end +end + +json.question do + json.id @question.id + json.title @question.title + json.description format_ckeditor_rich_text(@question.description) +end diff --git a/app/views/course/statistics/assessments/live_feedback_statistics.json.jbuilder b/app/views/course/statistics/assessments/live_feedback_statistics.json.jbuilder new file mode 100644 index 00000000000..750bb80fc1d --- /dev/null +++ b/app/views/course/statistics/assessments/live_feedback_statistics.json.jbuilder @@ -0,0 +1,16 @@ +# frozen_string_literal: true +json.array! @student_live_feedback_hash.each do |course_user, (submission, live_feedback_count)| + json.partial! 'course_user', course_user: course_user + if submission.nil? + json.workflowState 'unstarted' + else + json.workflowState submission.workflow_state + end + + json.groups @group_names_hash[course_user.id] do |name| + json.name name + end + + json.liveFeedbackCount live_feedback_count + json.questionIds(@question_order_hash.keys.sort_by { |key| @question_order_hash[key] }) +end diff --git a/app/views/course/statistics/assessments/main_statistics.json.jbuilder b/app/views/course/statistics/assessments/main_statistics.json.jbuilder index 19eb1aadeb6..569ac9a8f44 100644 --- a/app/views/course/statistics/assessments/main_statistics.json.jbuilder +++ b/app/views/course/statistics/assessments/main_statistics.json.jbuilder @@ -3,6 +3,7 @@ json.assessment do json.partial! 'assessment', assessment: @assessment, course: current_course json.isAutograded @assessment_autograded json.questionCount @assessment.question_count + json.liveFeedbackEnabled @assessment.programming_questions.any?(&:live_feedback_enabled) end json.submissions @student_submissions_hash.each do |course_user, (submission, answers, end_at)| diff --git a/client/app/api/course/Assessment/Submissions.js b/client/app/api/course/Assessment/Submissions.js index d3a7a966288..712ebaca3ef 100644 --- a/client/app/api/course/Assessment/Submissions.js +++ b/client/app/api/course/Assessment/Submissions.js @@ -138,6 +138,13 @@ export default class SubmissionsAPI extends BaseAssessmentAPI { }); } + saveLiveFeedback(liveFeedbackId, feedbackFiles) { + return this.client.post(`${this.#urlPrefix}/save_live_feedback`, { + live_feedback_id: liveFeedbackId, + feedback_files: feedbackFiles, + }); + } + createProgrammingAnnotation(submissionId, answerId, fileId, params) { const url = `${this.#urlPrefix}/${submissionId}/answers/${answerId}/programming/files/${fileId}/annotations`; return this.client.post(url, params); diff --git a/client/app/api/course/Statistics/AssessmentStatistics.ts b/client/app/api/course/Statistics/AssessmentStatistics.ts index 03d329eba70..5a8f2a4e1e8 100644 --- a/client/app/api/course/Statistics/AssessmentStatistics.ts +++ b/client/app/api/course/Statistics/AssessmentStatistics.ts @@ -1,5 +1,7 @@ +import { LiveFeedbackHistoryState } from 'types/course/assessment/submission/liveFeedback'; import { AncestorAssessmentStats, + AssessmentLiveFeedbackStatistics, MainAssessmentStats, } from 'types/course/statistics/assessmentStatistics'; @@ -33,4 +35,23 @@ export default class AssessmentStatisticsAPI extends BaseCourseAPI { `${this.#urlPrefix}/${assessmentId}/main_statistics`, ); } + + fetchLiveFeedbackStatistics( + assessmentId: number, + ): APIResponse { + return this.client.get( + `${this.#urlPrefix}/${assessmentId}/live_feedback_statistics`, + ); + } + + fetchLiveFeedbackHistory( + assessmentId: string | number, + questionId: string | number, + courseUserId: string | number, + ): APIResponse { + return this.client.get( + `${this.#urlPrefix}/${assessmentId}/live_feedback_history`, + { params: { question_id: questionId, course_user_id: courseUserId } }, + ); + } } diff --git a/client/app/bundles/course/admin/pages/CodaveriSettings/translations.ts b/client/app/bundles/course/admin/pages/CodaveriSettings/translations.ts index c374b1bf4d1..d3e5dd926ac 100644 --- a/client/app/bundles/course/admin/pages/CodaveriSettings/translations.ts +++ b/client/app/bundles/course/admin/pages/CodaveriSettings/translations.ts @@ -85,7 +85,8 @@ export default defineMessages({ }, errorOccurredWhenUpdatingLiveFeedbackSettings: { id: 'course.admin.CodaveriSettings.errorOccurredWhenUpdatingLiveFeedbackSettings', - defaultMessage: 'An error occurred while updating the get help settings.', + defaultMessage: + 'An error occurred while updating the live feedback settings.', }, enableDisableButton: { id: 'course.admin.CodaveriSettings.enableDisableButton', @@ -100,7 +101,7 @@ export default defineMessages({ enableDisableLiveFeedback: { id: 'course.admin.CodaveriSettings.enableDisableLiveFeedback', defaultMessage: - '{enabled, select, true {Enable } other {Disable }} Get Help for {questionCount} \ + '{enabled, select, true {Enable } other {Disable }} live feedback for {questionCount} \ programming questions in {title}?', }, enableDisableEvaluatorDescription: { @@ -116,7 +117,7 @@ export default defineMessages({ successfulUpdateAllLiveFeedbackEnabled: { id: 'course.admin.CodaveriSettings.successfulUpdateAllLiveFeedbackEnabled', defaultMessage: - 'Sucessfully {liveFeedbackEnabled, select, true {enabled} other {disabled}} get help for all questions', + 'Sucessfully {liveFeedbackEnabled, select, true {enabled} other {disabled}} live feedback for all questions', }, evaluatorUpdateSuccess: { id: 'course.admin.CodaveriSettings.evaluatorUpdateSuccess', @@ -125,7 +126,7 @@ export default defineMessages({ liveFeedbackEnabledUpdateSuccess: { id: 'course.admin.CodaveriSettings.liveFeedbackEnabledUpdateSuccess', defaultMessage: - 'Get Help for {question} is now {liveFeedbackEnabled, select, true {enabled} other {disabled}}', + 'Live feedback for {question} is now {liveFeedbackEnabled, select, true {enabled} other {disabled}}', }, expandAll: { id: 'course.admin.CodaveriSettings.expandAll', diff --git a/client/app/bundles/course/assessment/components/AssessmentForm/translations.ts b/client/app/bundles/course/assessment/components/AssessmentForm/translations.ts index 50ffa859c72..3eb9bfaab3c 100644 --- a/client/app/bundles/course/assessment/components/AssessmentForm/translations.ts +++ b/client/app/bundles/course/assessment/components/AssessmentForm/translations.ts @@ -31,13 +31,14 @@ const translations = defineMessages({ }, toggleLiveFeedbackDescription: { id: 'course.assessment.AssessmentForm.toggleLiveFeedbackDescription', - defaultMessage: 'Enable Get Help feature for all programming questions', + defaultMessage: + 'Enable live feedback feature for all programming questions', }, noProgrammingQuestion: { id: 'course.assessment.AssessmentForm.noProgrammingQuestion', defaultMessage: 'You need to add at least one programming question that can be \ - supported by Codaveri to allow enabling Get Help for this Assessment', + supported by Codaveri to allow enabling live feedback for this Assessment', }, timeLimit: { id: 'course.assessment.AssessmentForm.timeLimit', diff --git a/client/app/bundles/course/assessment/operations/liveFeedback.ts b/client/app/bundles/course/assessment/operations/liveFeedback.ts new file mode 100644 index 00000000000..e5c7ca7d737 --- /dev/null +++ b/client/app/bundles/course/assessment/operations/liveFeedback.ts @@ -0,0 +1,32 @@ +import { AxiosError } from 'axios'; +import { dispatch } from 'store'; + +import CourseAPI from 'api/course'; + +import { liveFeedbackActions as actions } from '../reducers/liveFeedback'; + +export const fetchLiveFeedbackHistory = async ( + assessmentId: number, + questionId: number, + courseUserId: number, +): Promise => { + try { + const response = + await CourseAPI.statistics.assessment.fetchLiveFeedbackHistory( + assessmentId, + questionId, + courseUserId, + ); + + const data = response.data; + dispatch( + actions.initialize({ + liveFeedbackHistory: data.liveFeedbackHistory, + question: data.question, + }), + ); + } catch (error) { + if (error instanceof AxiosError) throw error.response?.data?.errors; + throw error; + } +}; diff --git a/client/app/bundles/course/assessment/operations/statistics.ts b/client/app/bundles/course/assessment/operations/statistics.ts index b7e1f92211f..e1a0c5ee5a5 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, + AssessmentLiveFeedbackStatistics, QuestionAllAnswerDisplayDetails, QuestionAnswerDetails, } from 'types/course/statistics/assessmentStatistics'; @@ -58,3 +59,13 @@ export const fetchAllAnswers = async ( return response.data; }; + +export const fetchLiveFeedbackStatistics = async ( + assessmentId: number, +): Promise => { + const response = + await CourseAPI.statistics.assessment.fetchLiveFeedbackStatistics( + assessmentId, + ); + return response.data; +}; diff --git a/client/app/bundles/course/assessment/pages/AssessmentStatistics/AnswerDisplay/AllAttemptsDisplay.tsx b/client/app/bundles/course/assessment/pages/AssessmentStatistics/AnswerDisplay/AllAttemptsDisplay.tsx index a4572bb5dfd..83e9f068408 100644 --- a/client/app/bundles/course/assessment/pages/AssessmentStatistics/AnswerDisplay/AllAttemptsDisplay.tsx +++ b/client/app/bundles/course/assessment/pages/AssessmentStatistics/AnswerDisplay/AllAttemptsDisplay.tsx @@ -1,6 +1,6 @@ import { FC, useState } from 'react'; import { defineMessages } from 'react-intl'; -import { Slider, Typography } from '@mui/material'; +import { Typography } from '@mui/material'; import { QuestionType } from 'types/course/assessment/question'; import { AllAnswerDetails, @@ -9,6 +9,7 @@ import { import Accordion from 'lib/components/core/layouts/Accordion'; import Link from 'lib/components/core/Link'; +import CustomSlider from 'lib/components/extensions/CustomSlider'; import useTranslation from 'lib/hooks/useTranslation'; import { formatLongDateTime } from 'lib/moment'; @@ -91,7 +92,7 @@ const AllAttemptsDisplay: FC = (props) => { {answerSubmittedTimes.length > 1 && (
- => { ), duplicationHistory: , + liveFeedback: , }; }; @@ -154,6 +160,14 @@ const AssessmentStatisticsPage: FC = () => { label={t(translations.duplicationHistory)} value="duplicationHistory" /> + {statistics.assessment?.liveFeedbackEnabled && ( + + )} diff --git a/client/app/bundles/course/assessment/pages/AssessmentStatistics/LiveFeedbackHistory/LiveFeedbackDetails.tsx b/client/app/bundles/course/assessment/pages/AssessmentStatistics/LiveFeedbackHistory/LiveFeedbackDetails.tsx new file mode 100644 index 00000000000..6ce2f24d70d --- /dev/null +++ b/client/app/bundles/course/assessment/pages/AssessmentStatistics/LiveFeedbackHistory/LiveFeedbackDetails.tsx @@ -0,0 +1,95 @@ +import { FC, useRef, useState } from 'react'; +import ReactAce from 'react-ace'; +import { Box, Card, CardContent, Drawer, Typography } from '@mui/material'; +import { LiveFeedbackCodeAndComments } from 'types/course/assessment/submission/liveFeedback'; + +import { parseLanguages } from 'course/assessment/submission/utils'; +import EditorField from 'lib/components/core/fields/EditorField'; +import useTranslation from 'lib/hooks/useTranslation'; + +import translations from '../translations'; + +interface Props { + file: LiveFeedbackCodeAndComments; +} + +const LiveFeedbackDetails: FC = (props) => { + const { t } = useTranslation(); + const { file } = props; + + const startingLineNum = Math.min( + ...file.comments.map((comment) => comment.lineNumber), + ); + + const [selectedLine, setSelectedLine] = useState(startingLineNum); + const editorRef = useRef(null); + + const handleCursorChange = (selection): void => { + const currentLine = selection.getCursor().row + 1; // Ace editor uses 0-index, so add 1 + setSelectedLine(currentLine); + }; + + const handleCommentClick = (lineNumber: number): void => { + setSelectedLine(lineNumber); + if (editorRef.current) { + editorRef.current.editor.focus(); + editorRef.current.editor.gotoLine(lineNumber, 0, true); + } + }; + + return ( +
+ + + + +
+ {file.comments.map((comment) => ( + { + handleCommentClick(comment.lineNumber); + }} + > + + {t(translations.lineHeader, { + lineNumber: comment.lineNumber, + })} + + + {comment.comment} + + + ))} +
+
+
+ ); +}; + +export default LiveFeedbackDetails; diff --git a/client/app/bundles/course/assessment/pages/AssessmentStatistics/LiveFeedbackHistory/LiveFeedbackHistoryPage.tsx b/client/app/bundles/course/assessment/pages/AssessmentStatistics/LiveFeedbackHistory/LiveFeedbackHistoryPage.tsx new file mode 100644 index 00000000000..56ddb2535c2 --- /dev/null +++ b/client/app/bundles/course/assessment/pages/AssessmentStatistics/LiveFeedbackHistory/LiveFeedbackHistoryPage.tsx @@ -0,0 +1,104 @@ +import { FC, useState } from 'react'; +import { Typography } from '@mui/material'; + +import Accordion from 'lib/components/core/layouts/Accordion'; +import CustomSlider from 'lib/components/extensions/CustomSlider'; +import { useAppSelector } from 'lib/hooks/store'; +import useTranslation from 'lib/hooks/useTranslation'; +import { formatLongDateTime } from 'lib/moment'; + +import { + getLiveFeedbackHistory, + getLiveFeedbadkQuestionInfo, +} from '../selectors'; +import translations from '../translations'; + +import LiveFeedbackDetails from './LiveFeedbackDetails'; + +interface Props { + questionNumber: number; +} + +const LiveFeedbackHistoryPage: FC = (props) => { + const { t } = useTranslation(); + const { questionNumber } = props; + const allLiveFeedbackHistory = useAppSelector(getLiveFeedbackHistory); + const nonEmptyLiveFeedbackHistory = allLiveFeedbackHistory.filter( + (liveFeedbackHistory) => { + // Remove live feedbacks that have no comments + return liveFeedbackHistory.files.some((file) => file.comments.length > 0); + }, + ); + const question = useAppSelector(getLiveFeedbadkQuestionInfo); + + const [displayedIndex, setDisplayedIndex] = useState( + nonEmptyLiveFeedbackHistory.length - 1, + ); + const sliderMarks = nonEmptyLiveFeedbackHistory.map( + (liveFeedbackHistory, idx) => { + return { + value: idx + 1, + label: + idx === 0 || idx === nonEmptyLiveFeedbackHistory.length - 1 + ? formatLongDateTime(liveFeedbackHistory.createdAt) + : '', + }; + }, + ); + + return ( + <> +
+ +
+ {question.title} + +
+
+
+ + {nonEmptyLiveFeedbackHistory.length > 1 && ( +
+ { + setDisplayedIndex( + Array.isArray(value) ? value[0] - 1 : value - 1, + ); + }} + step={null} + valueLabelDisplay="auto" + /> +
+ )} + + + {t(translations.feedbackTimingTitle, { + usedAt: formatLongDateTime( + nonEmptyLiveFeedbackHistory[displayedIndex].createdAt, + ), + })} + + + {nonEmptyLiveFeedbackHistory[displayedIndex].files.map((file) => ( + + ))} + + ); +}; + +export default LiveFeedbackHistoryPage; diff --git a/client/app/bundles/course/assessment/pages/AssessmentStatistics/LiveFeedbackHistory/index.tsx b/client/app/bundles/course/assessment/pages/AssessmentStatistics/LiveFeedbackHistory/index.tsx new file mode 100644 index 00000000000..8808a4014cb --- /dev/null +++ b/client/app/bundles/course/assessment/pages/AssessmentStatistics/LiveFeedbackHistory/index.tsx @@ -0,0 +1,36 @@ +import { FC } from 'react'; +import { useParams } from 'react-router-dom'; + +import { fetchLiveFeedbackHistory } from 'course/assessment/operations/liveFeedback'; +import LoadingIndicator from 'lib/components/core/LoadingIndicator'; +import Preload from 'lib/components/wrappers/Preload'; + +import LiveFeedbackHistoryPage from './LiveFeedbackHistoryPage'; + +interface Props { + questionNumber: number; + questionId: number; + courseUserId: number; +} + +const LiveFeedbackHistoryIndex: FC = (props): JSX.Element => { + const { questionNumber, questionId, courseUserId } = props; + const { assessmentId } = useParams(); + const parsedAssessmentId = parseInt(assessmentId!, 10); + + const fetchLiveFeedbackHistoryDetails = (): Promise => + fetchLiveFeedbackHistory(parsedAssessmentId, questionId, courseUserId); + + return ( + } + while={fetchLiveFeedbackHistoryDetails} + > + {(): JSX.Element => ( + + )} + + ); +}; + +export default LiveFeedbackHistoryIndex; diff --git a/client/app/bundles/course/assessment/pages/AssessmentStatistics/LiveFeedbackStatistics.tsx b/client/app/bundles/course/assessment/pages/AssessmentStatistics/LiveFeedbackStatistics.tsx new file mode 100644 index 00000000000..caca0143135 --- /dev/null +++ b/client/app/bundles/course/assessment/pages/AssessmentStatistics/LiveFeedbackStatistics.tsx @@ -0,0 +1,44 @@ +import { FC, useState } from 'react'; +import { useParams } from 'react-router-dom'; +import { AssessmentLiveFeedbackStatistics } from 'types/course/statistics/assessmentStatistics'; + +import { fetchLiveFeedbackStatistics } from 'course/assessment/operations/statistics'; +import LoadingIndicator from 'lib/components/core/LoadingIndicator'; +import Preload from 'lib/components/wrappers/Preload'; + +import LiveFeedbackStatisticsTable from './LiveFeedbackStatisticsTable'; + +interface Props { + includePhantom: boolean; +} + +const LiveFeedbackStatistics: FC = (props) => { + const { assessmentId } = useParams(); + const parsedAssessmentId = parseInt(assessmentId!, 10); + const { includePhantom } = props; + + const [liveFeedbackStatistics, setLiveFeedbackStatistics] = useState< + AssessmentLiveFeedbackStatistics[] + >([]); + + const fetchAndSetLiveFeedbackStatistics = (): Promise => + fetchLiveFeedbackStatistics(parsedAssessmentId).then( + setLiveFeedbackStatistics, + ); + + return ( + } + while={fetchAndSetLiveFeedbackStatistics} + > + {(): JSX.Element => ( + + )} + + ); +}; + +export default LiveFeedbackStatistics; diff --git a/client/app/bundles/course/assessment/pages/AssessmentStatistics/LiveFeedbackStatisticsTable.tsx b/client/app/bundles/course/assessment/pages/AssessmentStatistics/LiveFeedbackStatisticsTable.tsx new file mode 100644 index 00000000000..082ad2b4991 --- /dev/null +++ b/client/app/bundles/course/assessment/pages/AssessmentStatistics/LiveFeedbackStatisticsTable.tsx @@ -0,0 +1,290 @@ +import { FC, ReactNode, useEffect, useState } from 'react'; +import { useParams } from 'react-router-dom'; +import { Box, Chip, Typography } from '@mui/material'; +import palette from 'theme/palette'; +import { AssessmentLiveFeedbackStatistics } from 'types/course/statistics/assessmentStatistics'; + +import { workflowStates } from 'course/assessment/submission/constants'; +import Prompt from 'lib/components/core/dialogs/Prompt'; +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 { useAppSelector } from 'lib/hooks/store'; +import useTranslation from 'lib/hooks/useTranslation'; + +import { getClassnameForLiveFeedbackCell } from './classNameUtils'; +import LiveFeedbackHistoryIndex from './LiveFeedbackHistory'; +import { getAssessmentStatistics } from './selectors'; +import translations from './translations'; +import { getJointGroupsName, translateStatus } from './utils'; + +interface Props { + includePhantom: boolean; + liveFeedbackStatistics: AssessmentLiveFeedbackStatistics[]; +} + +const LiveFeedbackStatisticsTable: FC = (props) => { + const { t } = useTranslation(); + const { courseId } = useParams(); + const { includePhantom, liveFeedbackStatistics } = props; + + const statistics = useAppSelector(getAssessmentStatistics); + const assessment = statistics.assessment; + + const [parsedStatistics, setParsedStatistics] = useState< + AssessmentLiveFeedbackStatistics[] + >([]); + const [upperQuartileFeedbackCount, setUpperQuartileFeedbackCount] = + useState(0); + + const [openLiveFeedbackHistory, setOpenLiveFeedbackHistory] = useState(false); + const [liveFeedbackInfo, setLiveFeedbackInfo] = useState({ + courseUserId: 0, + questionId: 0, + questionNumber: 0, + }); + + useEffect(() => { + const feedbackCounts = liveFeedbackStatistics + .flatMap((s) => s.liveFeedbackCount ?? []) + .map((c) => c ?? 0) + .filter((c) => c !== 0) + .sort((a, b) => a - b); + const upperQuartilePercentileIndex = Math.floor( + 0.75 * (feedbackCounts.length - 1), + ); + const upperQuartilePercentileValue = + feedbackCounts[upperQuartilePercentileIndex]; + setUpperQuartileFeedbackCount(upperQuartilePercentileValue); + + const filteredStats = includePhantom + ? liveFeedbackStatistics + : liveFeedbackStatistics.filter((s) => !s.courseUser.isPhantom); + + filteredStats.forEach((stat) => { + stat.totalFeedbackCount = + stat.liveFeedbackCount?.reduce((sum, count) => sum + (count || 0), 0) ?? + 0; + }); + + setParsedStatistics( + filteredStats.sort((a, b) => { + const phantomDiff = + Number(a.courseUser.isPhantom) - Number(b.courseUser.isPhantom); + if (phantomDiff !== 0) return phantomDiff; + + const feedbackDiff = + (b.totalFeedbackCount ?? 0) - (a.totalFeedbackCount ?? 0); + if (feedbackDiff !== 0) return feedbackDiff; + + return a.courseUser.name.localeCompare(b.courseUser.name); + }), + ); + }, [liveFeedbackStatistics, includePhantom]); + + // the case where the live feedback count is null is handled separately inside the column + // (refer to the definition of statColumns below) + const renderNonNullClickableLiveFeedbackCountCell = ( + count: number, + courseUserId: number, + questionId: number, + questionNumber: number, + ): ReactNode => { + const classname = getClassnameForLiveFeedbackCell( + count, + upperQuartileFeedbackCount, + ); + if (count === 0) { + return {count}; + } + return ( +
{ + setOpenLiveFeedbackHistory(true); + setLiveFeedbackInfo({ courseUserId, questionId, questionNumber }); + }} + > + {count} +
+ ); + }; + + const statColumns: ColumnTemplate[] = + Array.from({ length: assessment?.questionCount ?? 0 }, (_, index) => { + return { + searchProps: { + getValue: (datum) => + datum.liveFeedbackCount?.[index]?.toString() ?? '', + }, + title: t(translations.questionIndex, { index: index + 1 }), + cell: (datum): ReactNode => { + return typeof datum.liveFeedbackCount?.[index] === 'number' + ? renderNonNullClickableLiveFeedbackCountCell( + datum.liveFeedbackCount?.[index], + datum.courseUser.id, + datum.questionIds[index], + index + 1, + ) + : null; + }, + sortable: true, + csvDownloadable: true, + className: 'text-right', + sortProps: { + sort: (a, b): number => { + const aValue = + a.liveFeedbackCount?.[index] ?? Number.MIN_SAFE_INTEGER; + const bValue = + b.liveFeedbackCount?.[index] ?? Number.MIN_SAFE_INTEGER; + + return aValue - bValue; + }, + }, + }; + }); + + const columns: ColumnTemplate[] = [ + { + searchProps: { + getValue: (datum) => datum.courseUser.name, + }, + title: t(translations.name), + sortable: true, + searchable: true, + cell: (datum) => ( +
+ + {datum.courseUser.name} + + {datum.courseUser.isPhantom && ( + + )} +
+ ), + csvDownloadable: true, + }, + { + of: 'groups', + title: t(translations.group), + sortable: true, + searchable: true, + searchProps: { + getValue: (datum) => getJointGroupsName(datum.groups), + }, + cell: (datum) => getJointGroupsName(datum.groups), + csvDownloadable: true, + }, + { + of: 'workflowState', + title: t(translations.workflowState), + sortable: true, + cell: (datum) => ( + + ), + className: 'center', + }, + ...statColumns, + { + searchProps: { + getValue: (datum) => + datum.liveFeedbackCount + ? datum.liveFeedbackCount + .reduce((sum, count) => sum + (count || 0), 0) + .toString() + : '', + }, + title: t(translations.total), + cell: (datum): ReactNode => { + const totalFeedbackCount = datum.liveFeedbackCount + ? datum.liveFeedbackCount.reduce( + (sum, count) => sum + (count || 0), + 0, + ) + : null; + return ( +
+ {totalFeedbackCount} +
+ ); + }, + sortable: true, + csvDownloadable: true, + className: 'text-right', + sortProps: { + sort: (a, b): number => { + const totalA = a.totalFeedbackCount ?? 0; + const totalB = b.totalFeedbackCount ?? 0; + return totalA - totalB; + }, + }, + }, + ]; + + return ( + <> +
+ + {t(translations.legendLowerUsage)} + + { + // The gradient color bar +
+ } + + {t(translations.legendHigherusage)} + +
+ + + `data_${datum.courseUser.id} bg-slot-1 hover?:bg-slot-2 slot-1-white slot-2-neutral-100` + } + getRowEqualityData={(datum): AssessmentLiveFeedbackStatistics => datum} + getRowId={(datum): string => datum.courseUser.id.toString()} + indexing={{ indices: true }} + pagination={{ + rowsPerPage: [DEFAULT_TABLE_ROWS_PER_PAGE], + showAllRows: true, + }} + search={{ searchPlaceholder: t(translations.nameGroupsSearchText) }} + toolbar={{ show: true }} + /> + setOpenLiveFeedbackHistory(false)} + open={openLiveFeedbackHistory} + title={t(translations.liveFeedbackHistoryPromptTitle)} + > + + + + ); +}; + +export default LiveFeedbackStatisticsTable; diff --git a/client/app/bundles/course/assessment/pages/AssessmentStatistics/StudentAttemptCountTable.tsx b/client/app/bundles/course/assessment/pages/AssessmentStatistics/StudentAttemptCountTable.tsx index 409dfc3ee09..444d965137e 100644 --- a/client/app/bundles/course/assessment/pages/AssessmentStatistics/StudentAttemptCountTable.tsx +++ b/client/app/bundles/course/assessment/pages/AssessmentStatistics/StudentAttemptCountTable.tsx @@ -1,5 +1,4 @@ import { FC, ReactNode, useState } from 'react'; -import { defineMessages } from 'react-intl'; import { useParams } from 'react-router-dom'; import { Box, Chip } from '@mui/material'; import palette from 'theme/palette'; @@ -20,75 +19,13 @@ import useTranslation from 'lib/hooks/useTranslation'; import AllAttemptsIndex from './AnswerDisplay/AllAttempts'; import { getClassNameForAttemptCountCell } from './classNameUtils'; import { getAssessmentStatistics } from './selectors'; - -const translations = defineMessages({ - onlyForAutogradableAssessment: { - id: 'course.assessment.statistics.onlyForAutogradableAssessment', - defaultMessage: - 'This table is only displayed for Assessment with at least one Autograded Questions', - }, - greenCellLegend: { - id: 'course.assessment.statistics.greenCellLegend', - defaultMessage: 'Correct', - }, - redCellLegend: { - id: 'course.assessment.statistics.redCellLegend', - defaultMessage: 'Incorrect', - }, - grayCellLegend: { - id: 'course.assessment.statistics.grayCellLegend', - defaultMessage: 'Undecided (question is Non-autogradable)', - }, - name: { - id: 'course.assessment.statistics.name', - defaultMessage: 'Name', - }, - group: { - id: 'course.assessment.statistics.group', - defaultMessage: 'Group', - }, - searchText: { - id: 'course.assessment.statistics.searchText', - defaultMessage: 'Search by Name or Groups', - }, - answers: { - id: 'course.assessment.statistics.answers', - defaultMessage: 'Answers', - }, - questionIndex: { - id: 'course.assessment.statistics.questionIndex', - defaultMessage: 'Q{index}', - }, - noSubmission: { - id: 'course.assessment.statistics.noSubmission', - defaultMessage: 'No Submission yet', - }, - workflowState: { - id: 'course.assessment.statistics.workflowState', - defaultMessage: 'Status', - }, - filename: { - id: 'course.assessment.statistics.filename', - defaultMessage: 'Question-level Attempt Statistics for {assessment}', - }, - close: { - id: 'course.assessment.statistics.close', - defaultMessage: 'Close', - }, -}); +import translations from './translations'; +import { getJointGroupsName, translateStatus } from './utils'; interface Props { includePhantom: boolean; } -const statusTranslations = { - attempting: 'Attempting', - submitted: 'Submitted', - graded: 'Graded, unpublished', - published: 'Graded', - unstarted: 'Not Started', -}; - const StudentAttemptCountTable: FC = (props) => { const { t } = useTranslation(); const { courseId, assessmentId } = useParams(); @@ -180,14 +117,6 @@ const StudentAttemptCountTable: FC = (props) => { }, ); - const jointGroupsName = (datum: MainSubmissionInfo): string => - datum.groups - ? datum.groups - .map((g) => g.name) - .sort() - .join(', ') - : ''; - const columns: ColumnTemplate[] = [ { searchProps: { @@ -214,9 +143,9 @@ const StudentAttemptCountTable: FC = (props) => { sortable: true, searchable: true, searchProps: { - getValue: (datum) => jointGroupsName(datum), + getValue: (datum) => getJointGroupsName(datum.groups), }, - cell: (datum) => jointGroupsName(datum), + cell: (datum) => getJointGroupsName(datum.groups), csvDownloadable: true, }, { @@ -230,11 +159,9 @@ const StudentAttemptCountTable: FC = (props) => { > @@ -251,12 +178,12 @@ const StudentAttemptCountTable: FC = (props) => { { key: 'correct', backgroundColor: 'bg-green-300', - description: t(translations.greenCellLegend), + description: t(translations.attemptsGreenCellLegend), }, { key: 'incorrect', backgroundColor: 'bg-red-300', - description: t(translations.redCellLegend), + description: t(translations.attemptsRedCellLegend), }, { key: 'undecided', @@ -268,7 +195,7 @@ const StudentAttemptCountTable: FC = (props) => {
= (props) => { rowsPerPage: [DEFAULT_TABLE_ROWS_PER_PAGE], showAllRows: true, }} - search={{ searchPlaceholder: t(translations.searchText) }} + search={{ searchPlaceholder: t(translations.nameGroupsSearchText) }} toolbar={{ show: true }} /> setOpenPastAnswers(false)} open={openPastAnswers} diff --git a/client/app/bundles/course/assessment/pages/AssessmentStatistics/StudentMarksPerQuestionTable.tsx b/client/app/bundles/course/assessment/pages/AssessmentStatistics/StudentMarksPerQuestionTable.tsx index d5babb1933f..2cb0e17e175 100644 --- a/client/app/bundles/course/assessment/pages/AssessmentStatistics/StudentMarksPerQuestionTable.tsx +++ b/client/app/bundles/course/assessment/pages/AssessmentStatistics/StudentMarksPerQuestionTable.tsx @@ -1,5 +1,4 @@ import { FC, ReactNode, useState } from 'react'; -import { defineMessages } from 'react-intl'; import { useParams } from 'react-router-dom'; import { Box, Chip } from '@mui/material'; import palette from 'theme/palette'; @@ -19,74 +18,13 @@ import useTranslation from 'lib/hooks/useTranslation'; import LastAttemptIndex from './AnswerDisplay/LastAttempt'; import { getClassNameForMarkCell } from './classNameUtils'; import { getAssessmentStatistics } from './selectors'; - -const translations = defineMessages({ - name: { - id: 'course.assessment.statistics.name', - defaultMessage: 'Name', - }, - greenCellLegend: { - id: 'course.assessment.statistics.greenCellLegend', - defaultMessage: '>= 0.5 * Maximum Grade', - }, - redCellLegend: { - id: 'course.assessment.statistics.redCellLegend', - defaultMessage: '< 0.5 * Maximum Grade', - }, - group: { - id: 'course.assessment.statistics.group', - defaultMessage: 'Group', - }, - totalGrade: { - id: 'course.assessment.statistics.totalGrade', - defaultMessage: 'Total', - }, - grader: { - id: 'course.assessment.statistics.grader', - defaultMessage: 'Grader', - }, - searchText: { - id: 'course.assessment.statistics.searchText', - defaultMessage: 'Search by Student Name, Group or Grader Name', - }, - answers: { - id: 'course.assessment.statistics.answers', - defaultMessage: 'Answers', - }, - questionIndex: { - id: 'course.assessment.statistics.questionIndex', - defaultMessage: 'Q{index}', - }, - noSubmission: { - id: 'course.assessment.statistics.noSubmission', - defaultMessage: 'No Submission yet', - }, - workflowState: { - id: 'course.assessment.statistics.workflowState', - defaultMessage: 'Status', - }, - filename: { - id: 'course.assessment.statistics.filename', - defaultMessage: 'Question-level Marks Statistics for {assessment}', - }, - close: { - id: 'course.assessment.statistics.close', - defaultMessage: 'Close', - }, -}); +import translations from './translations'; +import { getJointGroupsName, translateStatus } from './utils'; interface Props { includePhantom: boolean; } -const statusTranslations = { - attempting: 'Attempting', - submitted: 'Submitted', - graded: 'Graded, unpublished', - published: 'Graded', - unstarted: 'Not Started', -}; - const StudentMarksPerQuestionTable: FC = (props) => { const { t } = useTranslation(); const { courseId, assessmentId } = useParams(); @@ -185,14 +123,6 @@ const StudentMarksPerQuestionTable: FC = (props) => { }, ); - const jointGroupsName = (datum: MainSubmissionInfo): string => - datum.groups - ? datum.groups - .map((g) => g.name) - .sort() - .join(', ') - : ''; - const columns: ColumnTemplate[] = [ { searchProps: { @@ -219,9 +149,9 @@ const StudentMarksPerQuestionTable: FC = (props) => { sortable: true, searchable: true, searchProps: { - getValue: (datum) => jointGroupsName(datum), + getValue: (datum) => getJointGroupsName(datum.groups), }, - cell: (datum) => jointGroupsName(datum), + cell: (datum) => getJointGroupsName(datum.groups), csvDownloadable: true, }, { @@ -235,11 +165,9 @@ const StudentMarksPerQuestionTable: FC = (props) => { > @@ -251,7 +179,7 @@ const StudentMarksPerQuestionTable: FC = (props) => { searchProps: { getValue: (datum) => datum.totalGrade?.toString() ?? undefined, }, - title: t(translations.totalGrade), + title: t(translations.total), sortable: true, cell: (datum): ReactNode => { const isGradedOrPublished = @@ -298,19 +226,19 @@ const StudentMarksPerQuestionTable: FC = (props) => { { key: 'correct', backgroundColor: 'bg-green-500', - description: t(translations.greenCellLegend), + description: t(translations.marksGreenCellLegend), }, { key: 'incorrect', backgroundColor: 'bg-red-500', - description: t(translations.redCellLegend), + description: t(translations.marksRedCellLegend), }, ]} />
= (props) => { rowsPerPage: [DEFAULT_TABLE_ROWS_PER_PAGE], showAllRows: true, }} - search={{ searchPlaceholder: t(translations.searchText) }} + search={{ + searchPlaceholder: t(translations.nameGroupsGraderSearchText), + }} toolbar={{ show: true }} /> setOpenAnswer(false)} open={openAnswer} diff --git a/client/app/bundles/course/assessment/pages/AssessmentStatistics/classNameUtils.ts b/client/app/bundles/course/assessment/pages/AssessmentStatistics/classNameUtils.ts index d8b50e02a16..f596d3cb76a 100644 --- a/client/app/bundles/course/assessment/pages/AssessmentStatistics/classNameUtils.ts +++ b/client/app/bundles/course/assessment/pages/AssessmentStatistics/classNameUtils.ts @@ -1,7 +1,6 @@ import { AttemptInfo } from 'types/course/statistics/assessmentStatistics'; -// lower grade means obtaining the grade less than half the maximum possible grade -const lowerGradeBackgroundColorClassName = { +const redBackgroundColorClassName = { 0: 'bg-red-50', 100: 'bg-red-100', 200: 'bg-red-200', @@ -10,8 +9,7 @@ const lowerGradeBackgroundColorClassName = { 500: 'bg-red-500', }; -// higher grade means obtaining the grade at least half the maximum possible grade -const higherGradeBackgroundColorClassName = { +const greenBackgroundColorClassName = { 0: 'bg-green-50', 100: 'bg-green-100', 200: 'bg-green-200', @@ -20,15 +18,21 @@ const higherGradeBackgroundColorClassName = { 500: 'bg-green-500', }; -// calculate the gradient of the color in each grade cell -// 1. we compute the distance between the grade and the mid-grade (half the maximum) +// 1. we compute the distance between the value and the halfMaxValue // 2. then, we compute the fraction of it -> range becomes [0,1] -// 3. then we convert it into range [0,3] so that the shades will become [100, 200, 300] -const calculateColorGradientLevel = ( - grade: number, - halfMaxGrade: number, +// 3. then we convert it into range [0,5] so that the shades will become [100, 200, 300, 400, 500] +const calculateTwoSidedColorGradientLevel = ( + value: number, + halfMaxValue: number, +): number => { + return Math.round((Math.abs(value - halfMaxValue) / halfMaxValue) * 5) * 100; +}; + +const calculateOneSidedColorGradientLevel = ( + value: number, + maxValue: number, ): number => { - return Math.round((Math.abs(grade - halfMaxGrade) / halfMaxGrade) * 5) * 100; + return Math.round((Math.min(value, maxValue) / maxValue) * 5) * 100; }; // for marks per question cell, the difference in color means the following: @@ -38,10 +42,13 @@ export const getClassNameForMarkCell = ( grade: number, maxGrade: number, ): string => { - const gradientLevel = calculateColorGradientLevel(grade, maxGrade / 2); + const gradientLevel = calculateTwoSidedColorGradientLevel( + grade, + maxGrade / 2, + ); return grade >= maxGrade / 2 - ? `${higherGradeBackgroundColorClassName[gradientLevel]} p-[1rem]` - : `${lowerGradeBackgroundColorClassName[gradientLevel]} p-[1rem]`; + ? `${greenBackgroundColorClassName[gradientLevel]} p-[1rem]` + : `${redBackgroundColorClassName[gradientLevel]} p-[1rem]`; }; // for attempt count cell, the difference in color means the following: @@ -57,3 +64,14 @@ export const getClassNameForAttemptCountCell = ( return attempt.correct ? 'bg-green-300 p-[1rem]' : 'bg-red-300 p-[1rem]'; }; + +export const getClassnameForLiveFeedbackCell = ( + count: number, + upperQuartile: number, +): string => { + const gradientLevel = calculateOneSidedColorGradientLevel( + count, + upperQuartile, + ); + return `${redBackgroundColorClassName[gradientLevel]} p-[1rem]`; +}; diff --git a/client/app/bundles/course/assessment/pages/AssessmentStatistics/selectors.ts b/client/app/bundles/course/assessment/pages/AssessmentStatistics/selectors.ts index a2997f84ad8..1dc46870b7e 100644 --- a/client/app/bundles/course/assessment/pages/AssessmentStatistics/selectors.ts +++ b/client/app/bundles/course/assessment/pages/AssessmentStatistics/selectors.ts @@ -1,6 +1,17 @@ import { AppState } from 'store'; +import { + LiveFeedbackHistory, + QuestionInfo, +} from 'types/course/assessment/submission/liveFeedback'; import { AssessmentStatisticsState } from 'types/course/statistics/assessmentStatistics'; export const getAssessmentStatistics = ( state: AppState, ): AssessmentStatisticsState => state.assessments.statistics; + +export const getLiveFeedbackHistory = ( + state: AppState, +): LiveFeedbackHistory[] => state.assessments.liveFeedback.liveFeedbackHistory; + +export const getLiveFeedbadkQuestionInfo = (state: AppState): QuestionInfo => + state.assessments.liveFeedback.question; diff --git a/client/app/bundles/course/assessment/pages/AssessmentStatistics/translations.ts b/client/app/bundles/course/assessment/pages/AssessmentStatistics/translations.ts new file mode 100644 index 00000000000..e4459a20794 --- /dev/null +++ b/client/app/bundles/course/assessment/pages/AssessmentStatistics/translations.ts @@ -0,0 +1,125 @@ +import { defineMessages } from 'react-intl'; + +const translations = defineMessages({ + answers: { + id: 'course.assessment.statistics.answers', + defaultMessage: 'Answers', + }, + attemptsFilename: { + id: 'course.assessment.statistics.attempts.filename', + defaultMessage: 'Question-level Attempt Statistics for {assessment}', + }, + attemptsGreenCellLegend: { + id: 'course.assessment.statistics.attempts.greenCellLegend', + defaultMessage: 'Correct', + }, + attemptsRedCellLegend: { + id: 'course.assessment.statistics.attempts.redCellLegend', + defaultMessage: 'Incorrect', + }, + closePrompt: { + id: 'course.assessment.statistics.closePrompt', + defaultMessage: 'Close', + }, + grader: { + id: 'course.assessment.statistics.grader', + defaultMessage: 'Grader', + }, + grayCellLegend: { + id: 'course.assessment.statistics.grayCellLegend', + defaultMessage: 'Undecided (question is Non-autogradable)', + }, + group: { + id: 'course.assessment.statistics.group', + defaultMessage: 'Group', + }, + legendHigherusage: { + id: 'course.assessment.statistics.legendHigherusage', + defaultMessage: 'Higher Usage', + }, + legendLowerUsage: { + id: 'course.assessment.statistics.legendLowerUsage', + defaultMessage: 'Lower Usage', + }, + liveFeedbackFilename: { + id: 'course.assessment.statistics.liveFeedback.filename', + defaultMessage: 'Question-level Live Feedback Statistics for {assessment}', + }, + liveFeedbackHistoryPromptTitle: { + id: 'course.assessment.statistics.liveFeedbackHistoryPromptTitle', + defaultMessage: 'Live Feedback History', + }, + marksFilename: { + id: 'course.assessment.statistics.marks.filename', + defaultMessage: 'Question-level Marks Statistics for {assessment}', + }, + marksGreenCellLegend: { + id: 'course.assessment.statistics.marks.greenCellLegend', + defaultMessage: '>= 0.5 * Maximum Grade', + }, + marksRedCellLegend: { + id: 'course.assessment.statistics.marks.redCellLegend', + defaultMessage: '< 0.5 * Maximum Grade', + }, + name: { + id: 'course.assessment.statistics.name', + defaultMessage: 'Name', + }, + nameGroupsGraderSearchText: { + id: 'course.assessment.statistics.nameGroupsGraderSearchText', + defaultMessage: 'Search by Student Name, Group or Grader Name', + }, + nameGroupsSearchText: { + id: 'course.assessment.statistics.nameGroupsSearchText', + defaultMessage: 'Search by Name or Groups', + }, + noSubmission: { + id: 'course.assessment.statistics.noSubmission', + defaultMessage: 'No submission yet', + }, + onlyForAutogradableAssessment: { + id: 'course.assessment.statistics.onlyForAutogradableAssessment', + defaultMessage: + 'This table is only displayed for Assessment with at least one Autograded Questions', + }, + questionDisplayTitle: { + id: 'course.assessment.statistics.questionDisplayTitle', + defaultMessage: 'Q{index} for {student}', + }, + questionIndex: { + id: 'course.assessment.statistics.questionIndex', + defaultMessage: 'Q{index}', + }, + total: { + id: 'course.assessment.statistics.total', + defaultMessage: 'Total', + }, + workflowState: { + id: 'course.assessment.statistics.workflowState', + defaultMessage: 'Status', + }, + + questionTitle: { + id: 'course.assessment.liveFeedback.questionTitle', + defaultMessage: 'Question {index}', + }, + feedbackTimingTitle: { + id: 'course.assessment.liveFeedback.feedbackTimingTitle', + defaultMessage: 'Used at: {usedAt}', + }, + + liveFeedbackName: { + id: 'course.assessment.liveFeedback.comments', + defaultMessage: 'Live Feedback', + }, + comments: { + id: 'course.assessment.liveFeedback.comments', + defaultMessage: 'Comments', + }, + lineHeader: { + id: 'course.assessment.liveFeedback.lineHeader', + defaultMessage: 'Line {lineNumber}', + }, +}); + +export default translations; diff --git a/client/app/bundles/course/assessment/pages/AssessmentStatistics/utils.js b/client/app/bundles/course/assessment/pages/AssessmentStatistics/utils.js index 7d569d46210..a5b55e04144 100644 --- a/client/app/bundles/course/assessment/pages/AssessmentStatistics/utils.js +++ b/client/app/bundles/course/assessment/pages/AssessmentStatistics/utils.js @@ -61,3 +61,33 @@ export function processSubmissionsIntoChartData(submissions) { } return { labels, lineData, barData }; } + +// Change to this function when file is converted to TypeScript +// const getJointGroupsName = (groups: {name: string}[]): string => +// groups +// ? groups +// .map((group) => group.name) +// .sort() +// .join(', ') +// : ''; + +export const getJointGroupsName = (groups) => + groups + ? groups + .map((group) => group.name) + .sort() + .join(', ') + : ''; + +const statusTranslations = { + attempting: 'Attempting', + submitted: 'Submitted', + graded: 'Graded, unpublished', + published: 'Graded', + unstarted: 'Not Started', +}; + +// Change to this function when file is converted to TypeScript +// export const translateStatus = (status: WorkflowState): string => statusTranslations[status]; + +export const translateStatus = (status) => statusTranslations[status]; diff --git a/client/app/bundles/course/assessment/reducers/liveFeedback.ts b/client/app/bundles/course/assessment/reducers/liveFeedback.ts new file mode 100644 index 00000000000..e6abe5d0086 --- /dev/null +++ b/client/app/bundles/course/assessment/reducers/liveFeedback.ts @@ -0,0 +1,29 @@ +import { createSlice, PayloadAction } from '@reduxjs/toolkit'; +import { LiveFeedbackHistoryState } from 'types/course/assessment/submission/liveFeedback'; + +const initialState: LiveFeedbackHistoryState = { + liveFeedbackHistory: [], + question: { + id: 0, + title: '', + description: '', + }, +}; + +export const liveFeedbackSlice = createSlice({ + name: 'liveFeedbackHistory', + initialState, + reducers: { + initialize: (state, action: PayloadAction) => { + state.liveFeedbackHistory = action.payload.liveFeedbackHistory; + state.question = action.payload.question; + }, + reset: () => { + return initialState; + }, + }, +}); + +export const liveFeedbackActions = liveFeedbackSlice.actions; + +export default liveFeedbackSlice.reducer; diff --git a/client/app/bundles/course/assessment/store.ts b/client/app/bundles/course/assessment/store.ts index 9c475cd392a..87f375fd7ef 100644 --- a/client/app/bundles/course/assessment/store.ts +++ b/client/app/bundles/course/assessment/store.ts @@ -3,6 +3,7 @@ import { combineReducers } from 'redux'; import editPageReducer from './reducers/editPage'; import formDialogReducer from './reducers/formDialog'; import generatePageReducer from './reducers/generation'; +import liveFeedbackHistoryReducer from './reducers/liveFeedback'; import monitoringReducer from './reducers/monitoring'; import statisticsReducer from './reducers/statistics'; import submissionReducer from './submission/reducers'; @@ -14,6 +15,7 @@ const reducer = combineReducers({ monitoring: monitoringReducer, statistics: statisticsReducer, submission: submissionReducer, + liveFeedback: liveFeedbackHistoryReducer, }); export default reducer; diff --git a/client/app/bundles/course/assessment/submission/actions/answers/index.js b/client/app/bundles/course/assessment/submission/actions/answers/index.js index 991a48886f7..dc8a97194a0 100644 --- a/client/app/bundles/course/assessment/submission/actions/answers/index.js +++ b/client/app/bundles/course/assessment/submission/actions/answers/index.js @@ -288,6 +288,7 @@ export function generateLiveFeedback({ type: actionTypes.LIVE_FEEDBACK_REQUEST, payload: { questionId, + liveFeedbackId: response.data?.liveFeedbackId, feedbackUrl: response.data?.feedbackUrl, token: response.data?.data?.token, }, @@ -309,6 +310,7 @@ export function fetchLiveFeedback({ answerId, questionId, feedbackUrl, + liveFeedbackId, feedbackToken, successMessage, noFeedbackMessage, @@ -318,6 +320,10 @@ export function fetchLiveFeedback({ .fetchLiveFeedback(feedbackUrl, feedbackToken) .then((response) => { if (response.status === 200) { + CourseAPI.assessment.submissions.saveLiveFeedback( + liveFeedbackId, + response.data?.data?.feedbackFiles ?? [], + ); handleFeedbackOKResponse({ dispatch, response, diff --git a/client/app/bundles/course/assessment/submission/pages/SubmissionEditIndex/SubmissionForm.tsx b/client/app/bundles/course/assessment/submission/pages/SubmissionEditIndex/SubmissionForm.tsx index 87524c1d65e..3efda501bd1 100644 --- a/client/app/bundles/course/assessment/submission/pages/SubmissionEditIndex/SubmissionForm.tsx +++ b/client/app/bundles/course/assessment/submission/pages/SubmissionEditIndex/SubmissionForm.tsx @@ -79,6 +79,8 @@ const SubmissionForm: FC = (props) => { }); const onFetchLiveFeedback = (answerId: number, questionId: number): void => { + const liveFeedbackId = + liveFeedbacks?.feedbackByQuestion?.[questionId].liveFeedbackId; const feedbackToken = liveFeedbacks?.feedbackByQuestion?.[questionId].pendingFeedbackToken; const questionIndex = questionIds.findIndex((id) => id === questionId) + 1; @@ -93,6 +95,7 @@ const SubmissionForm: FC = (props) => { answerId, questionId, feedbackUrl: liveFeedbacks?.feedbackUrl, + liveFeedbackId, feedbackToken, successMessage, noFeedbackMessage, diff --git a/client/app/bundles/course/assessment/submission/pages/SubmissionEditIndex/components/button/LiveFeedbackButton.tsx b/client/app/bundles/course/assessment/submission/pages/SubmissionEditIndex/components/button/LiveFeedbackButton.tsx index cc27d2c6030..1e18341229b 100644 --- a/client/app/bundles/course/assessment/submission/pages/SubmissionEditIndex/components/button/LiveFeedbackButton.tsx +++ b/client/app/bundles/course/assessment/submission/pages/SubmissionEditIndex/components/button/LiveFeedbackButton.tsx @@ -70,7 +70,7 @@ const LiveFeedbackButton: FC = (props) => { ); }; - // TODO: update logic pending #7418: allow [Get Help] on all programming questions + // TODO: update logic pending #7418: allow [Live feedback] on all programming questions return (