From 6910f9690849ef3ad8ada63030e2fadcbf5b9032 Mon Sep 17 00:00:00 2001 From: Glenn Rice Date: Tue, 5 Mar 2024 17:52:52 -0600 Subject: [PATCH 1/2] Fill in empty dates with valid dates for date picker groups. This builds on #2348, and was requested by @Alex-Jordan in that pull request (see https://github.com/openwebwork/webwork2/pull/2348#pullrequestreview-1918156425). This makes it so that if a date is not filled in, then it will be filled by javascript with a date as needed to ensure it satisfies the usual date requirements on open, reduced scoring, close, and answer dates. The class value is taken into account for this when editing a set for users. This applies on both the problem set detail page and the user detail page. This javascript is also used on the sets manager page when editing set dates where this change does not apply, but it is designed to still work without conflict there. --- htdocs/js/DatePicker/datepicker.js | 40 +++++++++++-------- .../Instructor/ProblemSetDetail.pm | 3 +- .../UserDetail/set_date_table.html.ep | 3 +- 3 files changed, 28 insertions(+), 18 deletions(-) diff --git a/htdocs/js/DatePicker/datepicker.js b/htdocs/js/DatePicker/datepicker.js index 767d5ce99b..2cfd887834 100644 --- a/htdocs/js/DatePicker/datepicker.js +++ b/htdocs/js/DatePicker/datepicker.js @@ -25,17 +25,34 @@ const groupRules = [ open_rule, - document.querySelector('input[id="' + name + '.due_date_id"]'), - document.querySelector('input[id="' + name + '.answer_date_id"]') + document.getElementById(`${name}.due_date_id`), + document.getElementById(`${name}.answer_date_id`) ]; - const reduced_rule = document.querySelector('input[id="' + name + '.reduced_scoring_date_id"]'); + const reduced_rule = document.getElementById(`${name}.reduced_scoring_date_id`); if (reduced_rule) groupRules.splice(1, 0, reduced_rule); + // Compute the time difference between the current browser timezone and the course timezone. + // flatpickr gives the time in the browser's timezone, and this is used to adjust to the course timezone. + // Note that this is in seconds. + const timezoneAdjustment = + new Date(new Date().toLocaleString('en-US')).getTime() - + new Date( + new Date().toLocaleString('en-US', { timeZone: open_rule.dataset.timezone ?? 'America/New_York' }) + ).getTime(); + + const classValues = groupRules.map( + (rule) => + parseInt(document.getElementsByName(`${rule.name}.class_value`)[0]?.dataset.classValue || '0') * 1000 - + timezoneAdjustment + ); + const update = () => { for (let i = 1; i < groupRules.length; ++i) { - const prevFieldDate = groupRules[i - 1].parentNode._flatpickr.selectedDates[0]; - const thisFieldDate = groupRules[i].parentNode._flatpickr.selectedDates[0]; + const prevFieldDate = + groupRules[i - 1]?.parentNode._flatpickr.selectedDates[0]?.getTime() || classValues[i - 1]; + const thisFieldDate = + groupRules[i]?.parentNode._flatpickr.selectedDates[0]?.getTime() || classValues[i]; if (prevFieldDate && thisFieldDate && prevFieldDate > thisFieldDate) { groupRules[i].parentNode._flatpickr.setDate(prevFieldDate, true); } @@ -47,15 +64,6 @@ luxon.Settings.defaultLocale = rule.dataset.locale ?? 'en'; - // Compute the time difference between the current browser timezone and the course timezone. - // flatpickr gives the time in the browser's timezone, and this is used to adjust to the course timezone. - // Note that this is in seconds. - const timezoneAdjustment = - new Date(new Date().toLocaleString('en-US')).getTime() - - new Date( - new Date().toLocaleString('en-US', { timeZone: rule.dataset.timezone ?? 'America/New_York' }) - ).getTime(); - const fp = flatpickr(rule.parentNode, { allowInput: true, enableTime: true, @@ -101,12 +109,12 @@ } }) ], - onChange(selectedDates) { + onChange() { if (this.input.value === orig_value) this.altInput.classList.remove('changed'); else this.altInput.classList.add('changed'); }, onClose: update, - onReady(selectedDates) { + onReady() { // Flatpickr hides the original input and adds the alternate input after it. That messes up the // bootstrap input group styling. So move the now hidden original input after the created alternate // input to fix that. diff --git a/lib/WeBWorK/ContentGenerator/Instructor/ProblemSetDetail.pm b/lib/WeBWorK/ContentGenerator/Instructor/ProblemSetDetail.pm index 0fbde8b7a1..96e3ab4f94 100644 --- a/lib/WeBWorK/ContentGenerator/Instructor/ProblemSetDetail.pm +++ b/lib/WeBWorK/ContentGenerator/Instructor/ProblemSetDetail.pm @@ -1015,7 +1015,8 @@ sub fieldHTML ($c, $userID, $setID, $problemID, $globalRecord, $userRecord, $fie size => $properties{size} || 5, class => 'form-control-plaintext form-control-sm', 'aria-labelledby' => "$recordType.$recordID.$field.label", - $field =~ /date/ || $field eq 'restricted_release' || $field eq 'source_file' ? (dir => 'ltr') : () + $field =~ /date/ || $field eq 'restricted_release' || $field eq 'source_file' ? (dir => 'ltr') : (), + data => { class_value => $globalValue } ) : '' ) if $forUsers; diff --git a/templates/ContentGenerator/Instructor/UserDetail/set_date_table.html.ep b/templates/ContentGenerator/Instructor/UserDetail/set_date_table.html.ep index 01a2496e96..12fabb25df 100644 --- a/templates/ContentGenerator/Instructor/UserDetail/set_date_table.html.ep +++ b/templates/ContentGenerator/Instructor/UserDetail/set_date_table.html.ep @@ -46,7 +46,8 @@ id => "set.$setID.$field.class_value", readonly => undef, dir => 'ltr', class => 'form-control-plaintext form-control-sm w-auto', size => 16, - defined $userRecord ? ('aria-labelledby' => "set.$setID.${field}_id") : () =%> + defined $userRecord ? ('aria-labelledby' => "set.$setID.${field}_id") : (), + data => { class_value => $globalRecord->$field } =%> % } % if (defined $userRecord) { From aefa295467fbdd3ccd5bf9bf61b59752e7ddf559 Mon Sep 17 00:00:00 2001 From: Glenn Rice Date: Thu, 14 Mar 2024 08:27:31 -0500 Subject: [PATCH 2/2] Change the fallback date behavior. Currently if a date is entered into a datepicker input that does not parse with the format of the current locale (for example if a date is typed without a time), then a fallback is filled in. That fallback is the last displayed valid value if there was one. If there was an intial value in the input, then that fallback will initially be the input's initial value. It is then updated anytime a new valid value is entered. However, if there was no initial value for the input, the fallback ends up being set to the last visible date in what is shown when the calender popup opens. That is sommewhat unexpected behavior. So this changes the way that the fallback works. If there is an initial value for the input (as will be the case when editing a set for the whole class), then that is still used for the initial fallback. However, if there is no initial value (as will be the case when editing the set for users), then the class value is used. In any case, the fallback will be updated to be the last valid value that is entered after that. There is a last case in which the input does not have an initial value and there also is no class value. In that case the fallback is the current date and time. This usually won't be used on the set detail page, but applies to the import date shift input on the set list. --- htdocs/js/DatePicker/datepicker.js | 46 +++++++++++----------- htdocs/js/ProblemSetList/problemsetlist.js | 11 +++--- 2 files changed, 28 insertions(+), 29 deletions(-) diff --git a/htdocs/js/DatePicker/datepicker.js b/htdocs/js/DatePicker/datepicker.js index 2cfd887834..b58ce42c08 100644 --- a/htdocs/js/DatePicker/datepicker.js +++ b/htdocs/js/DatePicker/datepicker.js @@ -24,13 +24,13 @@ const name = open_rule.name.replace('.open_date', ''); const groupRules = [ - open_rule, - document.getElementById(`${name}.due_date_id`), - document.getElementById(`${name}.answer_date_id`) + [open_rule], + [document.getElementById(`${name}.due_date_id`)], + [document.getElementById(`${name}.answer_date_id`)] ]; const reduced_rule = document.getElementById(`${name}.reduced_scoring_date_id`); - if (reduced_rule) groupRules.splice(1, 0, reduced_rule); + if (reduced_rule) groupRules.splice(1, 0, [reduced_rule]); // Compute the time difference between the current browser timezone and the course timezone. // flatpickr gives the time in the browser's timezone, and this is used to adjust to the course timezone. @@ -41,30 +41,31 @@ new Date().toLocaleString('en-US', { timeZone: open_rule.dataset.timezone ?? 'America/New_York' }) ).getTime(); - const classValues = groupRules.map( - (rule) => - parseInt(document.getElementsByName(`${rule.name}.class_value`)[0]?.dataset.classValue || '0') * 1000 - - timezoneAdjustment - ); + for (const rule of groupRules) { + const value = + rule[0].value || document.getElementsByName(`${rule[0].name}.class_value`)[0]?.dataset.classValue; + rule.push(value ? parseInt(value) * 1000 - timezoneAdjustment : 0); + } const update = () => { for (let i = 1; i < groupRules.length; ++i) { const prevFieldDate = - groupRules[i - 1]?.parentNode._flatpickr.selectedDates[0]?.getTime() || classValues[i - 1]; + groupRules[i - 1][0]?.parentNode._flatpickr.selectedDates[0]?.getTime() || groupRules[i - 1][1]; const thisFieldDate = - groupRules[i]?.parentNode._flatpickr.selectedDates[0]?.getTime() || classValues[i]; + groupRules[i][0]?.parentNode._flatpickr.selectedDates[0]?.getTime() || groupRules[i][1]; if (prevFieldDate && thisFieldDate && prevFieldDate > thisFieldDate) { - groupRules[i].parentNode._flatpickr.setDate(prevFieldDate, true); + groupRules[i][0].parentNode._flatpickr.setDate(prevFieldDate, true); } } }; for (const rule of groupRules) { - const orig_value = rule.value; + const orig_value = rule[0].value; + let fallbackDate = rule[1] ? new Date(rule[1]) : new Date(); - luxon.Settings.defaultLocale = rule.dataset.locale ?? 'en'; + luxon.Settings.defaultLocale = rule[0].dataset.locale ?? 'en'; - const fp = flatpickr(rule.parentNode, { + const fp = flatpickr(rule[0].parentNode, { allowInput: true, enableTime: true, minuteIncrement: 1, @@ -82,15 +83,15 @@ disableMobile: true, wrap: true, plugins: [ - new confirmDatePlugin({ confirmText: rule.dataset.doneText ?? 'Done', showAlways: true }), + new confirmDatePlugin({ confirmText: rule[0].dataset.doneText ?? 'Done', showAlways: true }), new ShortcutButtonsPlugin({ button: [ { - label: rule.dataset.todayText ?? 'Today', + label: rule[0].dataset.todayText ?? 'Today', attributes: { class: 'btn btn-sm btn-secondary ms-auto me-1 mb-1' } }, { - label: rule.dataset.nowText ?? 'Now', + label: rule[0].dataset.nowText ?? 'Now', attributes: { class: 'btn btn-sm btn-secondary me-auto mb-1' } } ], @@ -141,17 +142,14 @@ // Next attempt to parse the datestr with the current format. This should not be adjusted. It is // for display only. const date = luxon.DateTime.fromFormat(datestr.replaceAll(/\u202F/g, ' ').trim(), format); - if (date.isValid) return date.toJSDate(); + if (date.isValid) fallbackDate = date.toJSDate(); // Finally, fall back to the previous value in the original input if that failed. This is the case // that the user typed a time that isn't in the valid format. So fallback to the last valid time // that was displayed. This also should not be adjusted. - return new Date(this.lastFormattedDate.getTime()); + return fallbackDate; }, formatDate(date, format) { - // Save this date for the fallback in parseDate. - this.lastFormattedDate = date; - // In this case the date provided is in the browser's time zone. So it needs to be adjusted to the // timezone of the course. if (format === 'U') return (date.getTime() + timezoneAdjustment) / 1000; @@ -162,7 +160,7 @@ } }); - rule.nextElementSibling.addEventListener('keydown', (e) => { + rule[0].nextElementSibling.addEventListener('keydown', (e) => { if (e.key === ' ' || e.key === 'Enter') { e.preventDefault(); fp.open(); diff --git a/htdocs/js/ProblemSetList/problemsetlist.js b/htdocs/js/ProblemSetList/problemsetlist.js index 0981fc95cb..4af5a7cc1d 100644 --- a/htdocs/js/ProblemSetList/problemsetlist.js +++ b/htdocs/js/ProblemSetList/problemsetlist.js @@ -187,6 +187,10 @@ new Date().toLocaleString('en-US', { timeZone: importDateShift.dataset.timezone ?? 'America/New_York' }) ).getTime(); + let fallbackDate = importDateShift.value + ? new Date(parseInt(importDateShift.value) * 1000 - timezoneAdjustment) + : new Date(); + const fp = flatpickr(importDateShift.parentNode, { allowInput: true, enableTime: true, @@ -248,17 +252,14 @@ // Next attempt to parse the datestr with the current format. This should not be adjusted. It is // for display only. const date = luxon.DateTime.fromFormat(datestr.replaceAll(/\u202F/g, ' ').trim(), format); - if (date.isValid) return date.toJSDate(); + if (date.isValid) fallbackDate = date.toJSDate(); // Finally, fall back to the previous value in the original input if that failed. This is the case // that the user typed a time that isn't in the valid format. So fallback to the last valid time // that was displayed. This also should not be adjusted. - return new Date(this.lastFormattedDate.getTime()); + return fallbackDate; }, formatDate(date, format) { - // Save this date for the fallback in parseDate. - this.lastFormattedDate = date; - // In this case the date provided is in the browser's time zone. So it needs to be adjusted to the // timezone of the course. if (format === 'U') return (date.getTime() + timezoneAdjustment) / 1000;