Do not allow manual entry of insane years into datepickers

Fixes FOO-4288
flag=none

It's all too easy to inadvertently manually enter an incorrect
year when typing into either the jQuery or the InstUI date
input components. For example, while '20' is read as the year
2020, '2' becomes 1902 and '202' is left as the year 202. This
has created issues in custommer accounts.

Since Canvas dates are always related to some school term that
is likely to be in or near the present, it makes the most sense
to simply have the datepickers reject any year earlier than
1980. That figure was arbitrarily chosen but seems reasonable.

Test plan:
* Try both datepickers (suggest the course module "Lock Until"
  setting for the jQuery picker and the "Course Participation"
  start and end dates in the main course settings page).
* Free-form type a date. Any date that resolves to a year after
  1980 should work just as always; any earlier year should be
  rejected and the displayed error ("suggestion") below the
  picker field should indicate that the year is too far in
  the past.

Change-Id: Ib53d8c5cca1f9e76319323c5eda316eb63c34ef1
Reviewed-on: https://gerrit.instructure.com/c/canvas-lms/+/344411
Tested-by: Service Cloud Jenkins <svc.cloudjenkins@instructure.com>
Reviewed-by: August Thornton <august@instructure.com>
QA-Review: August Thornton <august@instructure.com>
Product-Review: Charley Kline <ckline@instructure.com>
This commit is contained in:
Charley Kline 2024-04-02 12:45:44 -05:00
parent 28a2c34666
commit edbed718bf
5 changed files with 127 additions and 89 deletions

View File

@ -19,7 +19,7 @@
import london from 'timezone/Europe/London' import london from 'timezone/Europe/London'
import * as tz from '@canvas/datetime' import * as tz from '@canvas/datetime'
import coupleTimeFields from '@canvas/calendar/jquery/coupleTimeFields' import coupleTimeFields from '@canvas/calendar/jquery/coupleTimeFields'
import DatetimeField from '@canvas/datetime/jquery/DatetimeField' import DatetimeField, {PARSE_RESULTS} from '@canvas/datetime/jquery/DatetimeField'
import $ from 'jquery' import $ from 'jquery'
import 'jquery-migrate' import 'jquery-migrate'
import fakeENV from 'helpers/fakeENV' import fakeENV from 'helpers/fakeENV'
@ -80,7 +80,7 @@ test('leaves invalid start alone', function () {
this.end.setTime(fixed) this.end.setTime(fixed)
coupleTimeFields(this.$start, this.$end) coupleTimeFields(this.$start, this.$end)
equal(this.$start.val(), 'invalid') equal(this.$start.val(), 'invalid')
equal(this.start.invalid, true) equal(this.start.valid, PARSE_RESULTS.ERROR)
}) })
test('leaves invalid end alone', function () { test('leaves invalid end alone', function () {
@ -89,7 +89,7 @@ test('leaves invalid end alone', function () {
this.end.setFromValue() this.end.setFromValue()
coupleTimeFields(this.$start, this.$end) coupleTimeFields(this.$start, this.$end)
equal(this.$end.val(), 'invalid') equal(this.$end.val(), 'invalid')
equal(this.end.invalid, true) equal(this.end.valid, PARSE_RESULTS.ERROR)
}) })
test('interprets time as occurring on date', function () { test('interprets time as occurring on date', function () {
@ -156,7 +156,7 @@ test('leaves invalid start alone', function () {
this.end.setTime(fixed) this.end.setTime(fixed)
this.$end.trigger('blur') this.$end.trigger('blur')
equal(this.$start.val(), 'invalid') equal(this.$start.val(), 'invalid')
equal(this.start.invalid, true) equal(this.start.valid, PARSE_RESULTS.ERROR)
}) })
test('leaves invalid end alone', function () { test('leaves invalid end alone', function () {
@ -165,7 +165,7 @@ test('leaves invalid end alone', function () {
this.end.setFromValue() this.end.setFromValue()
this.$start.trigger('blur') this.$start.trigger('blur')
equal(this.$end.val(), 'invalid') equal(this.$end.val(), 'invalid')
equal(this.end.invalid, true) equal(this.end.valid, PARSE_RESULTS.ERROR)
}) })
test('does not rewrite blurred input', function () { test('does not rewrite blurred input', function () {

View File

@ -20,6 +20,7 @@ import DatetimeField, {
DATE_FORMAT_OPTIONS, DATE_FORMAT_OPTIONS,
DATETIME_FORMAT_OPTIONS, DATETIME_FORMAT_OPTIONS,
TIME_FORMAT_OPTIONS, TIME_FORMAT_OPTIONS,
PARSE_RESULTS,
} from '@canvas/datetime/jquery/DatetimeField' } from '@canvas/datetime/jquery/DatetimeField'
import '@canvas/datetime/jquery' import '@canvas/datetime/jquery'
import $ from 'jquery' import $ from 'jquery'
@ -32,8 +33,8 @@ import juneau from 'timezone/America/Juneau'
import fakeENV from 'helpers/fakeENV' import fakeENV from 'helpers/fakeENV'
import moment from 'moment' import moment from 'moment'
const moonwalk = new Date('1969-07-21T02:56:00Z') const challenger = new Date('1986-01-28T16:39:00Z')
const john_glenn = new Date('1962-02-20T14:47:39') const columbia = new Date('2003-02-01T13:59:00Z')
QUnit.module('processTimeOptions', { QUnit.module('processTimeOptions', {
setup() { setup() {
@ -222,9 +223,9 @@ test('should add hidden input when requested', function () {
}) })
test('should initialize from the inputdate data', function () { test('should initialize from the inputdate data', function () {
this.$field.data('inputdate', '1969-07-21T02:56:00Z') this.$field.data('inputdate', '1986-01-28T16:39:00Z')
const field = new DatetimeField(this.$field, {}) const field = new DatetimeField(this.$field, {})
equal(field.$suggest.text(), 'Sun, Jul 20, 1969, 9:56 PM') equal(field.$suggest.text(), 'Tue, Jan 28, 1986, 11:39 AM')
}) })
test('should initialize from the initial value attribute if present, and then remove it', function () { test('should initialize from the initial value attribute if present, and then remove it', function () {
@ -239,8 +240,8 @@ test('should initialize from the initial value attribute if present, and then re
test('should tie it to update on keyup only', function () { test('should tie it to update on keyup only', function () {
const field = new DatetimeField(this.$field, {}) const field = new DatetimeField(this.$field, {})
this.$field.val('Jul 21, 1969 5:56am').trigger('keyup') this.$field.val('Jan 28, 1986 5:56am').trigger('keyup')
equal(field.$suggest.text(), 'Mon, Jul 21, 1969, 5:56 AM') equal(field.$suggest.text(), 'Tue, Jan 28, 1986, 5:56 AM')
}) })
QUnit.module('setFromValue', { QUnit.module('setFromValue', {
@ -269,9 +270,9 @@ test('should set data fields', function () {
}) })
test('should set suggest text', function () { test('should set suggest text', function () {
this.$field.val('Jul 21, 1969 at 2:56am') this.$field.val('Jan 28, 1986 at 2:56am')
this.field.setFromValue() this.field.setFromValue()
equal(this.field.$suggest.text(), 'Mon, Jul 21, 1969, 2:56 AM') equal(this.field.$suggest.text(), 'Tue, Jan 28, 1986, 2:56 AM')
}) })
QUnit.module('parseValue', { QUnit.module('parseValue', {
@ -282,15 +283,15 @@ QUnit.module('parseValue', {
}) })
test('sets @fudged according to browser (fudged) timezone', function () { test('sets @fudged according to browser (fudged) timezone', function () {
this.$field.val(tz.format(moonwalk, '%b %-e, %Y at %-l:%M%P')).change() this.$field.val(tz.format(challenger, '%b %-e, %Y at %-l:%M%P')).change()
this.field.parseValue() this.field.parseValue()
equal(+this.field.fudged, +$.fudgeDateForProfileTimezone(moonwalk)) equal(+this.field.fudged, +$.fudgeDateForProfileTimezone(challenger))
}) })
test('sets @datetime according to profile timezone', function () { test('sets @datetime according to profile timezone', function () {
this.$field.val(tz.format(moonwalk, '%b %-e, %Y at %-l:%M%P')).change() this.$field.val(tz.format(challenger, '%b %-e, %Y at %-l:%M%P')).change()
this.field.parseValue() this.field.parseValue()
equal(+this.field.datetime, +moonwalk) equal(+this.field.datetime, +challenger)
}) })
test('sets @showTime true by default', function () { test('sets @showTime true by default', function () {
@ -300,14 +301,14 @@ test('sets @showTime true by default', function () {
}) })
test('sets @showTime false when value is midnight in profile timezone', function () { test('sets @showTime false when value is midnight in profile timezone', function () {
this.$field.val('Jan 1, 1970 at 12:00am').change() this.$field.val('Jan 1, 1990 at 12:00am').change()
this.field.parseValue() this.field.parseValue()
equal(this.field.showTime, false) equal(this.field.showTime, false)
}) })
test('sets @showTime true for midnight if @alwaysShowTime', function () { test('sets @showTime true for midnight if @alwaysShowTime', function () {
this.field.alwaysShowTime = true this.field.alwaysShowTime = true
this.$field.val('Jan 1, 1970 at 12:00am').change() this.$field.val('Jan 1, 1990 at 12:00am').change()
this.field.parseValue() this.field.parseValue()
equal(this.field.showTime, true) equal(this.field.showTime, true)
}) })
@ -320,17 +321,17 @@ test('sets @showTime false for non-midnight if not @allowTime', function () {
}) })
test('sets not @blank and not @invalid on valid input', function () { test('sets not @blank and not @invalid on valid input', function () {
this.$field.val('Jan 1, 1970 at 12:00am').change() this.$field.val('Jan 1, 1990 at 12:00am').change()
this.field.parseValue() this.field.parseValue()
equal(this.field.blank, false) equal(this.field.blank, false)
equal(this.field.invalid, false) equal(this.field.valid, PARSE_RESULTS.VALID)
}) })
test('sets @blank and not @invalid and null dates when no input', function () { test('sets @blank and not @invalid and null dates when no input', function () {
this.$field.val('').change() this.$field.val('').change()
this.field.parseValue() this.field.parseValue()
equal(this.field.blank, true) equal(this.field.blank, true)
equal(this.field.invalid, false) equal(this.field.valid, PARSE_RESULTS.VALID)
equal(this.field.datetime, null) equal(this.field.datetime, null)
equal(this.field.fudged, null) equal(this.field.fudged, null)
}) })
@ -339,7 +340,7 @@ test('sets @invalid and not @blank and null dates when invalid input', function
this.$field.val('invalid').change() this.$field.val('invalid').change()
this.field.parseValue() this.field.parseValue()
equal(this.field.blank, false) equal(this.field.blank, false)
equal(this.field.invalid, true) equal(this.field.valid, PARSE_RESULTS.ERROR)
equal(this.field.datetime, null) equal(this.field.datetime, null)
equal(this.field.fudged, null) equal(this.field.fudged, null)
}) })
@ -363,18 +364,18 @@ test('interprets bare numbers >= 8 in time-only fields as 24-hour', function ()
test('interprets time-only fields as occurring on implicit date if set', function () { test('interprets time-only fields as occurring on implicit date if set', function () {
this.field.showDate = false this.field.showDate = false
this.field.setDate(moonwalk) this.field.setDate(challenger)
this.$field.val('12PM').change() this.$field.val('12PM').change()
this.field.parseValue() this.field.parseValue()
equal(tz.format(this.field.datetime, '%F %T'), `${tz.format(moonwalk, '%F ')}12:00:00`) equal(tz.format(this.field.datetime, '%F %T'), `${tz.format(challenger, '%F ')}12:00:00`)
}) })
test('setDate changes the date of an existing time field', function () { test('setDate changes the date of an existing time field', function () {
this.field.showDate = false this.field.showDate = false
this.field.setDate(moonwalk) this.field.setDate(challenger)
this.$field.val('12PM').change() this.$field.val('12PM').change()
this.field.setDate(john_glenn) this.field.setDate(columbia)
equal(tz.format(this.field.datetime, '%F %T'), `${tz.format(john_glenn, '%F ')}12:00:00`) equal(tz.format(this.field.datetime, '%F %T'), `${tz.format(columbia, '%F ')}12:00:00`)
}) })
QUnit.module('updateData', { QUnit.module('updateData', {
@ -388,10 +389,10 @@ QUnit.module('updateData', {
fakeENV.setup({TIMEZONE: 'America/Detroit'}) fakeENV.setup({TIMEZONE: 'America/Detroit'})
this.$field = $('<input type="text" name="due_at">') this.$field = $('<input type="text" name="due_at">')
this.$field.val('Jan 1, 1970 at 12:01am') this.$field.val('Jan 1, 1990 at 12:01am')
this.field = new DatetimeField(this.$field, {}) this.field = new DatetimeField(this.$field, {})
this.field.datetime = moonwalk this.field.datetime = challenger
this.field.fudged = $.fudgeDateForProfileTimezone(moonwalk) this.field.fudged = $.fudgeDateForProfileTimezone(challenger)
}, },
teardown() { teardown() {
@ -407,7 +408,7 @@ test('sets date field to fudged time', function () {
test('sets unfudged-date field to actual time', function () { test('sets unfudged-date field to actual time', function () {
this.field.updateData() this.field.updateData()
equal(+this.$field.data('unfudged-date'), +moonwalk) equal(+this.$field.data('unfudged-date'), +challenger)
}) })
test('sets invalid field', function () { test('sets invalid field', function () {
@ -428,16 +429,16 @@ test('sets value of hiddenInput, if present, to fudged time as ISO8601', functio
test('sets time-* to fudged, 12-hour values', function () { test('sets time-* to fudged, 12-hour values', function () {
this.field.updateData() this.field.updateData()
equal(this.$field.data('time-hour'), '9') equal(this.$field.data('time-hour'), '11')
equal(this.$field.data('time-minute'), '56') equal(this.$field.data('time-minute'), '39')
equal(this.$field.data('time-ampm'), 'PM') equal(this.$field.data('time-ampm'), 'AM')
}) })
test('sets time-* to fudged, 24-hour values', function () { test('sets time-* to fudged, 24-hour values', function () {
ENV.LOCALE = 'pt-BR' ENV.LOCALE = 'pt-BR'
this.field.updateData() this.field.updateData()
equal(this.$field.data('time-hour'), '21') equal(this.$field.data('time-hour'), '11')
equal(this.$field.data('time-minute'), '56') equal(this.$field.data('time-minute'), '39')
equal(this.$field.data('time-ampm'), null) equal(this.$field.data('time-ampm'), null)
}) })
@ -458,7 +459,7 @@ test('clear time-* to null if blank', function () {
}) })
test('clear time-* to null if invalid', function () { test('clear time-* to null if invalid', function () {
this.field.invalid = true this.field.valid = PARSE_RESULTS.ERROR
this.field.updateData() this.field.updateData()
equal(this.$field.data('time-hour'), null) equal(this.$field.data('time-hour'), null)
}) })
@ -510,7 +511,7 @@ test('omits course suggest text if formatSuggestContext is empty', function () {
test('adds invalid_datetime class to suggest if invalid', function () { test('adds invalid_datetime class to suggest if invalid', function () {
this.field.updateSuggest() this.field.updateSuggest()
ok(!this.field.$suggest.hasClass('invalid_datetime')) ok(!this.field.$suggest.hasClass('invalid_datetime'))
this.field.invalid = true this.field.valid = PARSE_RESULTS.ERROR
this.field.updateSuggest() this.field.updateSuggest()
ok(this.field.$suggest.hasClass('invalid_datetime')) ok(this.field.$suggest.hasClass('invalid_datetime'))
}) })
@ -533,6 +534,12 @@ test('should alert screenreader on an invalid parse no matter what', function ()
ok(this.field.debouncedSRFME.withArgs("That's not a date!").called) ok(this.field.debouncedSRFME.withArgs("That's not a date!").called)
}) })
test('should alert on an impossible year entered', function () {
this.$field.val('Jul 19 1964')
this.$field.change()
ok(this.field.debouncedSRFME.withArgs('Year is too far in the past.').called)
})
test('flashes suggest text to screenreader on typed input', function () { test('flashes suggest text to screenreader on typed input', function () {
const value = 'suggested value' const value = 'suggested value'
this.field.formatSuggest = () => value this.field.formatSuggest = () => value
@ -573,7 +580,7 @@ QUnit.module('formatSuggest', {
}) })
fakeENV.setup({TIMEZONE: 'America/Detroit'}) fakeENV.setup({TIMEZONE: 'America/Detroit'})
this.$field = $('<input type="text" name="due_at">') this.$field = $('<input type="text" name="due_at">')
this.$field.val('Jul 20, 1969 at 9:56pm') this.$field.val('Jan 28, 1986 at 11:39am')
this.field = new DatetimeField(this.$field, {}) this.field = new DatetimeField(this.$field, {})
}, },
@ -584,7 +591,7 @@ QUnit.module('formatSuggest', {
}) })
test('returns result formatted in profile timezone', function () { test('returns result formatted in profile timezone', function () {
equal(this.field.formatSuggest(), 'Sun, Jul 20, 1969, 9:56 PM') equal(this.field.formatSuggest(), 'Tue, Jan 28, 1986, 11:39 AM')
}) })
test('returns "" if @blank', function () { test('returns "" if @blank', function () {
@ -593,23 +600,23 @@ test('returns "" if @blank', function () {
}) })
test('returns error message if @invalid', function () { test('returns error message if @invalid', function () {
this.field.invalid = true this.field.valid = PARSE_RESULTS.ERROR
equal(this.field.formatSuggest(), this.field.parseError) equal(this.field.formatSuggest(), this.field.parseError)
}) })
test('returns date only if @showTime false', function () { test('returns date only if @showTime false', function () {
this.field.showTime = false this.field.showTime = false
equal(this.field.formatSuggest(), 'Sun, Jul 20, 1969') equal(this.field.formatSuggest(), 'Tue, Jan 28, 1986')
}) })
test('returns time only if @showDate false', function () { test('returns time only if @showDate false', function () {
this.field.showDate = false this.field.showDate = false
equal(this.field.formatSuggest(), '9:56 PM') equal(this.field.formatSuggest(), '11:39 AM')
}) })
test('localizes formatting of dates and times', function () { test('localizes formatting of dates and times', function () {
ENV.LOCALE = 'pt-BR' ENV.LOCALE = 'pt-BR'
equal(this.field.formatSuggest(), 'dom., 20 de jul. de 1969, 21:56') equal(this.field.formatSuggest(), 'ter., 28 de jan. de 1986, 11:39')
}) })
QUnit.module('formatSuggestContext', { QUnit.module('formatSuggestContext', {
@ -623,7 +630,7 @@ QUnit.module('formatSuggestContext', {
}) })
fakeENV.setup({TIMEZONE: 'America/Detroit', CONTEXT_TIMEZONE: 'America/Juneau'}) fakeENV.setup({TIMEZONE: 'America/Detroit', CONTEXT_TIMEZONE: 'America/Juneau'})
this.$field = $('<input type="text" name="due_at">') this.$field = $('<input type="text" name="due_at">')
this.$field.val('Jul 20, 1969 at 9:56pm') this.$field.val('Jan 28, 1986 at 11:39am')
this.field = new DatetimeField(this.$field, {}) this.field = new DatetimeField(this.$field, {})
}, },
@ -634,7 +641,7 @@ QUnit.module('formatSuggestContext', {
}) })
test('returns result formatted in course timezone', function () { test('returns result formatted in course timezone', function () {
equal(this.field.formatSuggestContext(), 'Sun, Jul 20, 1969, 7:56 PM') equal(this.field.formatSuggestContext(), 'Tue, Jan 28, 1986, 7:39 AM')
}) })
test('returns "" if @blank', function () { test('returns "" if @blank', function () {
@ -643,7 +650,7 @@ test('returns "" if @blank', function () {
}) })
test('returns "" if @invalid', function () { test('returns "" if @invalid', function () {
this.field.invalid = true this.field.valid = PARSE_RESULTS.ERROR
equal(this.field.formatSuggestContext(), '') equal(this.field.formatSuggestContext(), '')
}) })
@ -654,7 +661,7 @@ test('returns "" if @showTime false', function () {
test('returns time only if @showDate false', function () { test('returns time only if @showDate false', function () {
this.field.showDate = false this.field.showDate = false
equal(this.field.formatSuggestContext(), '7:56 PM') equal(this.field.formatSuggestContext(), '7:39 AM')
}) })
QUnit.module('normalizeValue', { QUnit.module('normalizeValue', {
@ -740,30 +747,30 @@ test('sets to blank with null value', function () {
equal(this.field.datetime, null) equal(this.field.datetime, null)
equal(this.field.fudged, null) equal(this.field.fudged, null)
equal(this.field.blank, true) equal(this.field.blank, true)
equal(this.field.invalid, false) equal(this.field.valid, PARSE_RESULTS.VALID)
equal(this.$field.val(), '') equal(this.$field.val(), '')
}) })
test('treats value as unfudged', function () { test('treats value as unfudged', function () {
this.field.setFormattedDatetime(moonwalk, DATETIME_FORMAT_OPTIONS) this.field.setFormattedDatetime(challenger, DATETIME_FORMAT_OPTIONS)
equal(+this.field.datetime, +moonwalk) equal(+this.field.datetime, +challenger)
equal(+this.field.fudged, +$.fudgeDateForProfileTimezone(moonwalk)) equal(+this.field.fudged, +$.fudgeDateForProfileTimezone(challenger))
equal(this.field.blank, false) equal(this.field.blank, false)
equal(this.field.invalid, false) equal(this.field.valid, PARSE_RESULTS.VALID)
equal(this.$field.val(), 'Sun, Jul 20, 1969, 9:56 PM') equal(this.$field.val(), 'Tue, Jan 28, 1986, 11:39 AM')
}) })
test('formats value into val() according to date/time requests', function () { test('formats value into val() according to date/time requests', function () {
this.field.setFormattedDatetime(moonwalk, DATE_FORMAT_OPTIONS) this.field.setFormattedDatetime(challenger, DATE_FORMAT_OPTIONS)
equal(this.$field.val(), 'Sun, Jul 20, 1969') equal(this.$field.val(), 'Tue, Jan 28, 1986')
this.field.setFormattedDatetime(moonwalk, TIME_FORMAT_OPTIONS) this.field.setFormattedDatetime(challenger, TIME_FORMAT_OPTIONS)
equal(this.$field.val(), '9:56 PM') equal(this.$field.val(), '11:39 AM')
}) })
test('localizes value', function () { test('localizes value', function () {
ENV.LOCALE = 'de' ENV.LOCALE = 'de'
this.field.setFormattedDatetime(moonwalk, DATETIME_FORMAT_OPTIONS) this.field.setFormattedDatetime(challenger, DATETIME_FORMAT_OPTIONS)
equal(this.$field.val(), 'So., 20. Juli 1969, 21:56') equal(this.$field.val(), 'Di., 28. Jan. 1986, 11:39')
}) })
QUnit.module('setDate/setTime/setDatetime', { QUnit.module('setDate/setTime/setDatetime', {
@ -786,16 +793,16 @@ QUnit.module('setDate/setTime/setDatetime', {
}) })
test('setDate formats into val() with just date', function () { test('setDate formats into val() with just date', function () {
this.field.setDate(moonwalk) this.field.setDate(challenger)
equal(this.$field.val(), 'Sun, Jul 20, 1969') equal(this.$field.val(), 'Tue, Jan 28, 1986')
}) })
test('setTime formats into val() with just time', function () { test('setTime formats into val() with just time', function () {
this.field.setTime(moonwalk) this.field.setTime(challenger)
equal(this.$field.val(), '9:56 PM') equal(this.$field.val(), '11:39 AM')
}) })
test('setDatetime formats into val() with full date and time', function () { test('setDatetime formats into val() with full date and time', function () {
this.field.setDatetime(moonwalk) this.field.setDatetime(challenger)
equal(this.$field.val(), 'Sun, Jul 20, 1969, 9:56 PM') equal(this.$field.val(), 'Tue, Jan 28, 1986, 11:39 AM')
}) })

View File

@ -79,9 +79,9 @@ export default function CourseAvailabilityOptions({canManage, viewPastLocked, vi
const formatDate = date => tz.format(date, 'date.formats.full') const formatDate = date => tz.format(date, 'date.formats.full')
const parseDate = (date, tz) => { const parseDate = (date, originTZ) => {
const dateObj = new Date(date) const dateObj = new Date(date)
const parsedDate = changeTimezone(dateObj, {originTZ: tz, desiredTZ: ENV.TIMEZONE}) const parsedDate = changeTimezone(dateObj, {originTZ, desiredTZ: ENV.TIMEZONE})
return formatDate(parsedDate) return formatDate(parsedDate)
} }

View File

@ -40,14 +40,23 @@ const DATE_FORMAT_OPTIONS = {
year: 'numeric', year: 'numeric',
} }
const EARLIEST_YEAR = 1980 // do not allow any manually entered year before this
const PARSE_RESULTS = {
VALID: 0,
ERROR: 1,
BAD_YEAR: 2,
}
const DATETIME_FORMAT_OPTIONS = {...DATE_FORMAT_OPTIONS, ...TIME_FORMAT_OPTIONS} const DATETIME_FORMAT_OPTIONS = {...DATE_FORMAT_OPTIONS, ...TIME_FORMAT_OPTIONS}
Object.freeze(TIME_FORMAT_OPTIONS) Object.freeze(TIME_FORMAT_OPTIONS)
Object.freeze(DATE_FORMAT_OPTIONS) Object.freeze(DATE_FORMAT_OPTIONS)
Object.freeze(DATETIME_FORMAT_OPTIONS) Object.freeze(DATETIME_FORMAT_OPTIONS)
Object.freeze(PARSE_RESULTS)
// for tests only // for tests only
export {TIME_FORMAT_OPTIONS, DATE_FORMAT_OPTIONS, DATETIME_FORMAT_OPTIONS} export {TIME_FORMAT_OPTIONS, DATE_FORMAT_OPTIONS, DATETIME_FORMAT_OPTIONS, PARSE_RESULTS}
function formatter(zone, formatOptions = DATETIME_FORMAT_OPTIONS) { function formatter(zone, formatOptions = DATETIME_FORMAT_OPTIONS) {
const options = {...formatOptions} const options = {...formatOptions}
@ -149,6 +158,10 @@ export default class DatetimeField {
} }
} }
invalid() {
return this.valid !== PARSE_RESULTS.VALID
}
processTimeOptions(options) { processTimeOptions(options) {
// default undefineds to false // default undefineds to false
let {timeOnly, dateOnly} = options let {timeOnly, dateOnly} = options
@ -285,26 +298,29 @@ export default class DatetimeField {
} }
parseValue(val) { parseValue(val) {
const previousDate = this.datetime
if (typeof val === 'undefined' && this.$field.data('inputdate')) { if (typeof val === 'undefined' && this.$field.data('inputdate')) {
const inputdate = this.$field.data('inputdate') const inputdate = this.$field.data('inputdate')
this.datetime = inputdate instanceof Date ? inputdate : new Date(inputdate) this.datetime = inputdate instanceof Date ? inputdate : new Date(inputdate)
this.blank = false this.blank = false
this.invalid = this.datetime === null this.valid = PARSE_RESULTS.VALID
if (this.datetime === null) this.valid = PARSE_RESULTS.ERROR
if (this.datetime && this.datetime.getFullYear() < EARLIEST_YEAR)
this.valid = PARSE_RESULTS.BAD_YEAR
this.$field.data('inputdate', null) this.$field.data('inputdate', null)
} else { } else {
const previousDate = this.datetime if (val) this.setFormattedDatetime(val, this.intlFormatType())
if (val) {
this.setFormattedDatetime(val, TIME_FORMAT_OPTIONS)
}
const value = this.normalizeValue(this.$field.val()) const value = this.normalizeValue(this.$field.val())
this.datetime = tz.parse(value) this.datetime = tz.parse(value)
this.blank = !value this.blank = !value
this.invalid = !this.blank && this.datetime === null this.valid = PARSE_RESULTS.VALID
// If the date is invalid, revert to the previous date if (!this.blank && this.datetime === null) this.valid = PARSE_RESULTS.ERROR
if (this.invalid) { if (this.datetime && this.datetime.getFullYear() < EARLIEST_YEAR)
this.datetime = previousDate this.valid = PARSE_RESULTS.BAD_YEAR
}
} }
// If the date is invalid, revert to the previous date
if (this.invalid()) this.datetime = previousDate
if (this.datetime && !this.showDate && this.implicitDate) { if (this.datetime && !this.showDate && this.implicitDate) {
this.datetime = tz.mergeTimeAndDate(this.datetime, this.implicitDate) this.datetime = tz.mergeTimeAndDate(this.datetime, this.implicitDate)
} }
@ -326,7 +342,7 @@ export default class DatetimeField {
this.fudged = null this.fudged = null
this.$field.val('') this.$field.val('')
} }
this.invalid = false this.valid = PARSE_RESULTS.VALID
this.showTime = this.alwaysShowTime || (this.allowTime && !tz.isMidnight(this.datetime)) this.showTime = this.alwaysShowTime || (this.allowTime && !tz.isMidnight(this.datetime))
this.update() this.update()
this.updateSuggest(false) this.updateSuggest(false)
@ -344,7 +360,7 @@ export default class DatetimeField {
date: this.fudged, date: this.fudged,
iso8601, iso8601,
blank: this.blank, blank: this.blank,
invalid: this.invalid, invalid: this.invalid(),
}) })
if (this.$hiddenInput) { if (this.$hiddenInput) {
@ -354,7 +370,7 @@ export default class DatetimeField {
// date_fields and time_fields don't have timepicker data fields // date_fields and time_fields don't have timepicker data fields
if (!(this.showDate && this.allowTime)) return if (!(this.showDate && this.allowTime)) return
if (this.invalid || this.blank || !this.showTime) { if (this.invalid() || this.blank || !this.showTime) {
this.$field.data({ this.$field.data({
'time-hour': null, 'time-hour': null,
'time-minute': null, 'time-minute': null,
@ -384,8 +400,8 @@ export default class DatetimeField {
} }
this.$contextSuggest.text(contextText).show() this.$contextSuggest.text(contextText).show()
} }
this.$suggest.toggleClass('invalid_datetime', this.invalid).text(localText) this.$suggest.toggleClass('invalid_datetime', this.invalid()).text(localText)
if (show || this.$contextSuggest || this.invalid) { if (show || this.$contextSuggest || this.invalid()) {
this.$suggest.show() this.$suggest.show()
return return
} }
@ -407,7 +423,7 @@ export default class DatetimeField {
} }
updateAria() { updateAria() {
this.$field.attr('aria-invalid', !!this.invalid) this.$field.attr('aria-invalid', this.invalid())
} }
intlFormatType() { intlFormatType() {
@ -417,13 +433,13 @@ export default class DatetimeField {
} }
formatSuggest() { formatSuggest() {
if (this.invalid) return this.parseError if (this.invalid()) return this.parseError
if (this.blank) return '' if (this.blank) return ''
return formatter(ENV.TIMEZONE, this.intlFormatType()).format(this.datetime) return formatter(ENV.TIMEZONE, this.intlFormatType()).format(this.datetime)
} }
formatSuggestContext() { formatSuggestContext() {
if (this.invalid || !this.showTime || this.blank) return '' if (this.invalid() || !this.showTime || this.blank) return ''
return formatter(this.contextTimezone, this.intlFormatType()).format(this.datetime) return formatter(this.contextTimezone, this.intlFormatType()).format(this.datetime)
} }
@ -438,6 +454,7 @@ export default class DatetimeField {
} }
get parseError() { get parseError() {
if (this.valid === PARSE_RESULTS.BAD_YEAR) return I18n.t('Year is too far in the past.')
return I18n.t('errors.not_a_date', "That's not a date!") return I18n.t('errors.not_a_date', "That's not a date!")
} }
} }

View File

@ -49,6 +49,8 @@ type BlurReturn = SyntheticEvent<Element, Event> | KeyboardEvent<DateInputProps>
const I18n = useI18nScope('app_shared_components_canvas_date_time') const I18n = useI18nScope('app_shared_components_canvas_date_time')
const EARLIEST_YEAR = 1980 // do not allow any manually entered year before this
export type CanvasDateInputProps = { export type CanvasDateInputProps = {
/** /**
* Represents the initial date to be selected. May be `undefined` for no selected date. * Represents the initial date to be selected. May be `undefined` for no selected date.
@ -288,10 +290,22 @@ export default function CanvasDateInput({
if (isShowingCalendar && withRunningValue) handleHideCalendar() if (isShowingCalendar && withRunningValue) handleHideCalendar()
const newDate = tz.parse(value, timezone) const newDate = tz.parse(value, timezone)
if (newDate) { if (newDate) {
const year = newDate.getFullYear()
if (year < EARLIEST_YEAR) {
setInternalMessages([
{
type: 'error',
text: I18n.t('Year %{year} is too far in the past', {year: String(year)}),
},
])
return
}
const msgs: Messages = withRunningValue ? [{type: 'success', text: formatDate(newDate)}] : [] const msgs: Messages = withRunningValue ? [{type: 'success', text: formatDate(newDate)}] : []
setRenderedMoment(moment.tz(newDate, timezone)) setRenderedMoment(moment.tz(newDate, timezone))
setInternalMessages(msgs) setInternalMessages(msgs)
} else if (value === '') { return
}
if (value === '') {
setInternalMessages([]) setInternalMessages([])
} else { } else {
const text = invalidText(value) const text = invalidText(value)