From a78ee1bd2de6aab0047b1a7d4a1f0245527649d0 Mon Sep 17 00:00:00 2001 From: yoopie Date: Tue, 3 Sep 2024 20:05:13 +0800 Subject: [PATCH 01/19] refactor(main-statistics): add boolean to indicate if live feedback is enabled --- app/controllers/course/statistics/assessments_controller.rb | 1 + .../course/statistics/assessments/main_statistics.json.jbuilder | 1 + client/app/types/course/statistics/assessmentStatistics.ts | 1 + 3 files changed, 3 insertions(+) diff --git a/app/controllers/course/statistics/assessments_controller.rb b/app/controllers/course/statistics/assessments_controller.rb index 9ce717d7f8c..97318f850f1 100644 --- a/app/controllers/course/statistics/assessments_controller.rb +++ b/app/controllers/course/statistics/assessments_controller.rb @@ -7,6 +7,7 @@ class Course::Statistics::AssessmentsController < Course::Statistics::Controller 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). 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/types/course/statistics/assessmentStatistics.ts b/client/app/types/course/statistics/assessmentStatistics.ts index 67d308c0dd3..22f6708d173 100644 --- a/client/app/types/course/statistics/assessmentStatistics.ts +++ b/client/app/types/course/statistics/assessmentStatistics.ts @@ -17,6 +17,7 @@ interface AssessmentInfo { interface MainAssessmentInfo extends AssessmentInfo { isAutograded: boolean; questionCount: number; + liveFeedbackEnabled: boolean; } interface AncestorAssessmentInfo extends AssessmentInfo {} From ff5274b3bd5ca985d1e058f22f7aee0e3e1910a3 Mon Sep 17 00:00:00 2001 From: yoopie Date: Thu, 15 Aug 2024 17:10:49 +0800 Subject: [PATCH 02/19] feat(live-feedback): add tables for storing live feeedback information New Tables: 1. course_assessment_live_feedbacks 2. course_assessment_live_feedback_code 3. course_assessment_live_feedback_comments Related models have also been updated. These include: users course_assessments course_assessment_questions --- app/models/course/assessment.rb | 2 + app/models/course/assessment/live_feedback.rb | 38 +++++++++++++++++++ .../course/assessment/live_feedback_code.rb | 10 +++++ .../assessment/live_feedback_comment.rb | 7 ++++ app/models/course/assessment/question.rb | 4 +- ...848_add_live_feedback_code_and_comments.rb | 23 +++++++++++ db/schema.rb | 30 +++++++++++++++ 7 files changed, 113 insertions(+), 1 deletion(-) create mode 100644 app/models/course/assessment/live_feedback.rb create mode 100644 app/models/course/assessment/live_feedback_code.rb create mode 100644 app/models/course/assessment/live_feedback_comment.rb create mode 100644 db/migrate/20240808083848_add_live_feedback_code_and_comments.rb 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/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/db/migrate/20240808083848_add_live_feedback_code_and_comments.rb b/db/migrate/20240808083848_add_live_feedback_code_and_comments.rb new file mode 100644 index 00000000000..89cc3d3f435 --- /dev/null +++ b/db/migrate/20240808083848_add_live_feedback_code_and_comments.rb @@ -0,0 +1,23 @@ +class AddLiveFeedbackCodeAndComments < ActiveRecord::Migration[7.0] + def change + create_table :course_assessment_live_feedbacks do |t| + t.references :assessment, null: false, index: true, foreign_key: { to_table: :course_assessments } + t.references :question, null: false, index: true, foreign_key: { to_table: :course_assessment_questions } + t.references :creator, null: false, index: true, foreign_key: { to_table: :users } + t.datetime :created_at, null: false + t.string :feedback_id + end + + create_table :course_assessment_live_feedback_code do |t| + t.references :feedback, null: false, index: true, foreign_key: { to_table: :course_assessment_live_feedbacks } + t.string :filename, null: false + t.text :content, null: false + end + + create_table :course_assessment_live_feedback_comments do |t| + t.references :code, null: false, index: true, foreign_key: { to_table: :course_assessment_live_feedback_code } + t.integer :line_number, null: false + t.text :comment, null: false + end + end +end \ No newline at end of file diff --git a/db/schema.rb b/db/schema.rb index 31c418521e5..e0b38d80dac 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -221,6 +221,31 @@ t.index ["updater_id"], name: "fk__course_assessment_categories_updater_id" end + create_table "course_assessment_live_feedback_code", force: :cascade do |t| + t.bigint "feedback_id", null: false + t.string "filename", null: false + t.text "content", null: false + t.index ["feedback_id"], name: "index_course_assessment_live_feedback_code_on_feedback_id" + end + + create_table "course_assessment_live_feedback_comments", force: :cascade do |t| + t.bigint "code_id", null: false + t.integer "line_number", null: false + t.text "comment", null: false + t.index ["code_id"], name: "index_course_assessment_live_feedback_comments_on_code_id" + end + + create_table "course_assessment_live_feedbacks", force: :cascade do |t| + t.bigint "assessment_id", null: false + t.bigint "question_id", null: false + t.bigint "creator_id", null: false + t.datetime "created_at", null: false + t.string "feedback_id" + t.index ["assessment_id"], name: "index_course_assessment_live_feedbacks_on_assessment_id" + t.index ["creator_id"], name: "index_course_assessment_live_feedbacks_on_creator_id" + t.index ["question_id"], name: "index_course_assessment_live_feedbacks_on_question_id" + end + create_table "course_assessment_question_bundle_assignments", force: :cascade do |t| t.bigint "user_id", null: false t.bigint "assessment_id", null: false @@ -1491,6 +1516,11 @@ add_foreign_key "course_assessment_categories", "courses", name: "fk_course_assessment_categories_course_id" add_foreign_key "course_assessment_categories", "users", column: "creator_id", name: "fk_course_assessment_categories_creator_id" add_foreign_key "course_assessment_categories", "users", column: "updater_id", name: "fk_course_assessment_categories_updater_id" + add_foreign_key "course_assessment_live_feedback_code", "course_assessment_live_feedbacks", column: "feedback_id" + add_foreign_key "course_assessment_live_feedback_comments", "course_assessment_live_feedback_code", column: "code_id" + add_foreign_key "course_assessment_live_feedbacks", "course_assessment_questions", column: "question_id" + add_foreign_key "course_assessment_live_feedbacks", "course_assessments", column: "assessment_id" + add_foreign_key "course_assessment_live_feedbacks", "users", column: "creator_id" add_foreign_key "course_assessment_question_bundle_assignments", "course_assessment_question_bundles", column: "bundle_id" add_foreign_key "course_assessment_question_bundle_assignments", "course_assessment_submissions", column: "submission_id" add_foreign_key "course_assessment_question_bundle_assignments", "course_assessments", column: "assessment_id" From 2787b4bde3a3a51f4dbb4eb0dbd2b578645d8598 Mon Sep 17 00:00:00 2001 From: yoopie Date: Tue, 3 Sep 2024 17:29:13 +0800 Subject: [PATCH 03/19] feat(live-feedback-stats): add backend add new route for getting live feedback stats add new live_feedback_concern refactored some code in assessments_controller as it was used for live feedback stats as well add new json return format for live feedback stats --- .../statistics/live_feedback_concern.rb | 62 +++++++++++++++++++ .../statistics/assessments_controller.rb | 32 +++++++++- .../live_feedback_statistics.json.jbuilder | 14 +++++ config/routes.rb | 5 +- 4 files changed, 108 insertions(+), 5 deletions(-) create mode 100644 app/controllers/concerns/course/statistics/live_feedback_concern.rb create mode 100644 app/views/course/statistics/assessments/live_feedback_statistics.json.jbuilder 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/statistics/assessments_controller.rb b/app/controllers/course/statistics/assessments_controller.rb index 97318f850f1..85de32ccb08 100644 --- a/app/controllers/course/statistics/assessments_controller.rb +++ b/app/controllers/course/statistics/assessments_controller.rb @@ -3,6 +3,7 @@ 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]). @@ -36,8 +37,29 @@ 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]) + + @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 + 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 @@ -60,11 +82,15 @@ def fetch_all_ancestor_assessments end def create_question_related_hash - @question_order_hash = @assessment.question_assessments.to_h do |q| - [q.question_id, q.weight] - end + 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 + end 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..07295d45fec --- /dev/null +++ b/app/views/course/statistics/assessments/live_feedback_statistics.json.jbuilder @@ -0,0 +1,14 @@ +# 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 +end diff --git a/config/routes.rb b/config/routes.rb index 4808925fea0..8657c97b7ff 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -437,8 +437,6 @@ namespace :statistics do get '/' => 'statistics#index' get 'answer/:id' => 'answers#question_answer_details' - get 'assessment/:id/main_statistics' => 'assessments#main_statistics' - get 'assessment/:id/ancestor_statistics' => 'assessments#ancestor_statistics' get 'assessments' => 'aggregate#all_assessments' get 'students' => 'aggregate#all_students' get 'staff' => 'aggregate#all_staff' @@ -446,6 +444,9 @@ get 'course/performance' => 'aggregate#course_performance' get 'submission_question/:id' => 'answers#all_answers' get 'user/:user_id/learning_rate_records' => 'users#learning_rate_records' + get 'assessment/:id/main_statistics' => 'assessments#main_statistics' + get 'assessment/:id/ancestor_statistics' => 'assessments#ancestor_statistics' + get 'assessment/:id/live_feedback_statistics' => 'assessments#live_feedback_statistics' end scope module: :video do From bca2905c057619727272ce3d724ba2e036a19097 Mon Sep 17 00:00:00 2001 From: yoopie Date: Thu, 15 Aug 2024 17:16:18 +0800 Subject: [PATCH 04/19] refactor(statistics): classNameUtils refactored for a wider range of usability fix erroneous comments add new function to get class for live feedback cells previously cells were coloured linearly based on a max number new colouring only applies this gradient to the lower 75% of the data upper 25% is all coloured the most intense shade of red --- .../AssessmentStatistics/classNameUtils.ts | 47 +++++++++++++------ 1 file changed, 33 insertions(+), 14 deletions(-) diff --git a/client/app/bundles/course/assessment/pages/AssessmentStatistics/classNameUtils.ts b/client/app/bundles/course/assessment/pages/AssessmentStatistics/classNameUtils.ts index d8b50e02a16..5abc3e69050 100644 --- a/client/app/bundles/course/assessment/pages/AssessmentStatistics/classNameUtils.ts +++ b/client/app/bundles/course/assessment/pages/AssessmentStatistics/classNameUtils.ts @@ -1,7 +1,7 @@ +import { green } from 'theme/colors'; 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 +10,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 +19,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 +43,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 +65,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]`; +}; From 3cdd0f45f86b597c2c8a4fe34fb119783cf3e77c Mon Sep 17 00:00:00 2001 From: yoopie Date: Wed, 4 Sep 2024 11:01:23 +0800 Subject: [PATCH 05/19] feat(live-feedback-stats): add frontend add new tab to assessment statistics if live help is enabled for assessment add new types for live feedback statistics add call to new route for getting live feedback statistics refactor functionality for getting jointGroupsName refactor functionality for translating status both refactors have their commented out typescript equivalent left inside in file --- .../course/Statistics/AssessmentStatistics.ts | 9 + .../assessment/operations/statistics.ts | 11 + .../AssessmentStatisticsPage.tsx | 14 + .../LiveFeedbackStatistics.tsx | 44 +++ .../LiveFeedbackStatisticsTable.tsx | 288 ++++++++++++++++++ .../StudentAttemptCountTable.tsx | 29 +- .../StudentMarksPerQuestionTable.tsx | 29 +- .../pages/AssessmentStatistics/utils.js | 30 ++ .../course/statistics/assessmentStatistics.ts | 8 + 9 files changed, 416 insertions(+), 46 deletions(-) create mode 100644 client/app/bundles/course/assessment/pages/AssessmentStatistics/LiveFeedbackStatistics.tsx create mode 100644 client/app/bundles/course/assessment/pages/AssessmentStatistics/LiveFeedbackStatisticsTable.tsx diff --git a/client/app/api/course/Statistics/AssessmentStatistics.ts b/client/app/api/course/Statistics/AssessmentStatistics.ts index 03d329eba70..fc427cdf01c 100644 --- a/client/app/api/course/Statistics/AssessmentStatistics.ts +++ b/client/app/api/course/Statistics/AssessmentStatistics.ts @@ -1,5 +1,6 @@ import { AncestorAssessmentStats, + AssessmentLiveFeedbackStatistics, MainAssessmentStats, } from 'types/course/statistics/assessmentStatistics'; @@ -33,4 +34,12 @@ 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`, + ); + } } 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/AssessmentStatisticsPage.tsx b/client/app/bundles/course/assessment/pages/AssessmentStatistics/AssessmentStatisticsPage.tsx index 54cf34560d0..cded46a0d41 100644 --- a/client/app/bundles/course/assessment/pages/AssessmentStatistics/AssessmentStatisticsPage.tsx +++ b/client/app/bundles/course/assessment/pages/AssessmentStatistics/AssessmentStatisticsPage.tsx @@ -17,6 +17,7 @@ import MainGradesChart from './GradeDistribution/MainGradesChart'; import MainSubmissionChart from './SubmissionStatus/MainSubmissionChart'; import MainSubmissionTimeAndGradeStatistics from './SubmissionTimeAndGradeStatistics/MainSubmissionTimeAndGradeStatistics'; import DuplicationHistoryStatistics from './DuplicationHistoryStatistics'; +import LiveFeedbackStatistics from './LiveFeedbackStatistics'; import { getAssessmentStatistics } from './selectors'; import StudentAttemptCountTable from './StudentAttemptCountTable'; import StudentMarksPerQuestionTable from './StudentMarksPerQuestionTable'; @@ -42,6 +43,10 @@ const translations = defineMessages({ id: 'course.assessment.statistics.duplicationHistory', defaultMessage: 'Duplication History', }, + liveFeedback: { + id: 'course.assessment.statistics.liveFeedback', + defaultMessage: 'Live Feedback', + }, marksPerQuestion: { id: 'course.assessment.statistics.marksPerQuestion', defaultMessage: 'Marks Per Question', @@ -75,6 +80,7 @@ const tabMapping = (includePhantom: boolean): Record => { ), duplicationHistory: , + liveHelp: , }; }; @@ -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/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..d67a3ce2f50 --- /dev/null +++ b/client/app/bundles/course/assessment/pages/AssessmentStatistics/LiveFeedbackStatisticsTable.tsx @@ -0,0 +1,288 @@ +import { FC, ReactNode, useEffect, useState } from 'react'; +import { defineMessages } from 'react-intl'; +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 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 { getAssessmentStatistics } from './selectors'; +import { getJointGroupsName, translateStatus } from './utils'; + +const translations = defineMessages({ + name: { + id: 'course.assessment.statistics.name', + defaultMessage: 'Name', + }, + group: { + id: 'course.assessment.statistics.group', + defaultMessage: 'Group', + }, + workflowState: { + id: 'course.assessment.statistics.workflowState', + defaultMessage: 'Status', + }, + questionIndex: { + id: 'course.assessment.statistics.questionIndex', + defaultMessage: 'Q{index}', + }, + totalFeedbackCount: { + id: 'course.assessment.statistics.totalFeedbackCount', + defaultMessage: 'Total', + }, + searchText: { + id: 'course.assessment.statistics.searchText', + defaultMessage: 'Search by Name or Groups', + }, + filename: { + id: 'course.assessment.statistics.filename', + defaultMessage: 'Question-level Live Feedback Statistics for {assessment}', + }, + legendLowerUsage: { + id: 'course.assessment.statistics.legendLowerUsage', + defaultMessage: 'Lower Usage', + }, + legendHigherusage: { + id: 'course.assessment.statistics.legendHigherusage', + defaultMessage: 'Higher Usage', + }, +}); + +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); + + 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 renderNonNullAttemptCountCell = (count: number): ReactNode => { + const classname = getClassnameForLiveFeedbackCell( + count, + upperQuartileFeedbackCount, + ); + return ( +
+ {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' + ? renderNonNullAttemptCountCell(datum.liveFeedbackCount?.[index]) + : 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.totalFeedbackCount), + 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.searchText) }} + toolbar={{ show: true }} + /> + + ); +}; + +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..e3e6f9d5584 100644 --- a/client/app/bundles/course/assessment/pages/AssessmentStatistics/StudentAttemptCountTable.tsx +++ b/client/app/bundles/course/assessment/pages/AssessmentStatistics/StudentAttemptCountTable.tsx @@ -20,6 +20,7 @@ import useTranslation from 'lib/hooks/useTranslation'; import AllAttemptsIndex from './AnswerDisplay/AllAttempts'; import { getClassNameForAttemptCountCell } from './classNameUtils'; import { getAssessmentStatistics } from './selectors'; +import { getJointGroupsName, translateStatus } from './utils'; const translations = defineMessages({ onlyForAutogradableAssessment: { @@ -81,14 +82,6 @@ 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 +173,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 +199,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 +215,9 @@ const StudentAttemptCountTable: FC = (props) => { > diff --git a/client/app/bundles/course/assessment/pages/AssessmentStatistics/StudentMarksPerQuestionTable.tsx b/client/app/bundles/course/assessment/pages/AssessmentStatistics/StudentMarksPerQuestionTable.tsx index d5babb1933f..773100e476d 100644 --- a/client/app/bundles/course/assessment/pages/AssessmentStatistics/StudentMarksPerQuestionTable.tsx +++ b/client/app/bundles/course/assessment/pages/AssessmentStatistics/StudentMarksPerQuestionTable.tsx @@ -19,6 +19,7 @@ import useTranslation from 'lib/hooks/useTranslation'; import LastAttemptIndex from './AnswerDisplay/LastAttempt'; import { getClassNameForMarkCell } from './classNameUtils'; import { getAssessmentStatistics } from './selectors'; +import { getJointGroupsName, translateStatus } from './utils'; const translations = defineMessages({ name: { @@ -79,14 +80,6 @@ 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 +178,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 +204,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 +220,9 @@ const StudentMarksPerQuestionTable: FC = (props) => { > 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/types/course/statistics/assessmentStatistics.ts b/client/app/types/course/statistics/assessmentStatistics.ts index 22f6708d173..b89d6322c9a 100644 --- a/client/app/types/course/statistics/assessmentStatistics.ts +++ b/client/app/types/course/statistics/assessmentStatistics.ts @@ -139,3 +139,11 @@ export interface QuestionAllAnswerDisplayDetails< submissionId: number; comments: CommentItem[]; } + +export interface AssessmentLiveFeedbackStatistics { + courseUser: StudentInfo; + groups: { name: string }[]; + workflowState?: WorkflowState; + liveFeedbackCount?: number[]; // Will already be ordered by question + totalFeedbackCount?: number; +} From 08662e317a17578b084ce52cdbd7a5dc4ed0aab5 Mon Sep 17 00:00:00 2001 From: yoopie Date: Fri, 16 Aug 2024 09:22:38 +0800 Subject: [PATCH 06/19] style(generate-live-feedback): remove unnecessary commented code this commit used to be to fix a breaking change from codaveri regarding categories, but the change has been made by another commit that is already merged the change has been removed from this commit during rebase and only other random changes are left remove some random commented code in codaveri-async-feedback-service --- .../answer/programming_codaveri_async_feedback_service.rb | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) 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 From 36bdb5182d96be2a1a992a7753c04699734fd6fe Mon Sep 17 00:00:00 2001 From: yoopie Date: Tue, 3 Sep 2024 17:29:04 +0800 Subject: [PATCH 07/19] feat(live-feedback): add live feedback saving add frontend logic for returning live feedback once retrieved add backend logic and routes for saving live feedback --- .../submission/live_feedback_controller.rb | 21 +++++++++++++++++++ .../submission/submissions_controller.rb | 17 ++++++++++++++- .../course/assessment/assessment_ability.rb | 3 ++- .../app/api/course/Assessment/Submissions.js | 7 +++++++ .../AssessmentStatistics/classNameUtils.ts | 1 - .../submission/actions/answers/index.js | 6 ++++++ .../SubmissionEditIndex/SubmissionForm.tsx | 3 +++ .../submission/reducers/liveFeedback.js | 5 ++++- .../course/assessment/submission/types.ts | 1 + config/routes.rb | 1 + 10 files changed, 61 insertions(+), 4 deletions(-) create mode 100644 app/controllers/course/assessment/submission/live_feedback_controller.rb 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..bcad1f01a52 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 < 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/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/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/bundles/course/assessment/pages/AssessmentStatistics/classNameUtils.ts b/client/app/bundles/course/assessment/pages/AssessmentStatistics/classNameUtils.ts index 5abc3e69050..f596d3cb76a 100644 --- a/client/app/bundles/course/assessment/pages/AssessmentStatistics/classNameUtils.ts +++ b/client/app/bundles/course/assessment/pages/AssessmentStatistics/classNameUtils.ts @@ -1,4 +1,3 @@ -import { green } from 'theme/colors'; import { AttemptInfo } from 'types/course/statistics/assessmentStatistics'; const redBackgroundColorClassName = { 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/reducers/liveFeedback.js b/client/app/bundles/course/assessment/submission/reducers/liveFeedback.js index 60fa4eb22e3..c7fed18d824 100644 --- a/client/app/bundles/course/assessment/submission/reducers/liveFeedback.js +++ b/client/app/bundles/course/assessment/submission/reducers/liveFeedback.js @@ -16,6 +16,7 @@ export default function (state = initialState, action) { isRequestingLiveFeedback: true, pendingFeedbackToken: null, answerId: null, + liveFeedbackId: null, feedbackFiles: {}, }; } else { @@ -27,18 +28,20 @@ export default function (state = initialState, action) { }); } case actions.LIVE_FEEDBACK_REQUEST: { - const { token, questionId, feedbackUrl } = action.payload; + const { token, questionId, liveFeedbackId, feedbackUrl } = action.payload; return produce(state, (draft) => { draft.feedbackUrl ??= feedbackUrl; if (!(questionId in draft)) { draft.feedbackByQuestion[questionId] = { isRequestingLiveFeedback: false, + liveFeedbackId, pendingFeedbackToken: token, }; } else { draft.feedbackByQuestion[questionId] = { isRequestingLiveFeedback: false, ...draft.feedbackByQuestion[questionId], + liveFeedbackId, pendingFeedbackToken: token, }; } diff --git a/client/app/bundles/course/assessment/submission/types.ts b/client/app/bundles/course/assessment/submission/types.ts index 16607c990c1..3e6f780cfad 100644 --- a/client/app/bundles/course/assessment/submission/types.ts +++ b/client/app/bundles/course/assessment/submission/types.ts @@ -65,6 +65,7 @@ interface LiveFeedback { pendingFeedbackToken: string | null; answerId: number; feedbackFiles: Record; + liveFeedbackId: number; } export interface LiveFeedbackState { diff --git a/config/routes.rb b/config/routes.rb index 8657c97b7ff..9b68c926c6a 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -252,6 +252,7 @@ post :generate_feedback, on: :member get :fetch_submitted_feedback, on: :member post :generate_live_feedback, on: :member + post 'save_live_feedback', to: 'live_feedback#save_live_feedback', on: :collection get :download_all, on: :collection get :download_statistics, on: :collection patch :publish_all, on: :collection From 0ae2a99ee0e8bf89c9f406486f5420a646349212 Mon Sep 17 00:00:00 2001 From: yoopie Date: Thu, 22 Aug 2024 09:53:17 +0800 Subject: [PATCH 08/19] refactor(submission-controller): disable file length restrictions --- .../course/assessment/submission/submissions_controller.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/controllers/course/assessment/submission/submissions_controller.rb b/app/controllers/course/assessment/submission/submissions_controller.rb index bcad1f01a52..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 From dc3b32d5e2279849d92bcf58243c824dd15c15f7 Mon Sep 17 00:00:00 2001 From: yoopie Date: Tue, 27 Aug 2024 18:25:33 +0800 Subject: [PATCH 09/19] refactor(EditorField): add optional cursorStart variable --- client/app/lib/components/core/fields/EditorField.tsx | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/client/app/lib/components/core/fields/EditorField.tsx b/client/app/lib/components/core/fields/EditorField.tsx index fd1a79eb3eb..d89e07bc715 100644 --- a/client/app/lib/components/core/fields/EditorField.tsx +++ b/client/app/lib/components/core/fields/EditorField.tsx @@ -11,6 +11,7 @@ interface EditorProps extends ComponentProps { value?: string; onChange?: (value: string) => void; disabled?: boolean; + cursorStart?: number; } /** @@ -31,7 +32,8 @@ const DEFAULT_FONT_FAMILY = [ const EditorField = forwardRef( (props: EditorProps, ref: ForwardedRef): JSX.Element => { - const { language, value, disabled, onChange, ...otherProps } = props; + const { language, value, disabled, onChange, cursorStart, ...otherProps } = + props; return ( { + if (cursorStart !== undefined) { + editor.getSession().getSelection().moveCursorTo(cursorStart, 0); + } if (language === 'python') editor.onPaste = (originalText, event: ClipboardEvent): void => { event.preventDefault(); From e0f8db042d6639be7b7245244eb1c1cd7e7bd149 Mon Sep 17 00:00:00 2001 From: yoopie Date: Tue, 27 Aug 2024 18:27:45 +0800 Subject: [PATCH 10/19] refactor(live-feedback-stats): add retrieval of question ids individual live feedback histories need this information to be retrieved --- .../assessments/live_feedback_statistics.json.jbuilder | 2 ++ client/app/types/course/statistics/assessmentStatistics.ts | 1 + 2 files changed, 3 insertions(+) diff --git a/app/views/course/statistics/assessments/live_feedback_statistics.json.jbuilder b/app/views/course/statistics/assessments/live_feedback_statistics.json.jbuilder index 07295d45fec..750bb80fc1d 100644 --- a/app/views/course/statistics/assessments/live_feedback_statistics.json.jbuilder +++ b/app/views/course/statistics/assessments/live_feedback_statistics.json.jbuilder @@ -10,5 +10,7 @@ json.array! @student_live_feedback_hash.each do |course_user, (submission, live_ 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/client/app/types/course/statistics/assessmentStatistics.ts b/client/app/types/course/statistics/assessmentStatistics.ts index b89d6322c9a..dd4c8bba30e 100644 --- a/client/app/types/course/statistics/assessmentStatistics.ts +++ b/client/app/types/course/statistics/assessmentStatistics.ts @@ -146,4 +146,5 @@ export interface AssessmentLiveFeedbackStatistics { workflowState?: WorkflowState; liveFeedbackCount?: number[]; // Will already be ordered by question totalFeedbackCount?: number; + questionIds: number[]; } From ae6fea0b930fe6ec15c09f70d9f4b77a79451489 Mon Sep 17 00:00:00 2001 From: yoopie Date: Tue, 3 Sep 2024 21:17:06 +0800 Subject: [PATCH 11/19] feat(live-feedback-history): add FE and BE add dialog to show live feedback history on the live feedback statistics page add routes and backend for retrieving live feedback history --- .../statistics/assessments_controller.rb | 35 ++++++ ...ive_feedback_history_details.json.jbuilder | 11 ++ .../live_feedback_history.json.jbuilder | 14 +++ .../course/Statistics/AssessmentStatistics.ts | 12 ++ .../assessment/operations/liveFeedback.ts | 32 +++++ .../LiveFeedbackDetails.tsx | 109 +++++++++++++++++ .../LiveFeedbackHistoryPage.tsx | 112 ++++++++++++++++++ .../LiveFeedbackHistory/index.tsx | 36 ++++++ .../LiveFeedbackStatisticsTable.tsx | 55 ++++++++- .../pages/AssessmentStatistics/selectors.ts | 11 ++ .../assessment/reducers/liveFeedback.ts | 29 +++++ client/app/bundles/course/assessment/store.ts | 2 + .../assessment/submission/liveFeedback.ts | 32 +++++ config/routes.rb | 1 + 14 files changed, 488 insertions(+), 3 deletions(-) create mode 100644 app/views/course/statistics/assessments/_live_feedback_history_details.json.jbuilder create mode 100644 app/views/course/statistics/assessments/live_feedback_history.json.jbuilder create mode 100644 client/app/bundles/course/assessment/operations/liveFeedback.ts create mode 100644 client/app/bundles/course/assessment/pages/AssessmentStatistics/LiveFeedbackHistory/LiveFeedbackDetails.tsx create mode 100644 client/app/bundles/course/assessment/pages/AssessmentStatistics/LiveFeedbackHistory/LiveFeedbackHistoryPage.tsx create mode 100644 client/app/bundles/course/assessment/pages/AssessmentStatistics/LiveFeedbackHistory/index.tsx create mode 100644 client/app/bundles/course/assessment/reducers/liveFeedback.ts create mode 100644 client/app/types/course/assessment/submission/liveFeedback.ts diff --git a/app/controllers/course/statistics/assessments_controller.rb b/app/controllers/course/statistics/assessments_controller.rb index 85de32ccb08..7a3b8b5a45e 100644 --- a/app/controllers/course/statistics/assessments_controller.rb +++ b/app/controllers/course/statistics/assessments_controller.rb @@ -54,6 +54,12 @@ def live_feedback_statistics 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 @@ -93,4 +99,33 @@ def create_question_order_hash [q.question_id, q.weight] end 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/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/client/app/api/course/Statistics/AssessmentStatistics.ts b/client/app/api/course/Statistics/AssessmentStatistics.ts index fc427cdf01c..5a8f2a4e1e8 100644 --- a/client/app/api/course/Statistics/AssessmentStatistics.ts +++ b/client/app/api/course/Statistics/AssessmentStatistics.ts @@ -1,3 +1,4 @@ +import { LiveFeedbackHistoryState } from 'types/course/assessment/submission/liveFeedback'; import { AncestorAssessmentStats, AssessmentLiveFeedbackStatistics, @@ -42,4 +43,15 @@ export default class AssessmentStatisticsAPI extends BaseCourseAPI { `${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/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/pages/AssessmentStatistics/LiveFeedbackHistory/LiveFeedbackDetails.tsx b/client/app/bundles/course/assessment/pages/AssessmentStatistics/LiveFeedbackHistory/LiveFeedbackDetails.tsx new file mode 100644 index 00000000000..786008c76df --- /dev/null +++ b/client/app/bundles/course/assessment/pages/AssessmentStatistics/LiveFeedbackHistory/LiveFeedbackDetails.tsx @@ -0,0 +1,109 @@ +import { FC, useRef, useState } from 'react'; +import ReactAce from 'react-ace'; +import { defineMessages } from 'react-intl'; +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'; + +const translations = defineMessages({ + 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}', + }, +}); + +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..5fc3a403eb1 --- /dev/null +++ b/client/app/bundles/course/assessment/pages/AssessmentStatistics/LiveFeedbackHistory/LiveFeedbackHistoryPage.tsx @@ -0,0 +1,112 @@ +import { FC, useState } from 'react'; +import { defineMessages } from 'react-intl'; +import { Slider, Typography } from '@mui/material'; + +import Accordion from 'lib/components/core/layouts/Accordion'; +import { useAppSelector } from 'lib/hooks/store'; +import useTranslation from 'lib/hooks/useTranslation'; +import { formatLongDateTime } from 'lib/moment'; + +import { + getLiveFeedbackHistory, + getLiveFeedbadkQuestionInfo, +} from '../selectors'; + +import LiveFeedbackDetails from './LiveFeedbackDetails'; + +const translations = defineMessages({ + questionTitle: { + id: 'course.assessment.liveFeedback.questionTitle', + defaultMessage: 'Question {index}', + }, + feedbackTimingTitle: { + id: 'course.assessment.liveFeedback.feedbackTimingTitle', + defaultMessage: 'Used at: {usedAt}', + }, +}); + +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, + label: + idx === 0 || idx === nonEmptyLiveFeedbackHistory.length - 1 + ? formatLongDateTime(liveFeedbackHistory.createdAt) + : '', + }; + }, + ); + + return ( + <> +
+ +
+ {question.title} + +
+
+
+ + {nonEmptyLiveFeedbackHistory.length > 1 && ( +
+ { + setDisplayedIndex(Array.isArray(value) ? value[0] : value); + }} + step={null} + valueLabelDisplay="off" + /> +
+ )} + + + {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/LiveFeedbackStatisticsTable.tsx b/client/app/bundles/course/assessment/pages/AssessmentStatistics/LiveFeedbackStatisticsTable.tsx index d67a3ce2f50..8a4609bab99 100644 --- a/client/app/bundles/course/assessment/pages/AssessmentStatistics/LiveFeedbackStatisticsTable.tsx +++ b/client/app/bundles/course/assessment/pages/AssessmentStatistics/LiveFeedbackStatisticsTable.tsx @@ -6,6 +6,7 @@ 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'; @@ -14,6 +15,7 @@ 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 { getJointGroupsName, translateStatus } from './utils'; @@ -46,6 +48,14 @@ const translations = defineMessages({ id: 'course.assessment.statistics.filename', defaultMessage: 'Question-level Live Feedback Statistics for {assessment}', }, + closePrompt: { + id: 'course.assessment.statistics.closePrompt', + defaultMessage: 'Close', + }, + liveFeedbackHistoryPromptTitle: { + id: 'course.assessment.statistics.liveFeedbackHistoryPromptTitle', + defaultMessage: 'Live Feedback History', + }, legendLowerUsage: { id: 'course.assessment.statistics.legendLowerUsage', defaultMessage: 'Lower Usage', @@ -75,6 +85,13 @@ const LiveFeedbackStatisticsTable: FC = (props) => { 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 ?? []) @@ -115,13 +132,27 @@ const LiveFeedbackStatisticsTable: FC = (props) => { // the case where the live feedback count is null is handled separately inside the column // (refer to the definition of statColumns below) - const renderNonNullAttemptCountCell = (count: number): ReactNode => { + 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}
); @@ -137,7 +168,12 @@ const LiveFeedbackStatisticsTable: FC = (props) => { title: t(translations.questionIndex, { index: index + 1 }), cell: (datum): ReactNode => { return typeof datum.liveFeedbackCount?.[index] === 'number' - ? renderNonNullAttemptCountCell(datum.liveFeedbackCount?.[index]) + ? renderNonNullClickableLiveFeedbackCountCell( + datum.liveFeedbackCount?.[index], + datum.courseUser.id, + datum.questionIds[index], + index + 1, + ) : null; }, sortable: true, @@ -281,6 +317,19 @@ const LiveFeedbackStatisticsTable: FC = (props) => { search={{ searchPlaceholder: t(translations.searchText) }} toolbar={{ show: true }} /> + setOpenLiveFeedbackHistory(false)} + open={openLiveFeedbackHistory} + title={t(translations.liveFeedbackHistoryPromptTitle)} + > + + ); }; 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/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/types/course/assessment/submission/liveFeedback.ts b/client/app/types/course/assessment/submission/liveFeedback.ts new file mode 100644 index 00000000000..f573a4fa1ff --- /dev/null +++ b/client/app/types/course/assessment/submission/liveFeedback.ts @@ -0,0 +1,32 @@ +export interface LiveFeedbackComments { + lineNumber: number; + comment: string; +} + +export interface LiveFeedbackCode { + id: number; + filename: string; + content: string; + language: string; +} + +export interface LiveFeedbackCodeAndComments extends LiveFeedbackCode { + comments: LiveFeedbackComments[]; +} + +export interface LiveFeedbackHistory { + id: number; + createdAt: string; + files: LiveFeedbackCodeAndComments[]; +} + +export interface QuestionInfo { + id: number; + title: string; + description: string; +} + +export interface LiveFeedbackHistoryState { + liveFeedbackHistory: LiveFeedbackHistory[]; + question: QuestionInfo; +} diff --git a/config/routes.rb b/config/routes.rb index 9b68c926c6a..db2b504c7e3 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -448,6 +448,7 @@ get 'assessment/:id/main_statistics' => 'assessments#main_statistics' get 'assessment/:id/ancestor_statistics' => 'assessments#ancestor_statistics' get 'assessment/:id/live_feedback_statistics' => 'assessments#live_feedback_statistics' + get 'assessment/:id/live_feedback_history' => 'assessments#live_feedback_history' end scope module: :video do From c4aad24bff5093c0cf29ef884c6897da00c14524 Mon Sep 17 00:00:00 2001 From: yoopie Date: Wed, 28 Aug 2024 17:53:25 +0800 Subject: [PATCH 12/19] test(live-feedback-history): add tests for live feedback history --- .../statistics/assessment_controller_spec.rb | 97 +++++++++++++++++++ .../course_assessment_live_feedback_code.rb | 8 ++ ...ourse_assessment_live_feedback_comments.rb | 8 ++ .../course_assessment_live_feedbacks.rb | 8 ++ 4 files changed, 121 insertions(+) create mode 100644 spec/factories/course_assessment_live_feedback_code.rb create mode 100644 spec/factories/course_assessment_live_feedback_comments.rb create mode 100644 spec/factories/course_assessment_live_feedbacks.rb diff --git a/spec/controllers/course/statistics/assessment_controller_spec.rb b/spec/controllers/course/statistics/assessment_controller_spec.rb index 84f656e6dd9..200fdfdce47 100644 --- a/spec/controllers/course/statistics/assessment_controller_spec.rb +++ b/spec/controllers/course/statistics/assessment_controller_spec.rb @@ -162,5 +162,102 @@ it { expect { subject }.to raise_exception(CanCan::AccessDenied) } end end + + describe '#live_feedback_history' do + let(:question) do + create(:course_assessment_question_programming, assessment: assessment).acting_as + end + let(:user) { create(:user) } + + let!(:course_student) { create(:course_student, course: course, user: user) } + let!(:live_feedback) do + create(:course_assessment_live_feedback, assessment: assessment, + question: question, + creator: course_student) + end + let!(:code) { create(:course_assessment_live_feedback_code, feedback: live_feedback) } + let!(:comment) do + create(:course_assessment_live_feedback_comment, code: code, line_number: 1, + comment: 'This is a test comment') + end + render_views + subject do + get :live_feedback_history, as: :json, + params: { + course_id: course, + id: assessment.id, + question_id: question.id, + course_user_id: course_student.id + } + end + + context 'when the Normal User wants to get live feedback history' do + before { controller_sign_in(controller, user) } + + it { expect { subject }.to raise_exception(CanCan::AccessDenied) } + end + + context 'when the Course Manager wants to get live feedback history' do + let(:course_manager) { create(:course_manager, course: course) } + + before { controller_sign_in(controller, course_manager.user) } + + it 'returns the live feedback history successfully' do + expect(subject).to have_http_status(:success) + json_result = JSON.parse(response.body) + + feedback_history = json_result['liveFeedbackHistory'] + question = json_result['question'] + + expect(feedback_history).not_to be_empty + expect(feedback_history.first).to have_key('files') + + file = feedback_history.first['files'].first + expect(file).to have_key('filename') + expect(file).to have_key('content') + expect(file).to have_key('language') + expect(file).to have_key('comments') + + comment = file['comments'].first + expect(comment).to have_key('lineNumber') + expect(comment).to have_key('comment') + + expect(question).not_to be_empty + expect(question).to have_key('title') + expect(question).to have_key('description') + end + end + + context 'when the Administrator wants to get live feedback history' do + let(:administrator) { create(:administrator) } + + before { controller_sign_in(controller, administrator) } + + it 'returns the live feedback history successfully' do + expect(subject).to have_http_status(:success) + json_result = JSON.parse(response.body) + + feedback_history = json_result['liveFeedbackHistory'] + question = json_result['question'] + + expect(feedback_history).not_to be_empty + expect(feedback_history.first).to have_key('files') + + file = feedback_history.first['files'].first + expect(file).to have_key('filename') + expect(file).to have_key('content') + expect(file).to have_key('language') + expect(file).to have_key('comments') + + comment = file['comments'].first + expect(comment).to have_key('lineNumber') + expect(comment).to have_key('comment') + + expect(question).not_to be_empty + expect(question).to have_key('title') + expect(question).to have_key('description') + end + end + end end end diff --git a/spec/factories/course_assessment_live_feedback_code.rb b/spec/factories/course_assessment_live_feedback_code.rb new file mode 100644 index 00000000000..8469cf30648 --- /dev/null +++ b/spec/factories/course_assessment_live_feedback_code.rb @@ -0,0 +1,8 @@ +# frozen_string_literal: true +FactoryBot.define do + factory :course_assessment_live_feedback_code, class: Course::Assessment::LiveFeedbackCode do + feedback { association(:course_assessment_live_feedback) } + filename { 'test_code.rb' } + content { 'puts "Hello, World!"' } + end +end diff --git a/spec/factories/course_assessment_live_feedback_comments.rb b/spec/factories/course_assessment_live_feedback_comments.rb new file mode 100644 index 00000000000..05ee2e213b6 --- /dev/null +++ b/spec/factories/course_assessment_live_feedback_comments.rb @@ -0,0 +1,8 @@ +# frozen_string_literal: true +FactoryBot.define do + factory :course_assessment_live_feedback_comment, class: Course::Assessment::LiveFeedbackComment do + code { association(:course_assessment_live_feedback_code) } + line_number { 1 } + comment { 'This is a test comment' } + end +end diff --git a/spec/factories/course_assessment_live_feedbacks.rb b/spec/factories/course_assessment_live_feedbacks.rb new file mode 100644 index 00000000000..3133b5dc9a9 --- /dev/null +++ b/spec/factories/course_assessment_live_feedbacks.rb @@ -0,0 +1,8 @@ +# frozen_string_literal: true +FactoryBot.define do + factory :course_assessment_live_feedback, class: Course::Assessment::LiveFeedback do + assessment + question { association(:course_assessment_question_programming, assessment: assessment) } + creator { association(:course_user, course: assessment.course) } + end +end From 91e49a6b63fda083ded8111f8a8b4cf73d30f39c Mon Sep 17 00:00:00 2001 From: yoopie Date: Wed, 28 Aug 2024 22:01:19 +0800 Subject: [PATCH 13/19] refactor(assessments-factory) Change assessments factory to give unique question weights when creating questions This is to assist in live feedback tests as weights are needed to order the questions --- .../course_assessment_assessments.rb | 33 ++++++++++--------- 1 file changed, 17 insertions(+), 16 deletions(-) diff --git a/spec/factories/course_assessment_assessments.rb b/spec/factories/course_assessment_assessments.rb index 059d49849e3..9ea34e379d3 100644 --- a/spec/factories/course_assessment_assessments.rb +++ b/spec/factories/course_assessment_assessments.rb @@ -2,6 +2,7 @@ FactoryBot.define do sequence(:course_assessment_assessment_name) { |n| "Assessment #{n}" } sequence(:course_assessment_assessment_description) { |n| "Awesome description #{n}" } + sequence(:question_weight) factory :course_assessment_assessment, class: Course::Assessment, aliases: [:assessment], parent: :course_lesson_plan_item do transient do @@ -44,74 +45,74 @@ trait :with_mcq_question do after(:build) do |assessment, evaluator| - evaluator.question_count.downto(1).each do |i| + evaluator.question_count.times do question = build(:course_assessment_question_multiple_response, :multiple_choice) - assessment.question_assessments.build(question: question.acting_as, weight: i) + assessment.question_assessments.build(question: question.acting_as, weight: generate(:question_weight)) end end end trait :with_mrq_question do after(:build) do |assessment, evaluator| - evaluator.question_count.downto(1).each do |i| + evaluator.question_count.times do question = build(:course_assessment_question_multiple_response) - assessment.question_assessments.build(question: question.acting_as, weight: i) + assessment.question_assessments.build(question: question.acting_as, weight: generate(:question_weight)) end end end trait :with_programming_question do after(:build) do |assessment, evaluator| - evaluator.question_count.downto(1).each do |i| + evaluator.question_count.times do question = build(:course_assessment_question_programming, :auto_gradable, template_package: true) - assessment.question_assessments.build(question: question.acting_as, weight: i) + assessment.question_assessments.build(question: question.acting_as, weight: generate(:question_weight)) end end end trait :with_programming_codaveri_question do after(:build) do |assessment, evaluator| - evaluator.question_count.downto(1).each do |i| + evaluator.question_count.times do question = build(:course_assessment_question_programming, :auto_gradable, template_package: true, is_codaveri: true) - assessment.question_assessments.build(question: question.acting_as, weight: i) + assessment.question_assessments.build(question: question.acting_as, weight: generate(:question_weight)) end end end trait :with_programming_file_submission_question do after(:build) do |assessment, evaluator| - evaluator.question_count.downto(1).each do |i| + evaluator.question_count.times do question = build(:course_assessment_question_programming, :auto_gradable, :multiple_file_submission) - assessment.question_assessments.build(question: question.acting_as, weight: i) + assessment.question_assessments.build(question: question.acting_as, weight: generate(:question_weight)) end end end trait :with_text_response_question do after(:build) do |assessment, evaluator| - evaluator.question_count.downto(1).each do |i| + evaluator.question_count.times do question = build(:course_assessment_question_text_response, :allow_multiple_attachments) - assessment.question_assessments.build(question: question.acting_as, weight: i) + assessment.question_assessments.build(question: question.acting_as, weight: generate(:question_weight)) end end end trait :with_file_upload_question do after(:build) do |assessment, evaluator| - evaluator.question_count.downto(1).each do |i| + evaluator.question_count.times do question = build(:course_assessment_question_text_response, :file_upload_question) - assessment.question_assessments.build(question: question.acting_as, weight: i) + assessment.question_assessments.build(question: question.acting_as, weight: generate(:question_weight)) end end end trait :with_forum_post_response_question do after(:build) do |assessment, evaluator| - evaluator.question_count.downto(1).each do |i| + evaluator.question_count.times do question = build(:course_assessment_question_forum_post_response) - assessment.question_assessments.build(question: question.acting_as, weight: i) + assessment.question_assessments.build(question: question.acting_as, weight: generate(:question_weight)) end end end From 5424b6cece2f9749b1f619acec33247315bd0ba6 Mon Sep 17 00:00:00 2001 From: yoopie Date: Wed, 28 Aug 2024 22:03:09 +0800 Subject: [PATCH 14/19] test(live-feedback-statistics) add tests for live feedback statistics --- .../statistics/assessment_controller_spec.rb | 121 +++++++++++++++++- .../course_assessment_live_feedback_code.rb | 8 ++ .../course_assessment_live_feedbacks.rb | 11 +- .../course/assessment/live_feedback_spec.rb | 71 ++++++++++ 4 files changed, 203 insertions(+), 8 deletions(-) create mode 100644 spec/models/course/assessment/live_feedback_spec.rb diff --git a/spec/controllers/course/statistics/assessment_controller_spec.rb b/spec/controllers/course/statistics/assessment_controller_spec.rb index 200fdfdce47..e8ccb27c33b 100644 --- a/spec/controllers/course/statistics/assessment_controller_spec.rb +++ b/spec/controllers/course/statistics/assessment_controller_spec.rb @@ -163,6 +163,118 @@ end end + describe '#live_feedback_statistics' do + render_views + + let(:question1) { create(:course_assessment_question_programming, assessment: assessment).acting_as } + let(:question2) { create(:course_assessment_question_multiple_response, assessment: assessment).acting_as } + let!(:course_student) { students[0] } + + before do + create_list(:course_assessment_live_feedback, 3, + assessment: assessment, + question: question1, + creator: course_student.user, + with_comment: true) + create(:course_assessment_live_feedback, assessment: assessment, question: question2, + creator: course_student.user) + end + + subject do + get :live_feedback_statistics, as: :json, params: { course_id: course, id: assessment.id } + end + + context 'when the Normal User tries to get live feedback 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 tries to get live feedback 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 gets live feedback statistics' do + let(:user) { create(:course_manager, course: course).user } + before { controller_sign_in(controller, user) } + + it 'returns OK with the correct live feedback statistics' do + expect(subject).to have_http_status(:success) + json_result = JSON.parse(response.body) + + first_result = json_result.first + + # Check the general structure + expect(first_result).to have_key('courseUser') + expect(first_result['courseUser']).to have_key('id') + expect(first_result['courseUser']).to have_key('name') + expect(first_result['courseUser']).to have_key('role') + expect(first_result['courseUser']).to have_key('isPhantom') + + expect(first_result).to have_key('workflowState') + expect(first_result).to have_key('groups') + expect(first_result).to have_key('liveFeedbackCount') + expect(first_result).to have_key('questionIds') + + # Ensure that the feedback count is correct for the specific questions + question_index = first_result['questionIds'].index(question1.id) + if first_result['courseUser']['id'] == course_student.id + expect(first_result['liveFeedbackCount'][question_index]).to eq(3) + else + expect(first_result['liveFeedbackCount'][question_index]).to eq(0) + end + + # No feedback for the second question, since there is no comment + question_index = first_result['questionIds'].index(question2.id) + if first_result['courseUser']['id'] == course_student.id + expect(first_result['liveFeedbackCount'][question_index]).to eq(0) + end + end + end + + context 'when the Administrator gets live feedback statistics' do + let(:administrator) { create(:administrator) } + before { controller_sign_in(controller, administrator) } + + it 'returns OK with the correct live feedback statistics' do + expect(subject).to have_http_status(:success) + json_result = JSON.parse(response.body) + + first_result = json_result.first + + # Check the general structure + expect(first_result).to have_key('courseUser') + expect(first_result['courseUser']).to have_key('id') + expect(first_result['courseUser']).to have_key('name') + expect(first_result['courseUser']).to have_key('role') + expect(first_result['courseUser']).to have_key('isPhantom') + + expect(first_result).to have_key('workflowState') + expect(first_result).to have_key('groups') + expect(first_result).to have_key('liveFeedbackCount') + expect(first_result).to have_key('questionIds') + + # Ensure that the feedback count is correct for the specific questions + question_index = first_result['questionIds'].index(question1.id) + if first_result['courseUser']['id'] == course_student.id + expect(first_result['liveFeedbackCount'][question_index]).to eq(3) + else + expect(first_result['liveFeedbackCount'][question_index]).to eq(0) + end + + # No feedback for the second question, since there is no comment + question_index = first_result['questionIds'].index(question2.id) + if first_result['courseUser']['id'] == course_student.id + expect(first_result['liveFeedbackCount'][question_index]).to eq(0) + end + end + end + end + describe '#live_feedback_history' do let(:question) do create(:course_assessment_question_programming, assessment: assessment).acting_as @@ -173,13 +285,10 @@ let!(:live_feedback) do create(:course_assessment_live_feedback, assessment: assessment, question: question, - creator: course_student) - end - let!(:code) { create(:course_assessment_live_feedback_code, feedback: live_feedback) } - let!(:comment) do - create(:course_assessment_live_feedback_comment, code: code, line_number: 1, - comment: 'This is a test comment') + creator: user, + with_comment: true) end + render_views subject do get :live_feedback_history, as: :json, diff --git a/spec/factories/course_assessment_live_feedback_code.rb b/spec/factories/course_assessment_live_feedback_code.rb index 8469cf30648..ed879b2bcb3 100644 --- a/spec/factories/course_assessment_live_feedback_code.rb +++ b/spec/factories/course_assessment_live_feedback_code.rb @@ -4,5 +4,13 @@ feedback { association(:course_assessment_live_feedback) } filename { 'test_code.rb' } content { 'puts "Hello, World!"' } + + transient do + with_comment { false } + end + + after(:create) do |live_feedback_code, evaluator| + create(:course_assessment_live_feedback_comment, code: live_feedback_code) if evaluator.with_comment + end end end diff --git a/spec/factories/course_assessment_live_feedbacks.rb b/spec/factories/course_assessment_live_feedbacks.rb index 3133b5dc9a9..c934b9867e6 100644 --- a/spec/factories/course_assessment_live_feedbacks.rb +++ b/spec/factories/course_assessment_live_feedbacks.rb @@ -2,7 +2,14 @@ FactoryBot.define do factory :course_assessment_live_feedback, class: Course::Assessment::LiveFeedback do assessment - question { association(:course_assessment_question_programming, assessment: assessment) } - creator { association(:course_user, course: assessment.course) } + question { association(:course_assessment_question, assessment: assessment) } + + transient do + with_comment { false } + end + + after(:create) do |live_feedback, evaluator| + create(:course_assessment_live_feedback_code, feedback: live_feedback, with_comment: evaluator.with_comment) + end end end diff --git a/spec/models/course/assessment/live_feedback_spec.rb b/spec/models/course/assessment/live_feedback_spec.rb new file mode 100644 index 00000000000..0895dc9db30 --- /dev/null +++ b/spec/models/course/assessment/live_feedback_spec.rb @@ -0,0 +1,71 @@ +# frozen_string_literal: true +require 'rails_helper' + +RSpec.describe Course::Assessment::LiveFeedback do + # Associations + it { is_expected.to belong_to(:assessment).class_name('Course::Assessment').inverse_of(:live_feedbacks).required } + it { + is_expected.to belong_to(:question).class_name('Course::Assessment::Question').inverse_of(:live_feedbacks).required + } + it { + is_expected.to have_many(:code). + class_name('Course::Assessment::LiveFeedbackCode'). + inverse_of(:feedback). + dependent(:destroy) + } + + let(:instance) { Instance.default } + with_tenant(:instance) do + let(:assessment) { create(:assessment) } + let(:question) { create(:course_assessment_question_programming, assessment: assessment) } + let(:user) { create(:course_user, course: assessment.course).user } + let(:files) do + [ + Struct.new(:filename, :content).new('test1.rb', 'Hello World'), + Struct.new(:filename, :content).new('test2.rb', 'Goodbye World') + ] + end + + describe '.create_with_codes' do + context 'when the live feedback is successfully created' do + it 'creates a live feedback with associated codes' do + feedback = Course::Assessment::LiveFeedback.create_with_codes( + assessment.id, question.id, user, nil, files + ) + + expect(feedback).to be_persisted + expect(feedback.code.size).to eq(files.size) + expect(feedback.code.map(&:filename)).to match_array(files.map(&:filename)) + expect(feedback.code.map(&:content)).to match_array(files.map(&:content)) + end + end + + context 'when the live feedback fails to save' do + it 'returns nil and logs an error' do + allow_any_instance_of(Course::Assessment::LiveFeedback).to receive(:save).and_return(false) + + expect(Rails.logger).to receive(:error).with(/Failed to save live_feedback/) + feedback = Course::Assessment::LiveFeedback.create_with_codes( + assessment.id, question.id, user, nil, files + ) + + expect(feedback).to be_nil + end + end + + context 'when a live feedback code fails to save' do + it 'logs an error and continues to create the live feedback' do + allow_any_instance_of(Course::Assessment::LiveFeedbackCode).to receive(:save).and_return(false) + + expect(Rails.logger).to receive(:error).with(/Failed to save live_feedback_code/).twice + feedback = Course::Assessment::LiveFeedback.create_with_codes( + assessment.id, question.id, user, nil, files + ) + + expect(feedback).to be_persisted + expect(feedback.code.size).to eq(0) # No codes should be saved + end + end + end + end +end From 3d1c0e75cdffe84aeefd7f6303120f63713568d5 Mon Sep 17 00:00:00 2001 From: yoopie Date: Tue, 3 Sep 2024 20:48:21 +0800 Subject: [PATCH 15/19] refactor(assessment-statistics): abstract translations abstract out the translations of some assessment stsatistics files add translations and localizations for the associated translations --- .../LiveFeedbackDetails.tsx | 16 +-- .../LiveFeedbackHistoryPage.tsx | 13 +- .../LiveFeedbackStatisticsTable.tsx | 55 +------- .../StudentAttemptCountTable.tsx | 68 +--------- .../StudentMarksPerQuestionTable.tsx | 71 ++-------- .../AssessmentStatistics/translations.ts | 125 ++++++++++++++++++ client/locales/en.json | 89 ++++++++++++- client/locales/zh.json | 92 ++++++++++++- 8 files changed, 325 insertions(+), 204 deletions(-) create mode 100644 client/app/bundles/course/assessment/pages/AssessmentStatistics/translations.ts diff --git a/client/app/bundles/course/assessment/pages/AssessmentStatistics/LiveFeedbackHistory/LiveFeedbackDetails.tsx b/client/app/bundles/course/assessment/pages/AssessmentStatistics/LiveFeedbackHistory/LiveFeedbackDetails.tsx index 786008c76df..fa4cc642cfa 100644 --- a/client/app/bundles/course/assessment/pages/AssessmentStatistics/LiveFeedbackHistory/LiveFeedbackDetails.tsx +++ b/client/app/bundles/course/assessment/pages/AssessmentStatistics/LiveFeedbackHistory/LiveFeedbackDetails.tsx @@ -1,6 +1,5 @@ import { FC, useRef, useState } from 'react'; import ReactAce from 'react-ace'; -import { defineMessages } from 'react-intl'; import { Box, Card, CardContent, Drawer, Typography } from '@mui/material'; import { LiveFeedbackCodeAndComments } from 'types/course/assessment/submission/liveFeedback'; @@ -8,20 +7,7 @@ import { parseLanguages } from 'course/assessment/submission/utils'; import EditorField from 'lib/components/core/fields/EditorField'; import useTranslation from 'lib/hooks/useTranslation'; -const translations = defineMessages({ - 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}', - }, -}); +import translations from '../translations'; interface Props { file: LiveFeedbackCodeAndComments; diff --git a/client/app/bundles/course/assessment/pages/AssessmentStatistics/LiveFeedbackHistory/LiveFeedbackHistoryPage.tsx b/client/app/bundles/course/assessment/pages/AssessmentStatistics/LiveFeedbackHistory/LiveFeedbackHistoryPage.tsx index 5fc3a403eb1..6d6abdb22dc 100644 --- a/client/app/bundles/course/assessment/pages/AssessmentStatistics/LiveFeedbackHistory/LiveFeedbackHistoryPage.tsx +++ b/client/app/bundles/course/assessment/pages/AssessmentStatistics/LiveFeedbackHistory/LiveFeedbackHistoryPage.tsx @@ -1,5 +1,4 @@ import { FC, useState } from 'react'; -import { defineMessages } from 'react-intl'; import { Slider, Typography } from '@mui/material'; import Accordion from 'lib/components/core/layouts/Accordion'; @@ -11,20 +10,10 @@ import { getLiveFeedbackHistory, getLiveFeedbadkQuestionInfo, } from '../selectors'; +import translations from '../translations'; import LiveFeedbackDetails from './LiveFeedbackDetails'; -const translations = defineMessages({ - questionTitle: { - id: 'course.assessment.liveFeedback.questionTitle', - defaultMessage: 'Question {index}', - }, - feedbackTimingTitle: { - id: 'course.assessment.liveFeedback.feedbackTimingTitle', - defaultMessage: 'Used at: {usedAt}', - }, -}); - interface Props { questionNumber: number; } diff --git a/client/app/bundles/course/assessment/pages/AssessmentStatistics/LiveFeedbackStatisticsTable.tsx b/client/app/bundles/course/assessment/pages/AssessmentStatistics/LiveFeedbackStatisticsTable.tsx index 8a4609bab99..082ad2b4991 100644 --- a/client/app/bundles/course/assessment/pages/AssessmentStatistics/LiveFeedbackStatisticsTable.tsx +++ b/client/app/bundles/course/assessment/pages/AssessmentStatistics/LiveFeedbackStatisticsTable.tsx @@ -1,5 +1,4 @@ import { FC, ReactNode, useEffect, useState } from 'react'; -import { defineMessages } from 'react-intl'; import { useParams } from 'react-router-dom'; import { Box, Chip, Typography } from '@mui/material'; import palette from 'theme/palette'; @@ -17,55 +16,9 @@ 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'; -const translations = defineMessages({ - name: { - id: 'course.assessment.statistics.name', - defaultMessage: 'Name', - }, - group: { - id: 'course.assessment.statistics.group', - defaultMessage: 'Group', - }, - workflowState: { - id: 'course.assessment.statistics.workflowState', - defaultMessage: 'Status', - }, - questionIndex: { - id: 'course.assessment.statistics.questionIndex', - defaultMessage: 'Q{index}', - }, - totalFeedbackCount: { - id: 'course.assessment.statistics.totalFeedbackCount', - defaultMessage: 'Total', - }, - searchText: { - id: 'course.assessment.statistics.searchText', - defaultMessage: 'Search by Name or Groups', - }, - filename: { - id: 'course.assessment.statistics.filename', - defaultMessage: 'Question-level Live Feedback Statistics for {assessment}', - }, - closePrompt: { - id: 'course.assessment.statistics.closePrompt', - defaultMessage: 'Close', - }, - liveFeedbackHistoryPromptTitle: { - id: 'course.assessment.statistics.liveFeedbackHistoryPromptTitle', - defaultMessage: 'Live Feedback History', - }, - legendLowerUsage: { - id: 'course.assessment.statistics.legendLowerUsage', - defaultMessage: 'Lower Usage', - }, - legendHigherusage: { - id: 'course.assessment.statistics.legendHigherusage', - defaultMessage: 'Higher Usage', - }, -}); - interface Props { includePhantom: boolean; liveFeedbackStatistics: AssessmentLiveFeedbackStatistics[]; @@ -254,7 +207,7 @@ const LiveFeedbackStatisticsTable: FC = (props) => { .toString() : '', }, - title: t(translations.totalFeedbackCount), + title: t(translations.total), cell: (datum): ReactNode => { const totalFeedbackCount = datum.liveFeedbackCount ? datum.liveFeedbackCount.reduce( @@ -299,7 +252,7 @@ const LiveFeedbackStatisticsTable: FC = (props) => {
= (props) => { rowsPerPage: [DEFAULT_TABLE_ROWS_PER_PAGE], showAllRows: true, }} - search={{ searchPlaceholder: t(translations.searchText) }} + search={{ searchPlaceholder: t(translations.nameGroupsSearchText) }} toolbar={{ show: true }} /> = (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', @@ -251,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 773100e476d..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,63 +18,9 @@ import useTranslation from 'lib/hooks/useTranslation'; import LastAttemptIndex from './AnswerDisplay/LastAttempt'; import { getClassNameForMarkCell } from './classNameUtils'; import { getAssessmentStatistics } from './selectors'; +import translations from './translations'; import { getJointGroupsName, translateStatus } from './utils'; -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', - }, -}); - interface Props { includePhantom: boolean; } @@ -234,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 = @@ -281,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/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/locales/en.json b/client/locales/en.json index 3e0d6d43ae1..a891dc16d8e 100644 --- a/client/locales/en.json +++ b/client/locales/en.json @@ -2435,6 +2435,93 @@ "course.assessment.skills.SkillsTable.uncategorised": { "defaultMessage": "Uncategorised Skills" }, + "course.assessment.liveFeedback.questionTitle": { + "defaultMessage": "Question {index}" + }, + "course.assessment.liveFeedback.feedbackTimingTitle": { + "defaultMessage": "Used at: {usedAt}" + }, + "course.assessment.liveFeedback.liveFeedbackName": { + "defaultMessage": "Live Feedback" + }, + "course.assessment.liveFeedback.comments": { + "defaultMessage": "Comments" + }, + "course.assessment.liveFeedback.lineHeader": { + "defaultMessage": "Line {lineNumber}" + }, + "course.assessment.statistics.answers": { + "defaultMessage": "Answers" + }, + "course.assessment.statistics.attempts.filename": { + "defaultMessage": "Question-level Attempt Statistics for {assessment}" + }, + "course.assessment.statistics.attempts.greenCellLegend": { + "defaultMessage": "Correct" + }, + "course.assessment.statistics.attempts.redCellLegend": { + "defaultMessage": "Incorrect" + }, + "course.assessment.statistics.closePrompt": { + "defaultMessage": "Close" + }, + "course.assessment.statistics.grader": { + "defaultMessage": "Grader" + }, + "course.assessment.statistics.grayCellLegend": { + "defaultMessage": "Undecided (question is Non-autogradable)" + }, + "course.assessment.statistics.group": { + "defaultMessage": "Group" + }, + "course.assessment.statistics.legendHigherusage": { + "defaultMessage": "Higher Usage" + }, + "course.assessment.statistics.legendLowerUsage": { + "defaultMessage": "Lower Usage" + }, + "course.assessment.statistics.liveFeedback.filename": { + "defaultMessage": "Question-level Live Feedback Statistics for {assessment}" + }, + "course.assessment.statistics.liveFeedbackHistoryPromptTitle": { + "defaultMessage": "Live Feedback History" + }, + "course.assessment.statistics.marks.filename": { + "defaultMessage": "Question-level Marks Statistics for {assessment}" + }, + "course.assessment.statistics.marks.greenCellLegend": { + "defaultMessage": ">= 0.5 * Maximum Grade" + }, + "course.assessment.statistics.marks.redCellLegend": { + "defaultMessage": "< 0.5 * Maximum Grade" + }, + "course.assessment.statistics.name": { + "defaultMessage": "Name" + }, + "course.assessment.statistics.nameGroupsGraderSearchText": { + "defaultMessage": "Search by Student Name, Group or Grader Name" + }, + "course.assessment.statistics.nameGroupsSearchText": { + "defaultMessage": "Search by Name or Groups" + }, + "course.assessment.statistics.noSubmission": { + "defaultMessage": "No submission yet" + }, + "course.assessment.statistics.onlyForAutogradableAssessment": { + "defaultMessage": "This table is only displayed for Assessment with at least one Autograded Questions" + }, + "course.assessment.statistics.questionDisplayTitle": { + "defaultMessage": "Q{index} for {student}" + }, + "course.assessment.statistics.questionIndex": { + "defaultMessage": "Q{index}" + }, + "course.assessment.statistics.total": { + "defaultMessage": "Total" + }, + "course.assessment.statistics.workflowState": { + "defaultMessage": "Status" + }, "course.assessment.statistics.ancestorFail": { "defaultMessage": "Failed to fetch past iterations of this assessment." }, @@ -7811,4 +7898,4 @@ "users.troubleSigningIn": { "defaultMessage": "Trouble signing in?" } -} +} \ No newline at end of file diff --git a/client/locales/zh.json b/client/locales/zh.json index cd0c2f5982a..a297321e32d 100644 --- a/client/locales/zh.json +++ b/client/locales/zh.json @@ -2384,6 +2384,96 @@ "course.assessment.skills.SkillsTable.uncategorised": { "defaultMessage": "未分类技能" }, + "course.assessment.liveFeedback.questionTitle": { + "defaultMessage": "题目 {index}" + }, + "course.assessment.liveFeedback.feedbackTimingTitle": { + "defaultMessage": "使用时间:{usedAt}" + }, + "course.assessment.liveFeedback.liveFeedbackName": { + "defaultMessage": "实时反馈" + }, + "course.assessment.liveFeedback.comments": { + "defaultMessage": "评论" + }, + "course.assessment.liveFeedback.lineHeader": { + "defaultMessage": "第 {lineNumber} 行" + }, + "course.assessment.statistics.answers": { + "defaultMessage": "答案" + }, + "course.assessment.statistics.attempts.filename": { + "defaultMessage": "{assessment} 的题目尝试统计" + }, + "course.assessment.statistics.attempts.greenCellLegend": { + "defaultMessage": "正确" + }, + "course.assessment.statistics.attempts.redCellLegend": { + "defaultMessage": "错误" + }, + "course.assessment.statistics.closePrompt": { + "defaultMessage": "关闭" + }, + "course.assessment.statistics.grader": { + "defaultMessage": "评分者" + }, + "course.assessment.statistics.grayCellLegend": { + "defaultMessage": "未决定 (问题无法自动评分)" + }, + "course.assessment.statistics.group": { + "defaultMessage": "组别" + }, + "course.assessment.statistics.legendHigherusage": { + "defaultMessage": "使用较多" + }, + "course.assessment.statistics.legendLowerUsage": { + "defaultMessage": "使用较少" + }, + "course.assessment.statistics.liveFeedback.filename": { + "defaultMessage": "{assessment} 的题目实时反馈统计" + }, + "course.assessment.statistics.liveFeedbackHistoryPromptTitle": { + "defaultMessage": "实时反馈历史" + }, + "course.assessment.statistics.marks.filename": { + "defaultMessage": "{assessment} 的题目分数统计" + }, + "course.assessment.statistics.marks.greenCellLegend": { + "defaultMessage": ">= 0.5 * 最高分" + }, + "course.assessment.statistics.marks.redCellLegend": { + "defaultMessage": "< 0.5 * 最高分" + }, + "course.assessment.statistics.name": { + "defaultMessage": "姓名" + }, + "course.assessment.statistics.nameGroupsGraderSearchText": { + "defaultMessage": "按学生姓名、组别或评分者姓名搜索" + }, + "course.assessment.statistics.nameGroupsSearchText": { + "defaultMessage": "按姓名或组别搜索" + }, + "course.assessment.statistics.noSubmission": { + "defaultMessage": "尚未提交" + }, + "course.assessment.statistics.onlyForAutogradableAssessment": { + "defaultMessage": "此表仅适用于包含至少一个自动评分问题的评估" + }, + "course.assessment.statistics.questionDisplayTitle": { + "defaultMessage": "{student} 的题目 {index}" + }, + "course.assessment.statistics.questionIndex": { + "defaultMessage": "题目 {index}" + }, + "course.assessment.statistics.totalFeedbackCount": { + "defaultMessage": "总计" + }, + "course.assessment.statistics.totalGrade": { + "defaultMessage": "总分" + }, + "course.assessment.statistics.workflowState": { + "defaultMessage": "状态" + }, "course.assessment.statistics.ancestorFail": { "defaultMessage": "无法获取此测试的过去结果。" }, @@ -7760,4 +7850,4 @@ "users.troubleSigningIn": { "defaultMessage": "登录遇到问题?" } -} +} \ No newline at end of file From aeaa54a0cfb1da05f5fea347dc5fa4e47c9338c7 Mon Sep 17 00:00:00 2001 From: yoopie Date: Wed, 11 Sep 2024 13:51:19 +0800 Subject: [PATCH 16/19] style(live-feedback): change translations - conform to new standard, all user facing "verbs" are "Get Help" - "Get Help" will describe the act of getting live feedback - all other usage of the terms "Live Help" and "Get Help" will instead be "Live Feedback" --- .../course/assessment/question/programming.rb | 4 ++-- .../pages/CodaveriSettings/translations.ts | 9 +++++---- .../components/AssessmentForm/translations.ts | 5 +++-- .../AssessmentStatisticsPage.tsx | 8 ++++---- .../components/button/LiveFeedbackButton.tsx | 2 +- .../bundles/course/assessment/translations.ts | 4 ++-- client/locales/en.json | 18 +++++++++--------- .../admin/codaveri_settings_controller_spec.rb | 4 ++-- .../question/programming_controller_spec.rb | 4 ++-- .../assessment/question/programming_spec.rb | 18 +++++++++--------- 10 files changed, 39 insertions(+), 37 deletions(-) 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/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/pages/AssessmentStatistics/AssessmentStatisticsPage.tsx b/client/app/bundles/course/assessment/pages/AssessmentStatistics/AssessmentStatisticsPage.tsx index cded46a0d41..9f7cf2a2104 100644 --- a/client/app/bundles/course/assessment/pages/AssessmentStatistics/AssessmentStatisticsPage.tsx +++ b/client/app/bundles/course/assessment/pages/AssessmentStatistics/AssessmentStatisticsPage.tsx @@ -80,7 +80,7 @@ const tabMapping = (includePhantom: boolean): Record => { ), duplicationHistory: , - liveHelp: , + liveFeedback: , }; }; @@ -163,9 +163,9 @@ const AssessmentStatisticsPage: FC = () => { {statistics.assessment?.liveFeedbackEnabled && ( )} 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 (