diff --git a/gradebookng/api/src/main/java/org/sakaiproject/grading/api/GradingService.java b/gradebookng/api/src/main/java/org/sakaiproject/grading/api/GradingService.java old mode 100644 new mode 100755 index a879e0a88f5f..0bab9705572f --- a/gradebookng/api/src/main/java/org/sakaiproject/grading/api/GradingService.java +++ b/gradebookng/api/src/main/java/org/sakaiproject/grading/api/GradingService.java @@ -669,6 +669,19 @@ Map> calculateAllCategoryScoresForStudents( */ Map getCourseGradeForStudents(String gradebookUid, String siteId, List userUuids, Map schema); + /** + * Calculate an on-the-fly course grade preview using supplied what-if scores. No data is persisted. + * + * @param gradebookUid the gradebook id + * @param siteId the site id + * @param studentUuid the student to calculate for + * @param whatIfScores assignment id to raw score map (points/percent/letter based on gradebook type) + * @param includeNonReleasedItems whether to include items that are not released + * @return the calculated course grade, or null if the user cannot access it + */ + CourseGradeTransferBean calculateCourseGradePreview(String gradebookUid, String siteId, String studentUuid, + Map whatIfScores, boolean includeNonReleasedItems); + /** * Get a list of CourseSections that the current user has access to in the given gradebook. This is a combination of sections and groups * and is permission filtered. diff --git a/gradebookng/bundle/src/main/bundle/gradebookng.properties b/gradebookng/bundle/src/main/bundle/gradebookng.properties index 07661d4070bb..79de198a4401 100644 --- a/gradebookng/bundle/src/main/bundle/gradebookng.properties +++ b/gradebookng/bundle/src/main/bundle/gradebookng.properties @@ -137,6 +137,7 @@ button.settingsexpandall = Expand All button.settingscollapseall = Collapse All button.saveoverride = Save Course Grade Override button.print = Print +button.whatif.toggle = What-if calculator heading.addgradeitem = Add Gradebook Item heading.editgradeitem = Edit Gradebook Item @@ -235,6 +236,11 @@ label.studentsummary.next = Next Student label.studentsummary.coursegradenotreleased = Not Yet Released label.studentsummary.coursegradenotreleasedflag = Not released to students* label.studentsummary.coursegradenotreleasedmessage = * To release final course grade to students, go to Settings and select "Display Final Course Grades to Students". +whatif.help = Enter hypothetical scores to estimate your course grade. These values are not saved. +whatif.projected = Projected course grade: +whatif.error.generic = Unable to calculate a what-if grade right now. +whatif.error.invalid = One or more scores could not be read. Please enter valid scores. +whatif.label.prefix = What-if score for label.studentsummary.categoryweight=({0}) label.studentsummary.closeconfirmation.title=You are about to leave Student Review mode. label.studentsummary.closeconfirmation.content=Unreleased grades and other students' data will become visible. diff --git a/gradebookng/bundle/src/main/bundle/gradebookng_fr_FR.properties b/gradebookng/bundle/src/main/bundle/gradebookng_fr_FR.properties index 915945e6682e..81f17e693abb 100644 --- a/gradebookng/bundle/src/main/bundle/gradebookng_fr_FR.properties +++ b/gradebookng/bundle/src/main/bundle/gradebookng_fr_FR.properties @@ -107,6 +107,7 @@ button.settingsexpandall=D\u00e9velopper tout button.settingscollapseall=Replier tout button.saveoverride=Enregistrez le remplacement de note de cours button.print=Imprimer +button.whatif.toggle=Calculateur hypoth\u00e9tique heading.addgradeitem=Ajouter \u00e9l\u00e9ment du bulletin de notes heading.editgradeitem=Modifier \u00e9l\u00e9ment du bulletin de notes @@ -201,6 +202,11 @@ label.studentsummary.previous=\u00c9tudiant pr\u00e9c\u00e9dent label.studentsummary.next=\u00c9tudiant suivant label.studentsummary.coursegradenotreleased=Pas encore diffus\u00e9 label.studentsummary.coursegradenotreleasedflag=Pas diffus\u00e9 aux \u00e9tudiants* +whatif.help = Saisissez des notes hypoth\u00e9tiques pour estimer votre note finale. Ces valeurs ne sont pas enregistr\u00e9es. +whatif.projected = Note de cours simul\u00e9e \: +whatif.error.generic = Impossible de calculer la note hypoth\u00e9tique pour le moment. +whatif.error.invalid = Une ou plusieurs notes sont invalides. Merci de saisir des valeurs valides. +whatif.label.prefix = Note hypoth\u00e9tique pour label.studentsummary.coursegradenotreleasedmessage=* To release final course grade to students, go to Settings and select "Display Final Course Grades to Students". label.studentsummary.categoryweight=({0}) label.studentsummary.closeconfirmation.title=Vous \u00eates sur le point de quitter le mode de r\u00e9vision \u00e9tudiant. diff --git a/gradebookng/impl/src/main/java/org/sakaiproject/grading/impl/GradingServiceImpl.java b/gradebookng/impl/src/main/java/org/sakaiproject/grading/impl/GradingServiceImpl.java old mode 100644 new mode 100755 index 0a9f11398e79..e5a0c15d297e --- a/gradebookng/impl/src/main/java/org/sakaiproject/grading/impl/GradingServiceImpl.java +++ b/gradebookng/impl/src/main/java/org/sakaiproject/grading/impl/GradingServiceImpl.java @@ -2761,6 +2761,32 @@ public Double convertStringToDouble(final String doubleAsString) { return scoreAsDouble; } + private Double convertWhatIfGrade(final String rawGrade, final GradebookAssignment assignment, final Gradebook gradebook, + final Map sortedGradeMap) { + + if (StringUtils.isBlank(rawGrade)) { + return null; + } + + if (Objects.equals(GradingConstants.GRADE_TYPE_PERCENTAGE, gradebook.getGradeType())) { + final Double percent = convertStringToDouble(rawGrade); + return calculateEquivalentPointValueForPercent(assignment.getPointsPossible(), percent); + } else if (Objects.equals(GradingConstants.GRADE_TYPE_LETTER, gradebook.getGradeType())) { + final LetterGradePercentMapping mapping = getLetterGradePercentMapping(gradebook); + if (mapping == null) { + return null; + } + final String standardizedGrade = mapping.standardizeInputGrade(rawGrade); + if (standardizedGrade == null) { + return null; + } + final Double percent = sortedGradeMap.get(standardizedGrade); + return calculateEquivalentPointValueForPercent(assignment.getPointsPossible(), percent); + } + + return convertStringToDouble(rawGrade); + } + /** * Get a list of assignments in the gradebook attached to the given category. Note that each assignment only knows the category by name. * @@ -3433,6 +3459,127 @@ public Map getCourseGradeForStudents(final Stri return rval; } + @Override + public CourseGradeTransferBean calculateCourseGradePreview(final String gradebookUid, final String siteId, + final String studentUuid, final Map whatIfScores, final boolean includeNonReleasedItems) { + + if (gradebookUid == null || siteId == null || studentUuid == null) { + throw new IllegalArgumentException("gradebookUid, siteId and studentUuid are required"); + } + + final String currentUser = sessionManager.getCurrentSessionUserId(); + final boolean isSelf = StringUtils.equals(studentUuid, currentUser); + final boolean isStaff = currentUserHasEditPerm(siteId) || currentUserHasGradingPerm(siteId); + final boolean canView = isStaff || (currentUserHasViewOwnGradesPerm(siteId) && isSelf); + + if (!canView) { + throw new GradingSecurityException("You do not have permission to preview this course grade"); + } + final boolean effectiveIncludeNonReleasedItems = includeNonReleasedItems && isStaff; + + final Gradebook gradebook = getGradebook(gradebookUid); + if (gradebook == null) { + throw new IllegalArgumentException("Invalid gradebook uid"); + } + + if (!gradebook.getCourseGradeDisplayed() && !(currentUserHasEditPerm(siteId) || currentUserHasGradingPerm(siteId))) { + return null; + } + if (!isStaff && !gradebook.getAssignmentsDisplayed()) { + throw new GradingSecurityException("You do not have permission to preview this course grade"); + } + + final CourseGrade courseGrade = getCourseGrade(gradebook.getId()); + final Map sortedGradeMap = GradeMappingDefinition + .sortGradeMapping(gradebook.getSelectedGradeMapping().getGradeMap()); + + final Map> gradeRecordMap = getGradeRecordMapForStudents(gradebook.getId(), Collections.singletonList(studentUuid)); + final List existingRecords = gradeRecordMap.getOrDefault(studentUuid, Collections.emptyList()); + final Map recordByAssignment = new HashMap<>(); + final List workingRecords = new ArrayList<>(); + + for (final AssignmentGradeRecord agr : existingRecords) { + final AssignmentGradeRecord copy = agr.clone(); + copy.setExcludedFromGrade(agr.getExcludedFromGrade()); + copy.setDroppedFromGrade(Boolean.FALSE); + recordByAssignment.put(copy.getAssignment().getId(), copy); + workingRecords.add(copy); + } + + if (whatIfScores != null && !whatIfScores.isEmpty()) { + for (final Map.Entry entry : whatIfScores.entrySet()) { + final Long assignmentId = entry.getKey(); + if (assignmentId == null) { + continue; + } + + final GradebookAssignment assignment = getAssignmentWithoutStatsByID(gradebookUid, assignmentId); + if (assignment == null) { + continue; + } + + AssignmentGradeRecord record = recordByAssignment.get(assignmentId); + if (record == null) { + record = new AssignmentGradeRecord(assignment, studentUuid, null); + record.setExcludedFromGrade(Boolean.FALSE); + recordByAssignment.put(assignmentId, record); + workingRecords.add(record); + } + + final String rawGrade = StringUtils.trimToEmpty(entry.getValue()); + final Double convertedGrade = convertWhatIfGrade(rawGrade, assignment, gradebook, sortedGradeMap); + + if (StringUtils.isNotBlank(rawGrade) && convertedGrade == null) { + throw new IllegalArgumentException("invalidGrade"); + } + + record.setPointsEarned(convertedGrade); + if (StringUtils.isNotBlank(rawGrade)) { + record.setExcludedFromGrade(Boolean.FALSE); + } + record.setDroppedFromGrade(Boolean.FALSE); + record.setGradableObject(assignment); + } + } + + if (!effectiveIncludeNonReleasedItems) { + workingRecords.removeIf(rec -> rec.getAssignment() != null && !rec.getAssignment().getReleased()); + } + + applyDropScores(workingRecords, gradebook.getCategoryType()); + + final List categories = getCategories(gradebook.getId()); + final List countedAssignments = getCountedAssignments(gradebook.getId()).stream() + .filter(GradebookAssignment::isIncludedInCalculations) + .collect(Collectors.toList()); + + final List earnedTotals = getTotalPointsEarnedInternal(studentUuid, gradebook, categories, workingRecords, countedAssignments); + final double totalPointsEarned = earnedTotals.get(0); + final double literalTotalPointsEarned = earnedTotals.get(1); + final double extraPointsEarned = earnedTotals.get(2); + final double totalPointsPossible = getTotalPointsInternal(gradebook, categories, studentUuid, workingRecords, countedAssignments, false); + + final CourseGradeRecord previewRecord = new CourseGradeRecord(courseGrade, studentUuid); + previewRecord.initNonpersistentFields(totalPointsPossible, totalPointsEarned, literalTotalPointsEarned, extraPointsEarned); + + final CourseGradeTransferBean cg = new CourseGradeTransferBean(); + cg.setId(courseGrade.getId()); + + Double calculatedGrade = previewRecord.getAutoCalculatedGrade(); + if (calculatedGrade != null) { + final BigDecimal rounded = new BigDecimal(calculatedGrade) + .setScale(10, RoundingMode.HALF_UP) + .setScale(2, RoundingMode.HALF_UP); + calculatedGrade = rounded.doubleValue(); + cg.setCalculatedGrade(calculatedGrade.toString()); + cg.setMappedGrade(GradeMapping.getMappedGrade(sortedGradeMap, calculatedGrade)); + } + + cg.setPointsEarned(previewRecord.getCalculatedPointsEarned()); + cg.setTotalPointsPossible(previewRecord.getTotalPointsPossible()); + return cg; + } + @Override public List getViewableSections(final String gradebookUid, final String siteId) { diff --git a/gradebookng/tool/src/java/org/sakaiproject/gradebookng/business/GradebookNgBusinessService.java b/gradebookng/tool/src/java/org/sakaiproject/gradebookng/business/GradebookNgBusinessService.java old mode 100644 new mode 100755 index 98b645cbc919..f7c038ed0dc9 --- a/gradebookng/tool/src/java/org/sakaiproject/gradebookng/business/GradebookNgBusinessService.java +++ b/gradebookng/tool/src/java/org/sakaiproject/gradebookng/business/GradebookNgBusinessService.java @@ -571,6 +571,21 @@ public Long getCourseGradeId(Long gradebookId){ return this.gradingService.getCourseGradeId(gradebookId); } + /** + * Calculate a course grade preview for the given student with hypothetical scores. + * + * @param gradebookUid the gradebook id + * @param siteId the site id + * @param studentUuid the student + * @param whatIfScores map of assignment id to raw score string + * @param includeNonReleasedItems whether to include items that are not released + * @return preview of the course grade, or null if it cannot be calculated for the user + */ + public CourseGradeTransferBean calculateWhatIfCourseGrade(final String gradebookUid, final String siteId, final String studentUuid, + final Map whatIfScores, final boolean includeNonReleasedItems) { + return this.gradingService.calculateCourseGradePreview(gradebookUid, siteId, studentUuid, whatIfScores, includeNonReleasedItems); + } + /** * Save the grade and comment for a student's assignment and do concurrency checking * diff --git a/gradebookng/tool/src/java/org/sakaiproject/gradebookng/tool/pages/StudentPage.html b/gradebookng/tool/src/java/org/sakaiproject/gradebookng/tool/pages/StudentPage.html old mode 100644 new mode 100755 index df33d6d6a64a..fab7de97ada3 --- a/gradebookng/tool/src/java/org/sakaiproject/gradebookng/tool/pages/StudentPage.html +++ b/gradebookng/tool/src/java/org/sakaiproject/gradebookng/tool/pages/StudentPage.html @@ -7,6 +7,7 @@

Grade Report for Tony Stark

+
diff --git a/gradebookng/tool/src/java/org/sakaiproject/gradebookng/tool/panels/GradeSummaryTablePanel.java b/gradebookng/tool/src/java/org/sakaiproject/gradebookng/tool/panels/GradeSummaryTablePanel.java old mode 100644 new mode 100755 index 6aaf518803b4..9bd12bf9267d --- a/gradebookng/tool/src/java/org/sakaiproject/gradebookng/tool/panels/GradeSummaryTablePanel.java +++ b/gradebookng/tool/src/java/org/sakaiproject/gradebookng/tool/panels/GradeSummaryTablePanel.java @@ -259,6 +259,9 @@ protected void populateItem(final ListItem assignmentItem) { rawGrade = ""; comment = ""; } + assignmentItem.add(new AttributeModifier("data-assignment-id", assignment.getId())); + assignmentItem.add(new AttributeModifier("data-points", assignment.getPoints())); + assignmentItem.add(new AttributeModifier("data-grade", StringUtils.defaultString(rawGrade))); final Label title = new Label("title", assignment.getName()); assignmentItem.add(title); diff --git a/gradebookng/tool/src/java/org/sakaiproject/gradebookng/tool/panels/StudentGradeSummaryGradesPanel.html b/gradebookng/tool/src/java/org/sakaiproject/gradebookng/tool/panels/StudentGradeSummaryGradesPanel.html old mode 100644 new mode 100755 index 9737866b5c56..3d45197f61c8 --- a/gradebookng/tool/src/java/org/sakaiproject/gradebookng/tool/panels/StudentGradeSummaryGradesPanel.html +++ b/gradebookng/tool/src/java/org/sakaiproject/gradebookng/tool/panels/StudentGradeSummaryGradesPanel.html @@ -21,6 +21,14 @@ + +
+ +
+
+ + +
diff --git a/gradebookng/tool/src/java/org/sakaiproject/gradebookng/tool/panels/StudentGradeSummaryGradesPanel.java b/gradebookng/tool/src/java/org/sakaiproject/gradebookng/tool/panels/StudentGradeSummaryGradesPanel.java old mode 100644 new mode 100755 index 2ced255fdb85..cc60d7ec0fc7 --- a/gradebookng/tool/src/java/org/sakaiproject/gradebookng/tool/panels/StudentGradeSummaryGradesPanel.java +++ b/gradebookng/tool/src/java/org/sakaiproject/gradebookng/tool/panels/StudentGradeSummaryGradesPanel.java @@ -23,6 +23,10 @@ import java.util.Objects; import java.util.stream.Collectors; +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.node.ObjectNode; +import org.apache.wicket.ajax.AbstractDefaultAjaxBehavior; import org.apache.commons.lang3.StringUtils; import org.apache.wicket.AttributeModifier; import org.apache.wicket.ajax.AjaxRequestTarget; @@ -37,11 +41,16 @@ import org.sakaiproject.gradebookng.business.util.CourseGradeFormatter; import org.sakaiproject.gradebookng.tool.component.GbAjaxLink; import org.sakaiproject.gradebookng.tool.pages.GradebookPage; +import org.apache.wicket.request.cycle.RequestCycle; +import org.apache.wicket.request.handler.TextRequestHandler; +import org.apache.wicket.util.string.StringValue; import org.sakaiproject.grading.api.Assignment; import org.sakaiproject.grading.api.CategoryDefinition; import org.sakaiproject.grading.api.CategoryScoreData; import org.sakaiproject.grading.api.CourseGradeTransferBean; import org.sakaiproject.grading.api.GradebookInformation; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; import org.sakaiproject.grading.api.GradingConstants; import org.sakaiproject.grading.api.SortType; import org.sakaiproject.grading.api.model.Gradebook; @@ -52,6 +61,7 @@ public class StudentGradeSummaryGradesPanel extends BasePanel { private static final long serialVersionUID = 1L; + private static final Logger log = LoggerFactory.getLogger(StudentGradeSummaryGradesPanel.class); Integer configuredCategoryType; @@ -61,6 +71,8 @@ public class StudentGradeSummaryGradesPanel extends BasePanel { boolean categoriesEnabled = false; boolean isAssignmentsDisplayed = false; private boolean courseGradeStatsEnabled; + private AbstractDefaultAjaxBehavior whatIfCalculationBehavior; + private String studentUuid; public StudentGradeSummaryGradesPanel(final String id, final IModel> model) { super(id, model); @@ -70,6 +82,16 @@ public StudentGradeSummaryGradesPanel(final String id, final IModel modelData = (Map) getDefaultModelObject(); @@ -93,6 +115,7 @@ public void onBeforeRender() { // unpack model final Map modelData = (Map) getDefaultModelObject(); final String userId = (String) modelData.get("studentUuid"); + this.studentUuid = userId; final Gradebook gradebook = getGradebook(); String studentCourseGradeComment = this.businessService.getAssignmentGradeComment(getCurrentSiteId(), this.businessService.getCourseGradeId(gradebook.getId()), userId); @@ -232,6 +255,93 @@ public boolean isVisible() { courseGradePanel.add(courseGradeStatsLink); courseGradePanel.addOrReplace(new Label("studentCourseGradeComment", studentCourseGradeComment)); add(new AttributeModifier("data-studentid", userId)); + add(new AttributeModifier("data-whatif-url", this.whatIfCalculationBehavior.getCallbackUrl().toString())); + add(new AttributeModifier("data-whatif-error", getString("whatif.error.generic"))); + add(new AttributeModifier("data-whatif-label-prefix", getString("whatif.label.prefix"))); + + final boolean whatIfEnabled = this.someAssignmentsReleased && courseGrade != null; + add(new AttributeModifier("data-whatif-enabled", String.valueOf(whatIfEnabled))); + } + + /** + * Handle a what-if calculation request from the client. + */ + private void handleWhatIfRequest() { + final RequestCycle requestCycle = RequestCycle.get(); + final StringValue payload = requestCycle.getRequest().getRequestParameters().getParameterValue("whatIf"); + + final ObjectMapper mapper = new ObjectMapper(); + final ObjectNode result = mapper.createObjectNode(); + + try { + final Map modelData = (Map) getDefaultModelObject(); + final String modelStudentId = (String) modelData.get("studentUuid"); + if (StringUtils.isBlank(modelStudentId)) { + result.put("error", getString("whatif.error.generic")); + requestCycle.scheduleRequestHandlerAfterCurrent(new TextRequestHandler("application/json", "UTF-8", result.toString())); + return; + } + Map whatIfMap = Collections.emptyMap(); + final String payloadString = payload != null ? payload.toOptionalString() : null; + if (StringUtils.isNotBlank(payloadString)) { + final Map raw = mapper.readValue(payloadString, new TypeReference>() {}); + if (raw != null) { + final Map parsed = new HashMap<>(); + for (final Map.Entry entry : raw.entrySet()) { + if (StringUtils.isBlank(entry.getKey())) { + continue; + } + try { + parsed.put(Long.valueOf(entry.getKey()), entry.getValue()); + } catch (NumberFormatException nfe) { + log.warn("Ignoring what-if entry with invalid assignment id {}", entry.getKey()); + } + } + whatIfMap = parsed; + } + } + + final CourseGradeTransferBean preview = this.businessService.calculateWhatIfCourseGrade(currentGradebookUid, currentSiteId, + modelStudentId, whatIfMap, false); + + final Gradebook gradebook = this.businessService.getGradebook(currentGradebookUid, currentSiteId); + final CourseGradeFormatter formatter = new CourseGradeFormatter( + gradebook, + GbRole.STUDENT, + gradebook.getCourseGradeDisplayed(), + gradebook.getCoursePointsDisplayed(), + true, + this.businessService.getShowCalculatedGrade()); + + if (preview != null) { + result.put("courseGrade", formatter.format(preview)); + if (preview.getMappedGrade() != null) { + result.put("mappedGrade", preview.getMappedGrade()); + } + if (preview.getCalculatedGrade() != null) { + result.put("calculatedGrade", preview.getCalculatedGrade()); + } + if (preview.getPointsEarned() != null) { + result.put("pointsEarned", preview.getPointsEarned()); + } + if (preview.getTotalPointsPossible() != null) { + result.put("totalPointsPossible", preview.getTotalPointsPossible()); + } + } else { + result.put("courseGrade", formatter.format(null)); + } + } catch (IllegalArgumentException e) { + if ("invalidGrade".equals(e.getMessage())) { + result.put("error", getString("whatif.error.invalid")); + } else { + result.put("error", getString("whatif.error.generic")); + } + } catch (Exception e) { + log.error("Error calculating what-if course grade preview", e); + result.put("error", getString("whatif.error.generic")); + } + + requestCycle.scheduleRequestHandlerAfterCurrent(new TextRequestHandler("application/json", "UTF-8", result.toString())); } /** diff --git a/gradebookng/tool/src/webapp/scripts/gradebook-grade-summary.js b/gradebookng/tool/src/webapp/scripts/gradebook-grade-summary.js old mode 100644 new mode 100755 index 65ee8a438928..2a8e3139d307 --- a/gradebookng/tool/src/webapp/scripts/gradebook-grade-summary.js +++ b/gradebookng/tool/src/webapp/scripts/gradebook-grade-summary.js @@ -254,6 +254,7 @@ GradebookGradeSummary.prototype.setupModalPrint = function() { GradebookGradeSummary.prototype.setupStudentView = function() { var self = this; self.setupTableSorting(); + self.setupWhatIfCalculator(); var $button = $("body").find(".portletBody .gb-summary-print"); $button.off("click").on("click", function() { @@ -265,6 +266,134 @@ GradebookGradeSummary.prototype.setupStudentView = function() { }; +GradebookGradeSummary.prototype.setupWhatIfCalculator = function() { + const $toggle = $("body").find(".portletBody .gb-whatif-toggle"); + const callbackUrl = this.$content.attr("data-whatif-url"); + const enabled = this.$content.attr("data-whatif-enabled") === "true"; + const labelPrefix = this.$content.attr("data-whatif-label-prefix") || "What-if score for"; + + if (!$toggle.length || !callbackUrl || !enabled) { + $toggle.hide(); + return; + } + + $toggle.attr("aria-pressed", "false").removeClass("active"); + + const $help = this.$content.find(".gb-whatif-note"); + const $result = this.$content.find(".gb-whatif-result"); + const $gradeTarget = this.$content.find(".gb-whatif-grade"); + const $error = this.$content.find(".gb-whatif-error"); + + const showError = message => { + if (!$error.length) { + return; + } + $error.text(message).removeClass("d-none"); + }; + + const clearError = () => { + if (!$error.length) { + return; + } + $error.addClass("d-none").text(""); + }; + + const ensureInputs = () => { + this.$content.find(".gb-summary-grade-row[data-assignment-id]").each(function() { + const $row = $(this); + let $input = $row.find(".gb-whatif-input"); + if ($input.length === 0) { + const starting = $row.data("grade") ? ("" + $row.data("grade")).trim() + : $row.find(".gb-summary-grade-score-raw").text().trim(); + $input = $(''); + if (starting) { + $input.val(starting); + } + const assignmentTitle = $row.find(".gb-summary-grade-title").text().trim(); + const assignmentId = $row.data("assignment-id"); + const ariaLabel = assignmentTitle + ? `${labelPrefix} ${assignmentTitle}` + : (assignmentId ? `${labelPrefix} ${assignmentId}` : labelPrefix); + $input.attr("aria-label", ariaLabel); + $row.find(".gb-summary-grade-score").append($input); + } + }); + }; + + const collectScores = () => { + const scores = {}; + this.$content.find(".gb-whatif-input").each(function() { + const $input = $(this); + const assignmentId = $input.closest(".gb-summary-grade-row").data("assignment-id"); + if (assignmentId !== undefined && assignmentId !== null) { + scores[assignmentId] = $input.val().trim(); + } + }); + return scores; + }; + + let debounceTimer = null; + const requestPreview = () => { + clearTimeout(debounceTimer); + debounceTimer = setTimeout(() => { + const payload = collectScores(); + fetch(callbackUrl, { + method: "POST", + headers: { "Content-Type": "application/x-www-form-urlencoded; charset=UTF-8" }, + body: "whatIf=" + encodeURIComponent(JSON.stringify(payload)) + }) + .then(response => response.ok ? response.json() : Promise.reject()) + .then(data => { + if (data.error) { + showError(data.error); + $result.addClass("d-none"); + return; + } + clearError(); + if (data.courseGrade) { + $gradeTarget.text(data.courseGrade); + $result.removeClass("d-none"); + } else { + showError(this.$content.attr("data-whatif-error") || "Unable to calculate what-if grade right now."); + $result.addClass("d-none"); + } + }) + .catch(() => { + showError(this.$content.attr("data-whatif-error") || "Unable to calculate what-if grade right now."); + }); + }, 250); + }; + + const activate = () => { + ensureInputs(); + this.$content.find(".gb-whatif-input").removeClass("d-none"); + $help.removeClass("d-none"); + requestPreview(); + }; + + const deactivate = () => { + this.$content.find(".gb-whatif-input").addClass("d-none"); + $help.addClass("d-none"); + $result.addClass("d-none"); + clearError(); + }; + + this.$content.on("input", ".gb-whatif-input", requestPreview); + + $toggle.off("click").on("click", function(event) { + event.preventDefault(); + const active = $(this).attr("aria-pressed") === "true"; + const nextState = !active; + $(this).attr("aria-pressed", nextState); + $(this).toggleClass("active", nextState); + if (nextState) { + activate(); + } else { + deactivate(); + } + }); +}; + GradebookGradeSummary.prototype._print = function(headerHTML, contentHTML) { const printWindow = window.open("", "_blank");