From eaf896c11c9de0e6cb2ffaf34e24648797676b1a Mon Sep 17 00:00:00 2001 From: Jaimos Skriletz Date: Thu, 26 Feb 2026 16:04:08 -0700 Subject: [PATCH] Add point input to ProblemGrader. Add a point input field to set the problem score in the ProblemGrader. This is the same as what is being used in the SingleProblemGrader, and honors the same setting to show it or not. The input uses JavaScript to update the actual score which is what is submitted when the grader is saved. If the percent score is not shown, a hidden field is used instead. This also adds a step of 1 to the percent score and validation on both the percent score and point score values. --- htdocs/js/ProblemGrader/problemgrader.js | 39 ++++++++++++++++ lib/WeBWorK/ConfigValues.pm | 10 ++--- .../Instructor/ProblemGrader.pm | 9 ++-- lib/WeBWorK/Utils.pm | 40 +++++++++++++++++ .../Instructor/ProblemGrader.html.ep | 44 ++++++++++++++----- .../HTML/SingleProblemGrader/grader.html.ep | 25 ++--------- 6 files changed, 127 insertions(+), 40 deletions(-) diff --git a/htdocs/js/ProblemGrader/problemgrader.js b/htdocs/js/ProblemGrader/problemgrader.js index 941d77f5b4..ada3727ecc 100644 --- a/htdocs/js/ProblemGrader/problemgrader.js +++ b/htdocs/js/ProblemGrader/problemgrader.js @@ -1,6 +1,45 @@ 'use strict'; (() => { + const setPointInputValue = (pointInput, score) => + (pointInput.value = parseFloat( + (Math.round((score * pointInput.max) / 100 / pointInput.step) * pointInput.step).toFixed(2) + )); + + // Update problem score if point value changes and is a valid value. + for (const pointInput of document.querySelectorAll('.problem-points')) { + pointInput.addEventListener('input', () => { + const userId = pointInput.id.replace(/\.points$/, ''); + if (pointInput.checkValidity()) { + const scoreInput = document.getElementById(`${userId}.score`); + if (scoreInput) { + scoreInput.classList.remove('is-invalid'); + scoreInput.value = Math.round((100 * pointInput.value) / pointInput.max); + } + pointInput.classList.remove('is-invalid'); + } else { + pointInput.classList.add('is-invalid'); + } + }); + } + + // Update problem points if score changes and is a valid value. + for (const scoreInput of document.querySelectorAll('.problem-score')) { + scoreInput.addEventListener('input', () => { + const userId = scoreInput.id.replace(/\.score$/, ''); + if (scoreInput.checkValidity()) { + const pointInput = document.getElementById(`${userId}.points`); + if (pointInput) { + pointInput.classList.remove('is-invalid'); + pointInput.value = setPointInputValue(pointInput, scoreInput.value); + } + scoreInput.classList.remove('is-invalid'); + } else { + scoreInput.classList.add('is-invalid'); + } + }); + } + const userSelect = document.getElementById('student_selector'); if (!userSelect) return; diff --git a/lib/WeBWorK/ConfigValues.pm b/lib/WeBWorK/ConfigValues.pm index 2bb2d95b71..5a61a7e860 100644 --- a/lib/WeBWorK/ConfigValues.pm +++ b/lib/WeBWorK/ConfigValues.pm @@ -776,12 +776,12 @@ sub getConfigValues ($ce) { }, { var => 'problemGraderScore', - doc => x('Method to enter problem scores in the single problem manual grader'), + doc => x('Method to enter problem scores in the manual problem graders'), doc2 => x( - 'This configures if the single problem manual grader has inputs to enter problem scores as ' - . 'a percent, a point value, or both. Note, the problem score is always saved as a ' - . 'percent, so when using a point value, the problem score will be rounded to the ' - . 'nearest whole percent.' + 'This configures if the manual problem grader or single problem grader has inputs to enter ' + . 'problem scores as a percent, a point value, or both. Note, the problem score is always ' + . 'saved as a percent, so when using a point value, the problem score will be rounded to ' + . 'the nearest whole percent.' ), values => [qw(Percent Point Both)], type => 'popuplist' diff --git a/lib/WeBWorK/ContentGenerator/Instructor/ProblemGrader.pm b/lib/WeBWorK/ContentGenerator/Instructor/ProblemGrader.pm index dae595e356..950d0f5039 100644 --- a/lib/WeBWorK/ContentGenerator/Instructor/ProblemGrader.pm +++ b/lib/WeBWorK/ContentGenerator/Instructor/ProblemGrader.pm @@ -25,10 +25,11 @@ async sub initialize ($c) { my $userID = $c->param('user'); # Make sure these are defined for the template. - $c->stash->{set} = $db->getGlobalSet($setID); - $c->stash->{problem} = $db->getGlobalProblem($setID, $problemID); - $c->stash->{users} = []; - $c->stash->{haveSections} = 0; + $c->stash->{set} = $db->getGlobalSet($setID); + $c->stash->{problem} = $db->getGlobalProblem($setID, $problemID); + $c->stash->{users} = []; + $c->stash->{haveSections} = 0; + $c->stash->{problem_value} = $c->stash->{problem}->value; return unless $c->stash->{set} diff --git a/lib/WeBWorK/Utils.pm b/lib/WeBWorK/Utils.pm index e357abf272..2048c66b56 100644 --- a/lib/WeBWorK/Utils.pm +++ b/lib/WeBWorK/Utils.pm @@ -32,6 +32,8 @@ our @EXPORT_OK = qw( generateURLs formatEmailSubject getAssetURL + points_stepsize + round_nearest_stepsize x ); @@ -533,6 +535,28 @@ sub getAssetURL ($ce, $file, $isThemeFile = 0) { return "$ce->{webworkURLs}{htdocs}/$file"; } +sub points_stepsize ($points) { + my $stepsize; + if ($points == 1) { + $stepsize = 0.01; + } elsif ($points <= 5) { + $stepsize = 0.05; + } elsif ($points <= 10) { + $stepsize = 0.1; + } elsif ($points <= 25) { + $stepsize = 0.25; + } elsif ($points <= 50) { + $stepsize = 0.5; + } else { + $stepsize = int(($points - 1) / 100) + 1; + } + return $stepsize; +} + +sub round_nearest_stepsize ($score, $stepsize) { + return wwRound(2, wwRound(0, $score / $stepsize) * $stepsize); +} + sub x (@args) { return @args } 1; @@ -763,6 +787,22 @@ Returns the URL for the asset specified in C<$file>. If C<$isThemeFile> is true, then the asset will be assumed to be located in a theme directory. The parameter C<$ce> must be a valid C object. +=head2 points_stepsize + +Usage: C + +Returns a reasonable stepsize that best converts between a whole percent and +a point value. The stepsize is the point value that is close to but greater +than or equal to 1% per step. This is done by first using preset values of +0.01, 0.05, 0.1, 0.25, or 0.5, then using only whole point values, such that +the stepsize is greater than or equal to 1% of total points. + +=head2 round_nearest_stepsize + +Usage: C + +Returns the value of the score rounded to the nearest stepsize. + =head2 x Usage: C diff --git a/templates/ContentGenerator/Instructor/ProblemGrader.html.ep b/templates/ContentGenerator/Instructor/ProblemGrader.html.ep index d8c7ec6e8f..1f51e3b079 100644 --- a/templates/ContentGenerator/Instructor/ProblemGrader.html.ep +++ b/templates/ContentGenerator/Instructor/ProblemGrader.html.ep @@ -1,4 +1,4 @@ -% use WeBWorK::Utils qw(wwRound getAssetURL); +% use WeBWorK::Utils qw(wwRound getAssetURL points_stepsize round_nearest_stepsize); % require WeBWorK::PG; % % content_for js => begin @@ -122,11 +122,17 @@ <%= check_box 'select-all' => 'on', id => 'select-all', class => 'select-all form-check-input', data => { select_group => 'mark_correct' } =%> - <%= maketext('Score (%)') %> + % unless ($ce->{problemGraderScore} eq 'Point') { + <%= maketext('Score (%)') %> + % } + % unless ($ce->{problemGraderScore} eq 'Percent') { + <%= maketext('Points (0 - [_1])', $problem_value) %> + % } <%= maketext('Comment') %> + % my $stepSize = points_stepsize($problem_value); % for my $user (@$users) { % my $userID = $user->user_id; % @@ -206,14 +212,32 @@ class => 'mark_correct form-check-input', 'aria-labelledby' => 'mark-all-correct-header' =%> - - % param("$userID.$versionID.score", undef); - <%= number_field "$userID.$versionID.score" => - wwRound(0, $_->{problem}->status * 100), - class => 'score-selector form-control form-control-sm restricted-width-col', - style => 'width:6.5rem;', min => 0, max => 100, autocomplete => 'off', - 'aria-labelledby' => 'score-header' =%> - + % unless ($ce->{problemGraderScore} eq 'Point') { + + % param("$userID.$versionID.score", undef); + <%= number_field "$userID.$versionID.score" => wwRound(0, $_->{problem}->status * 100), + id => "$userID.$versionID.score", + class => 'problem-score form-control form-control-sm restricted-width-col', + style => 'width:6.5rem;', min => 0, max => 100, step => 1, + autocomplete => 'off', 'aria-labelledby' => 'score-header' =%> + + % } + % unless ($ce->{problemGraderScore} eq 'Percent') { + + % if ($ce->{problemGraderScore} eq 'Point') { + % param("$userID.$versionID.score", undef); + <%= hidden_field "$userID.$versionID.score" => wwRound(0, $_->{problem}->status * 100), + id => "$userID.$versionID.score" %> + % } + % param("$userID.$versionID.points", undef); + <%= number_field "$userID.$versionID.points" => + round_nearest_stepsize($_->{problem}->status * $problem_value, $stepSize), + id => "$userID.$versionID.points", + class => 'problem-points form-control form-control-sm restricted-width-col', + style => 'width:6.5rem;', min => 0, max => $problem_value, step => $stepSize, + autocomplete => 'off', 'aria-labelledby' => 'point-header' =%> + + % } % if (defined $_->{past_answer}) { <%= text_area "$userID.$versionID.comment" => $_->{past_answer}->comment_string, diff --git a/templates/HTML/SingleProblemGrader/grader.html.ep b/templates/HTML/SingleProblemGrader/grader.html.ep index 8399cd1619..8405eab134 100644 --- a/templates/HTML/SingleProblemGrader/grader.html.ep +++ b/templates/HTML/SingleProblemGrader/grader.html.ep @@ -1,4 +1,4 @@ -% use WeBWorK::Utils 'wwRound'; +% use WeBWorK::Utils qw(wwRound points_stepsize round_nearest_stepsize); % % if (!stash->{jsInserted}) { % stash->{jsInserted} = 1; @@ -82,28 +82,11 @@ % % # Total point value. Show only if configured to. % unless ($ce->{problemGraderScore} eq 'Percent') { - % # Compute a reasonable step size based on point value. - % # First use some preset nice values, then only use whole - % # point values, such that the step size >= 1% of total. - % my $stepSize; - % if ($grader->{problem_value} == 1) { - % $stepSize = 0.01; - % } elsif ($grader->{problem_value} <= 5) { - % $stepSize = 0.05; - % } elsif ($grader->{problem_value} <= 10) { - % $stepSize = 0.1; - % } elsif ($grader->{problem_value} <= 25) { - % $stepSize = 0.25; - % } elsif ($grader->{problem_value} <= 50) { - % $stepSize = 0.5; - % } else { - % $stepSize = int(($grader->{problem_value} - 1) / 100) + 1; - % } - % # Round point score to the nearest $stepSize. + % my $stepSize = points_stepsize($grader->{problem_value}); % my $recordedPoints = - % wwRound(2, wwRound(0, $grader->{recorded_score} * $grader->{problem_value} / $stepSize) * $stepSize); + % round_nearest_stepsize($grader->{recorded_score} * $grader->{problem_value}, $stepSize); % my $currentPoints = - % wwRound(2, wwRound(0, $rawCurrentScore / 100 * $grader->{problem_value} / $stepSize) * $stepSize); + % round_nearest_stepsize($rawCurrentScore / 100 * $grader->{problem_value}, $stepSize); % param('grader-problem-points', $recordedPoints);
<%= label_for "score_problem$grader->{problem_id}_points",