diff --git a/app/controllers/course/assessment/question/controller.rb b/app/controllers/course/assessment/question/controller.rb index e0be1f5ba31..15f2d157c21 100644 --- a/app/controllers/course/assessment/question/controller.rb +++ b/app/controllers/course/assessment/question/controller.rb @@ -45,7 +45,9 @@ def load_question_assessment_for(question) end def update_skill_ids_if_params_present(question_assessment_params) - skill_ids_params = question_assessment_params[:skill_ids] unless question_assessment_params[:skill_ids].nil? + return if question_assessment_params.nil? || question_assessment_params[:skill_ids].nil? + + skill_ids_params = question_assessment_params[:skill_ids] @question_assessment.skill_ids = skill_ids_params unless skill_ids_params.nil? end diff --git a/app/controllers/course/assessment/question/text_responses_controller.rb b/app/controllers/course/assessment/question/text_responses_controller.rb index d3a6ca2789e..0205b21fec2 100644 --- a/app/controllers/course/assessment/question/text_responses_controller.rb +++ b/app/controllers/course/assessment/question/text_responses_controller.rb @@ -88,7 +88,15 @@ def text_response_question_params ) else permitted_params.concat( - [solutions_attributes: [:_destroy, :id, :solution_type, :solution, :grade, :explanation]] + [solutions_attributes: [ + :_destroy, :id, :solution_type, :solution, :grade, :explanation, + test_spreadsheet_attributes: [ + :id, :file, :_destroy, :is_randomization_enabled, :num_random_tests, + :is_random_seed_fixed, :test_random_seed, + :is_timestamp_fixed, :test_timestamp, + :variables + ] + ]] ) end params.require(:question_text_response).permit(*permitted_params) diff --git a/app/controllers/course/assessment/submission/submissions_controller.rb b/app/controllers/course/assessment/submission/submissions_controller.rb index c77597c68d0..cbccf86e429 100644 --- a/app/controllers/course/assessment/submission/submissions_controller.rb +++ b/app/controllers/course/assessment/submission/submissions_controller.rb @@ -79,7 +79,12 @@ def reevaluate_answer return head :bad_request if @answer.nil? job = @answer.auto_grade!(redirect_to_path: nil, reduce_priority: true) - render partial: 'jobs/submitted', locals: { job: job.job } + if job.nil? + @answer.reload + render @answer + else + render partial: 'jobs/submitted', locals: { job: job.job } + end end def generate_feedback diff --git a/app/models/course/assessment/answer/text_response.rb b/app/models/course/assessment/answer/text_response.rb index 973bf0643cf..4845621150e 100644 --- a/app/models/course/assessment/answer/text_response.rb +++ b/app/models/course/assessment/answer/text_response.rb @@ -19,6 +19,11 @@ def normalized_answer_text answer_text.strip.encode(universal_newline: true) end + # If text response grading requires formula evaluation, it should be graded in a job. + def grade_inline? + question.actable.solutions.all? { |solution| !solution.spreadsheet_formula? } + end + def download(dir) download_answer(dir) unless question.actable.file_upload_question? attachments.each { |a| download_attachment(a, dir) } diff --git a/app/models/course/assessment/question/text_response_solution.rb b/app/models/course/assessment/question/text_response_solution.rb index 74767c14314..69d03e523be 100644 --- a/app/models/course/assessment/question/text_response_solution.rb +++ b/app/models/course/assessment/question/text_response_solution.rb @@ -1,6 +1,6 @@ # frozen_string_literal: true class Course::Assessment::Question::TextResponseSolution < ApplicationRecord - enum :solution_type, [:exact_match, :keyword] + enum :solution_type, [:exact_match, :keyword, :regex, :spreadsheet_formula] before_validation :strip_whitespace before_save :sanitize_explanation @@ -12,6 +12,18 @@ class Course::Assessment::Question::TextResponseSolution < ApplicationRecord belongs_to :question, class_name: 'Course::Assessment::Question::TextResponse', inverse_of: :solutions + has_one :test_spreadsheet, class_name: 'Course::Assessment::Question::TextResponseSolutionSpreadsheet', + inverse_of: :solution, dependent: :destroy, autosave: true + accepts_nested_attributes_for :test_spreadsheet, allow_destroy: true + + def test_spreadsheet_attributes=(attributes) + if ActiveRecord::Type::Boolean.new.cast(attributes[:_destroy]) + test_spreadsheet&.mark_for_destruction + else + spreadsheet = test_spreadsheet || build_test_spreadsheet + spreadsheet.assign_params(attributes) + end + end def initialize_duplicate(duplicator, other) self.question = duplicator.duplicate(other.question) diff --git a/app/models/course/assessment/question/text_response_solution_spreadsheet.rb b/app/models/course/assessment/question/text_response_solution_spreadsheet.rb new file mode 100644 index 00000000000..39a72345d5b --- /dev/null +++ b/app/models/course/assessment/question/text_response_solution_spreadsheet.rb @@ -0,0 +1,25 @@ +# frozen_string_literal: true +class Course::Assessment::Question::TextResponseSolutionSpreadsheet < ApplicationRecord + belongs_to :solution, class_name: 'Course::Assessment::Question::TextResponseSolution', + inverse_of: :test_spreadsheet + + has_one_attachment + + validates :attachment_reference, presence: true + + def assign_params(params) + self.file = params[:file] if params[:file] + self.is_randomization_enabled = params[:is_randomization_enabled] if params.key?(:is_randomization_enabled) + self.num_random_tests = params[:num_random_tests] if params.key?(:num_random_tests) + self.is_random_seed_fixed = params[:is_random_seed_fixed] if params.key?(:is_random_seed_fixed) + self.test_random_seed = params[:test_random_seed] if params.key?(:test_random_seed) + self.is_timestamp_fixed = params[:is_timestamp_fixed] if params.key?(:is_timestamp_fixed) + self.test_timestamp = params[:test_timestamp] if params.key?(:test_timestamp) + self.variables = JSON.parse(params[:variables]) if params[:variables] + end + + def container_filename + ext = attachment&.name ? File.extname(attachment.name) : '' + "sheet_#{id}#{ext}" + end +end diff --git a/app/services/course/assessment/answer/text_response_auto_grading_service.rb b/app/services/course/assessment/answer/text_response_auto_grading_service.rb index 10ffd6d3054..e5c895e5ecb 100644 --- a/app/services/course/assessment/answer/text_response_auto_grading_service.rb +++ b/app/services/course/assessment/answer/text_response_auto_grading_service.rb @@ -2,13 +2,25 @@ class Course::Assessment::Answer::TextResponseAutoGradingService < \ Course::Assessment::Answer::AutoGradingService def evaluate(answer) - answer.correct, grade, messages = evaluate_answer(answer.actable) - answer.auto_grading.result = { messages: messages } + answer.correct, grade, evaluation_results = evaluate_answer(answer.actable) + answer.auto_grading.result = { + messages: explanations_for(evaluation_results), + evaluation_results: evaluation_results.map do |result| + result_json = { + solution_id: result.solution.id, + grade: result.grade + } + result_json[:tests] = result.results if result.results + result_json + end + } grade end private + SolutionEvaluationResult = Struct.new(:solution, :grade, :explanation, :results) + # Grades the given answer. # # @param [Course::Assessment::Answer::TextResponse] answer The answer specified by the @@ -18,28 +30,39 @@ def evaluate(answer) def evaluate_answer(answer) question = answer.question.actable answer_text = answer.normalized_answer_text - exact_matches, keywords = question.solutions.partition(&:exact_match?) + solutions_by_type = question.solutions.group_by(&:solution_type).symbolize_keys - solutions = find_exact_match(answer_text, exact_matches) - # If there is no exact match, we fall back to keyword matches. + exact_match_solution = find_correct_exact_match_solution(answer_text, solutions_by_type[:exact_match] || []) # Solutions are always kept in an array for easier use of #grade_for and #explanations_for - solutions = solutions.present? ? [solutions] : find_keywords(answer_text, keywords) - + evaluation_results = + if exact_match_solution + [ + SolutionEvaluationResult.new( + exact_match_solution, + exact_match_solution.grade, + exact_match_solution.explanation + ) + ] + else + evaluate_correct_keyword_solutions(answer_text, solutions_by_type[:keyword] || []) + + evaluate_correct_regex_solutions(answer_text, solutions_by_type[:regex] || []) + + evaluate_spreadsheet_formula_solutions(answer_text, solutions_by_type[:spreadsheet_formula] || []) + end [ - correctness_for(question, solutions), - grade_for(question, solutions), - explanations_for(solutions) + correctness_for(question, evaluation_results), + grade_for(question, evaluation_results), + evaluation_results ] end # Returns one solution that exactly matches the answer. # # @param [String] answer_text The answer text entered by the student. - # @param [Array] solutions The solutions + # @param [Array] solutions The solutions # to be matched against answer_text. - # @return [Course::Assessment::Question::TextResponseSolution] Solution that exactly matches + # @return [Course::Assessment::Question::TextResponse::Solution] Solution that exactly matches # the answer. - def find_exact_match(answer_text, solutions) + def find_correct_exact_match_solution(answer_text, solutions) # comparison is case insensitive solutions.find { |s| s.solution.encode(universal_newline: true).casecmp(answer_text) == 0 } end @@ -47,13 +70,118 @@ def find_exact_match(answer_text, solutions) # Returns the keywords found in the given answer text. # # @param [String] answer_text The answer text entered by the student. - # @param [Array] solutions The solutions + # @param [Array] solutions The solutions # to be matched against answer_text. - # @return [Array] Solutions that matches + # @return [Array] Solutions that matches # the answer. - def find_keywords(answer_text, solutions) + def evaluate_correct_keyword_solutions(answer_text, solutions) # TODO(minqi): Add tokenizer and stemmer for more natural keyword matching. - solutions.select { |s| answer_text.downcase.include?(s.solution.downcase) } + solutions.select { |s| answer_text.downcase.include?(s.solution.downcase) }. + map { |s| SolutionEvaluationResult.new(s, s.grade, s.explanation) } + end + + # Returns the regexes that match the given answer text. + # + # @param [String] answer_text The answer text entered by the student. + # @param [Array] solutions The solutions + # to be matched against answer_text. + # @return [Array] Solutions that matches + # the answer. + def evaluate_correct_regex_solutions(answer_text, solutions) + solutions.map do |s| + match = answer_text.match?(Regexp.new(s.solution, Regexp::IGNORECASE, timeout: 15.0)) + SolutionEvaluationResult.new(s, s.grade, s.explanation) if match + rescue Regexp::TimeoutError + SolutionEvaluationResult.new( + s, 0, I18n.t('errors.course.assessment.text_response_auto_grading.grade.regex_timeout') + ) + end.compact + end + + def normalize_formula_text(text) + formula_text = text.strip + formula_text.start_with?('=') ? formula_text : "=#{formula_text}" + end + + def container_test_num_random_tests(solution) + solution.is_randomization_enabled ? solution.test_spreadsheet.num_random_tests : 0 + end + + def container_test_random_seed(solution) + solution.is_random_seed_fixed ? solution.test_spreadsheet.test_random_seed : nil + end + + def container_test_timestamp(solution) + solution.is_timestamp_fixed ? solution.test_spreadsheet.test_timestamp : nil + end + + def save_container_test_metadata(container, answer_text, solutions) + test_data = { + answer: normalize_formula_text(answer_text), + solutions: solutions.map do |solution| + { + id: solution.id, + solution: normalize_formula_text(solution.solution), + variables: solution.test_spreadsheet.variables, + num_random_tests: container_test_num_random_tests(solution), + random_seed: container_test_random_seed(solution), + test_timestamp: container_test_timestamp(solution), + spreadsheet: + { + id: solution.test_spreadsheet.id, + filename: solution.test_spreadsheet.container_filename + } + } + end + }.to_json + container.store_file("#{CoursemologyDockerContainer::HOME_PATH}/tests.json", test_data) + end + + def save_container_test_spreadsheets(container, solutions) + solutions.each do |solution| + next unless solution.test_spreadsheet + + solution.test_spreadsheet.attachment.open(binmode: true) do |file| + tar = StringIO.new(Docker::Util.create_tar({ solution.test_spreadsheet.container_filename => file.read })) + container.archive_in_stream(CoursemologyDockerContainer::HOME_PATH) do + tar.read(Excon.defaults[:chunk_size]).to_s + end + end + end + end + + def process_spreadsheet_container_evaluation_results(container, solutions) + results = JSON.parse(container.read_file("#{CoursemologyDockerContainer::HOME_PATH}/result.json")) + results['evaluated_solutions'].map do |result| + solution = solutions.find { |s| s.id == result['solution_id'] } + SolutionEvaluationResult.new( + solution, + result['results'].all? { |r| r['correct'] } ? solution.grade : 0, + if result['results'].any? { |r| r.key?('expectedError') || r.key?('outputError') } + I18n.t('errors.course.assessment.text_response_auto_grading.grade.evaluation_failed') + else + solution.explanation + end, + result['results'] + ) + end + end + + # Returns the spreadsheet formula solutions that matches the given answer text. + def evaluate_spreadsheet_formula_solutions(answer_text, solutions) + container = CoursemologyDockerContainer.create( + 'coursemology/evaluator-image-python:3.14', + argv: ["#{CoursemologyDockerContainer::HOME_PATH}/autograde_spreadsheet.py"], + entrypoint: ['python3'] + ) + save_container_test_metadata(container, answer_text, solutions) + save_container_test_spreadsheets(container, solutions) + container.store_file("#{CoursemologyDockerContainer::HOME_PATH}/autograde_spreadsheet.py", + File.read(Rails.root.join('lib', 'evaluator_scripts', 'python', 'autograde_spreadsheet.py'))) + container.execute_package + process_spreadsheet_container_evaluation_results(container, solutions) + ensure + container&.delete end # Returns the grade for a question with all matched solutions. @@ -63,30 +191,27 @@ def find_keywords(answer_text, solutions) # # @param [Course::Assessment::Question::TextResponse] question The question answered by the # student. - # @param [Array] solutions The solutions that - # matches the student's answer. + # @param [Array] evaluation_results The evaluation results for the student's answer. # @return [Integer] The grade for the question. - def grade_for(question, solutions) - [solutions.map(&:grade).reduce(0, :+), question.maximum_grade].min + def grade_for(question, evaluation_results) + [evaluation_results.map(&:grade).reduce(0, :+), question.maximum_grade].min end # Returns the explanations for the given options. # - # @param [Array] solutions The solutions to - # obtain the explanations for. + # @param [Array] evaluation_results The evaluation results for the student's answer. # @return [Array] The explanations for the given solutions. - def explanations_for(solutions) - solutions.map(&:explanation).tap(&:compact!) + def explanations_for(evaluation_results) + evaluation_results.map(&:explanation).tap(&:compact!) end # Mark the correctness of the answer based on solutions. # # @param [Course::Assessment::Question::TextResponse] question The question answered by the # student. - # @param [Array] solutions The solutions that - # matches the student's answer. + # @param [Array] evaluation_results The evaluation results for the student's answer. # @return [Boolean] correct True if the answer is correct. - def correctness_for(question, solutions) - solutions.map(&:grade).sum >= question.maximum_grade + def correctness_for(question, evaluation_results) + grade_for(question, evaluation_results) >= question.maximum_grade end end diff --git a/app/views/course/assessment/answer/text_responses/_text_response.json.jbuilder b/app/views/course/assessment/answer/text_responses/_text_response.json.jbuilder index f20f0de5f8f..8f83be3c093 100644 --- a/app/views/course/assessment/answer/text_responses/_text_response.json.jbuilder +++ b/app/views/course/assessment/answer/text_responses/_text_response.json.jbuilder @@ -21,6 +21,22 @@ json.attachments answer.attachments do |attachment| end last_attempt = last_attempt(answer) +attempt = answer.current_answer? ? last_attempt : answer + +job = attempt&.auto_grading&.job + +if job + json.autograding do + json.path job_path(job) if job.submitted? + json.partial! "jobs/#{job.status}", job: job + end +end + +if attempt.submitted? && !attempt.auto_grading + json.autograding do + json.status :submitted + end +end if answer.can_read_grade?(current_ability) json.explanation do @@ -33,6 +49,24 @@ if answer.can_read_grade?(current_ability) end end +if ( + can_grade || (@assessment.show_rubric_to_students? && @submission.published?) +) && last_attempt&.auto_grading&.result + graded_solutions = + last_attempt.auto_grading.result['evaluation_results'].index_by do |result| + result['solution_id'] + end + json.solutionResults answer.question.specific.solutions do |solution| + result = graded_solutions[solution.id] + json.id solution.id + json.maximumGrade solution.grade.to_f + json.grade result['grade'].to_f if result + json.solution solution.solution + json.solutionType solution.solution_type + json.tests result['tests'] if result && result['tests'] + end +end + # Required in response of reload_answer and submit_answer to update past answers with the latest_attempt # Removing this check will cause it to render the latestAnswer recursively if answer.current_answer? && !last_attempt.current_answer? diff --git a/app/views/course/assessment/question/text_responses/_solution_details.json.jbuilder b/app/views/course/assessment/question/text_responses/_solution_details.json.jbuilder index 6ee5c0857cc..0f512ef5e68 100644 --- a/app/views/course/assessment/question/text_responses/_solution_details.json.jbuilder +++ b/app/views/course/assessment/question/text_responses/_solution_details.json.jbuilder @@ -5,4 +5,20 @@ json.solutions question.solutions do |sol| json.solution sol.solution json.grade sol.grade json.explanation sol.explanation + if sol.test_spreadsheet + json.spreadsheet do + json.id sol.test_spreadsheet.id + json.file do + json.name sol.test_spreadsheet.attachment.name + json.url attachment_reference_path(sol.test_spreadsheet.attachment) + end + json.isRandomizationEnabled sol.test_spreadsheet.is_randomization_enabled + json.isRandomSeedFixed sol.test_spreadsheet.is_random_seed_fixed + json.testRandomSeed sol.test_spreadsheet.test_random_seed + json.isTimestampFixed sol.test_spreadsheet.is_timestamp_fixed + json.testTimestamp sol.test_spreadsheet.test_timestamp + json.numRandomTests sol.test_spreadsheet.num_random_tests + json.variables sol.test_spreadsheet.variables + end + end end diff --git a/client/app/api/course/Assessment/Question/TextResponse.ts b/client/app/api/course/Assessment/Question/TextResponse.ts index 56b06b466d6..b09898564e9 100644 --- a/client/app/api/course/Assessment/Question/TextResponse.ts +++ b/client/app/api/course/Assessment/Question/TextResponse.ts @@ -1,7 +1,4 @@ -import { - TextResponseFormData, - TextResponsePostData, -} from 'types/course/assessment/question/text-responses'; +import { TextResponseFormData } from 'types/course/assessment/question/text-responses'; import { APIResponse, JustRedirect } from 'api/types'; @@ -26,11 +23,11 @@ export default class TextResponseAPI extends BaseAPI { return this.client.get(`${this.#urlPrefix}/${id}/edit`); } - create(data: TextResponsePostData): APIResponse { + create(data: FormData): APIResponse { return this.client.post(`${this.#urlPrefix}`, data); } - update(id: number, data: TextResponsePostData): APIResponse { + update(id: number, data: FormData): APIResponse { return this.client.patch(`${this.#urlPrefix}/${id}`, data); } } diff --git a/client/app/assets/icons/a-z.svg b/client/app/assets/icons/a-z.svg new file mode 100644 index 00000000000..163aafc2d60 --- /dev/null +++ b/client/app/assets/icons/a-z.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/client/app/assets/icons/one-nine.svg b/client/app/assets/icons/one-nine.svg new file mode 100644 index 00000000000..db9255e557f --- /dev/null +++ b/client/app/assets/icons/one-nine.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/client/app/assets/icons/randomize.svg b/client/app/assets/icons/randomize.svg new file mode 100644 index 00000000000..d2a1b7e1df8 --- /dev/null +++ b/client/app/assets/icons/randomize.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/client/app/bundles/course/assessment/question/text-responses/__test__/operations.test.ts b/client/app/bundles/course/assessment/question/text-responses/__test__/operations.test.ts index b7f3b21106f..1ba52687f66 100644 --- a/client/app/bundles/course/assessment/question/text-responses/__test__/operations.test.ts +++ b/client/app/bundles/course/assessment/question/text-responses/__test__/operations.test.ts @@ -68,22 +68,16 @@ describe('solutions_attributes request payload', () => { ]), ); - expect(spy).toHaveBeenCalledWith( - expect.objectContaining({ - question_text_response: expect.objectContaining({ - solutions_attributes: [ - expect.objectContaining({ - id: 1, - solution: 'exact answer', - solution_type: 'exact_match', - grade: 5, - explanation: 'well done', - _destroy: undefined, - }), - ], - }), - }), - ); + expect(spy).toHaveBeenCalledWith(expect.any(FormData)); + const fd = spy.mock.calls[0][0] as FormData; + const base = 'question_text_response[solutions_attributes][0]'; + expect(fd.get(`${base}[id]`)).toBe('1'); + expect(fd.get(`${base}[solution]`)).toBe('exact answer'); + expect(fd.get(`${base}[solution_type]`)).toBe('exact_match'); + expect(fd.get(`${base}[grade]`)).toBe('5'); + expect(fd.get(`${base}[explanation]`)).toBe('well done'); + // not deleted — _destroy absent + expect(fd.get(`${base}[_destroy]`)).toBeNull(); }); it('omits id for draft (unsaved) solutions', async () => { @@ -106,18 +100,15 @@ describe('solutions_attributes request payload', () => { ]), ); - expect(spy).toHaveBeenCalledWith( - expect.objectContaining({ - question_text_response: expect.objectContaining({ - solutions_attributes: [ - expect.objectContaining({ id: undefined, solution: 'new answer' }), - ], - }), - }), - ); + expect(spy).toHaveBeenCalledWith(expect.any(FormData)); + const fd = spy.mock.calls[0][0] as FormData; + const base = 'question_text_response[solutions_attributes][0]'; + // draft: true causes id to be omitted (undefined → not appended) + expect(fd.get(`${base}[id]`)).toBeNull(); + expect(fd.get(`${base}[solution]`)).toBe('new answer'); }); - it('sends _destroy: true for solutions marked toBeDeleted', async () => { + it('sends _destroy for solutions marked toBeDeleted', async () => { const spy = jest.spyOn( CourseAPI.assessment.question.textResponse, 'update', @@ -138,15 +129,55 @@ describe('solutions_attributes request payload', () => { ]), ); - expect(spy).toHaveBeenCalledWith( - questionId, - expect.objectContaining({ - question_text_response: expect.objectContaining({ - solutions_attributes: [ - expect.objectContaining({ id: 2, _destroy: true }), - ], - }), - }), + expect(spy).toHaveBeenCalledWith(questionId, expect.any(FormData)); + const fd = spy.mock.calls[0][1] as FormData; + const base = 'question_text_response[solutions_attributes][0]'; + expect(fd.get(`${base}[id]`)).toBe('2'); + // booleans are encoded as '1' (true) / '0' (false) + expect(fd.get(`${base}[_destroy]`)).toBe('1'); + }); + + it('sends spreadsheet attributes for spreadsheet_formula solutions', async () => { + const spy = jest.spyOn( + CourseAPI.assessment.question.textResponse, + 'create', ); + mock.onPost().reply(200, { redirectUrl }); + + await create( + makeData([ + { + id: 1, + solution: '', + solutionType: 'spreadsheet_formula', + grade: 10, + explanation: '', + spreadsheet: { + isRandomizationEnabled: false, + isRandomSeedFixed: true, + randomSeed: 42, + isTimestampFixed: false, + testTimestamp: null, + numRandomTests: 4, + variables: [], + file: { name: 'sheet.xlsx', url: '' }, + }, + }, + ]), + ); + + expect(spy).toHaveBeenCalledWith(expect.any(FormData)); + const fd = spy.mock.calls[0][0] as FormData; + const ss = + 'question_text_response[solutions_attributes][0][test_spreadsheet_attributes]'; + expect(fd.get(`${ss}[is_randomization_enabled]`)).toBe('0'); + expect(fd.get(`${ss}[is_random_seed_fixed]`)).toBe('1'); + expect(fd.get(`${ss}[test_random_seed]`)).toBe('42'); + expect(fd.get(`${ss}[is_timestamp_fixed]`)).toBe('0'); + // null values are not appended + expect(fd.get(`${ss}[test_timestamp]`)).toBeNull(); + expect(fd.get(`${ss}[num_random_tests]`)).toBe('4'); + // variables serialised as JSON string + expect(fd.get(`${ss}[variables]`)).toBe('[]'); }); }); diff --git a/client/app/bundles/course/assessment/question/text-responses/commons/validations.ts b/client/app/bundles/course/assessment/question/text-responses/commons/validations.ts index f062f3b9709..cbc9babb54b 100644 --- a/client/app/bundles/course/assessment/question/text-responses/commons/validations.ts +++ b/client/app/bundles/course/assessment/question/text-responses/commons/validations.ts @@ -9,11 +9,24 @@ import { commonQuestionFieldsValidation } from '../../components/CommonQuestionF const solutionSchema = object({ solutionType: string().required(formTranslations.required), - solution: string().when('toBeDeleted', { - is: true, - then: string().notRequired(), - otherwise: string().required(formTranslations.required), - }), + solution: string() + .when('toBeDeleted', { + is: true, + then: string().notRequired(), + otherwise: string().required(formTranslations.required), + }) + .test('valid-regex', translations.invalidRegex, function (value) { + if (this.parent.toBeDeleted || this.parent.solutionType !== 'regex') + return true; + if (!value) return true; + try { + // eslint-disable-next-line no-new + new RegExp(value); + return true; + } catch { + return false; + } + }), grade: number().when('toBeDeleted', { is: true, then: number().notRequired(), @@ -23,6 +36,21 @@ const solutionSchema = object({ }), explanation: string().nullable(), toBeDeleted: bool(), + spreadsheet: object() + .nullable() + .when(['toBeDeleted', 'solutionType'], { + is: (toBeDeleted: boolean, solutionType: string) => + !toBeDeleted && solutionType === 'spreadsheet_formula', + then: object({ + file: object({ name: string(), url: string() }) + .nullable() + .test( + 'spreadsheet-file-required', + translations.testSpreadsheetRequired, + (value) => Boolean(value?.name), + ), + }), + }), }); export const questionSchema = ( diff --git a/client/app/bundles/course/assessment/question/text-responses/components/NumericRandomizationManager.tsx b/client/app/bundles/course/assessment/question/text-responses/components/NumericRandomizationManager.tsx new file mode 100644 index 00000000000..c21750301d8 --- /dev/null +++ b/client/app/bundles/course/assessment/question/text-responses/components/NumericRandomizationManager.tsx @@ -0,0 +1,44 @@ +import { FC } from 'react'; +import { TextField } from '@mui/material'; +import { NumericRandomConfig } from 'types/course/assessment/question/text-responses'; + +import useTranslation from 'lib/hooks/useTranslation'; +import formTranslations from 'lib/translations/form'; + +interface Props { + config: Omit; + onChange: ( + newConfig: Partial>, + ) => void; +} + +const NumericRandomizationManager: FC = (props) => { + const { config, onChange } = props; + const { t } = useTranslation(); + return ( +
+ { + const v = parseFloat(e.target.value); + if (!Number.isNaN(v)) onChange({ min: v }); + }} + size="small" + type="number" + value={config.min} + /> + { + const v = parseFloat(e.target.value); + if (!Number.isNaN(v)) onChange({ max: v }); + }} + size="small" + type="number" + value={config.max} + /> +
+ ); +}; + +export default NumericRandomizationManager; diff --git a/client/app/bundles/course/assessment/question/text-responses/components/OverrideRandomizationManager.tsx b/client/app/bundles/course/assessment/question/text-responses/components/OverrideRandomizationManager.tsx new file mode 100644 index 00000000000..a50eaaefca0 --- /dev/null +++ b/client/app/bundles/course/assessment/question/text-responses/components/OverrideRandomizationManager.tsx @@ -0,0 +1,31 @@ +import { FC } from 'react'; +import { TextField } from '@mui/material'; +import { OverrideRandomConfig } from 'types/course/assessment/question/text-responses'; + +import useTranslation from 'lib/hooks/useTranslation'; + +import translations from '../../../translations'; + +interface Props { + config: Omit; + onChange: ( + newConfig: Partial>, + ) => void; +} + +const OverrideRandomizationManager: FC = ({ config, onChange }) => { + const { t } = useTranslation(); + return ( +
+ onChange({ value: e.target.value })} + size="small" + value={config.value} + /> +
+ ); +}; + +export default OverrideRandomizationManager; diff --git a/client/app/bundles/course/assessment/question/text-responses/components/ShuffleRandomizationManager.tsx b/client/app/bundles/course/assessment/question/text-responses/components/ShuffleRandomizationManager.tsx new file mode 100644 index 00000000000..b5030aeaf07 --- /dev/null +++ b/client/app/bundles/course/assessment/question/text-responses/components/ShuffleRandomizationManager.tsx @@ -0,0 +1,17 @@ +import { FC } from 'react'; +import { Alert } from '@mui/material'; + +import useTranslation from 'lib/hooks/useTranslation'; + +import translations from '../../../translations'; + +const ShuffleRandomizationManager: FC = () => { + const { t } = useTranslation(); + return ( + + {t(translations.shuffleRandomizationModeDescription)} + + ); +}; + +export default ShuffleRandomizationManager; diff --git a/client/app/bundles/course/assessment/question/text-responses/components/Solution.tsx b/client/app/bundles/course/assessment/question/text-responses/components/Solution.tsx index 53ce5713790..2ad13a18a03 100644 --- a/client/app/bundles/course/assessment/question/text-responses/components/Solution.tsx +++ b/client/app/bundles/course/assessment/question/text-responses/components/Solution.tsx @@ -1,16 +1,35 @@ +import { FC, useCallback, useEffect, useState } from 'react'; import { Controller, useFormContext } from 'react-hook-form'; import { Undo } from '@mui/icons-material'; -import { Alert, IconButton, Select, Tooltip } from '@mui/material'; +import { + Alert, + Button, + IconButton, + Select, + Tooltip, + Typography, +} from '@mui/material'; import FormHelperText from '@mui/material/FormHelperText'; import { TextResponseEditableFormData } from 'types/course/assessment/question/text-responses'; +import BaseAPI from 'api/Base'; import CKEditorRichText from 'lib/components/core/fields/CKEditorRichText'; +import FormCheckboxField from 'lib/components/form/fields/CheckboxField'; +import FormSingleFileInput, { + FilePreview, + type PreviewComponentProps, +} from 'lib/components/form/fields/SingleFileInput'; +import SpreadsheetPreview from 'lib/components/form/fields/SingleFileInput/SpreadsheetPreview'; import FormTextField from 'lib/components/form/fields/TextField'; import { formatErrorMessage } from 'lib/components/form/fields/utils/mapError'; import useTranslation from 'lib/hooks/useTranslation'; import translations from '../../../translations'; +import SpreadsheetManagerPrompt from './SpreadsheetManagerPrompt'; + +const api = new BaseAPI(); + interface SolutionProps { index: number; onDelete: () => void; @@ -18,23 +37,96 @@ interface SolutionProps { disabled?: boolean; } -const Solution = ({ +const Solution: FC = ({ index, disabled, onDelete, onUndoDelete, -}: SolutionProps): JSX.Element => { +}: SolutionProps) => { const { t } = useTranslation(); - const { control, watch } = useFormContext(); + const { control, watch, setValue } = + useFormContext(); + + const solution = watch(`solutions.${index}`); + + const [isAdvancedPromptOpen, setIsAdvancedPromptOpen] = useState(false); + + // Tracks the File object from the user's most recent upload for this solution. + const [localSpreadsheetFile, setLocalSpreadsheetFile] = useState( + null, + ); + // Tracks the File object fetched from the saved URL on form load. + const [serverSpreadsheetFile, setServerSpreadsheetFile] = + useState(null); + // localSpreadsheetFile takes precedence; falls back to the server-fetched file. + const spreadsheetFile = localSpreadsheetFile ?? serverSpreadsheetFile; + + useEffect(() => { + if ( + solution?.solutionType === 'spreadsheet_formula' && + !solution.spreadsheet + ) { + setValue(`solutions.${index}.spreadsheet`, { + isRandomizationEnabled: false, + isRandomSeedFixed: false, + randomSeed: null, + isTimestampFixed: false, + testTimestamp: null, + numRandomTests: 2, + variables: [], + file: { name: '', url: '' }, + }); + } + }, [solution?.solutionType]); - const toBeDeleted = watch(`solutions.${index}.toBeDeleted`); - const isDraft = watch(`solutions.${index}.draft`); + const spreadsheetFileUrl = solution?.spreadsheet?.file?.url; + const spreadsheetFileName = solution?.spreadsheet?.file?.name; + useEffect(() => { + if (!spreadsheetFileUrl) { + setServerSpreadsheetFile(null); + return undefined; + } + + let cancelled = false; + api.client + .get(spreadsheetFileUrl, { + responseType: 'blob', + params: { format: undefined }, + }) + .then(({ data: blob }) => { + if (!cancelled) + setServerSpreadsheetFile( + new File([blob], spreadsheetFileName ?? 'spreadsheet', { + type: blob.type, + }), + ); + }) + .catch(() => {}); + + return () => { + cancelled = true; + }; + }, [spreadsheetFileUrl]); + + // Stable identity unless spreadsheetFile changes (i.e. user uploads / server file loads). + const SpreadsheetFilePreview = useCallback>( + (props: PreviewComponentProps) => { + const fileToShow = props.file ?? spreadsheetFile; + if (fileToShow) return ; + return ; + }, + [spreadsheetFile], + ); + + if (!solution) { + return null; + } return (
@@ -46,9 +138,10 @@ const Solution = ({ render={({ field, fieldState }): JSX.Element => ( <> {fieldState.error && ( @@ -74,11 +171,12 @@ const Solution = ({ name={`solutions.${index}.grade`} render={({ field, fieldState }): JSX.Element => ( )} /> @@ -94,8 +192,8 @@ const Solution = ({ render={({ field, fieldState }): JSX.Element => ( <>