diff --git a/app/client/widgets/DateEditor.js b/app/client/widgets/DateEditor.js index bc33dcdc..dfc390c7 100644 --- a/app/client/widgets/DateEditor.js +++ b/app/client/widgets/DateEditor.js @@ -34,13 +34,11 @@ function DateEditor(options) { this.dateFormat = options.field.widgetOptionsJson.peek().dateFormat; this.locale = options.field.documentSettings.peek().locale; - // Strip moment format string to remove markers unsupported by the datepicker. - this.safeFormat = DateEditor.parseMomentToSafe(this.dateFormat); - - this._readonly = options.readonly; + // Update moment format string to represent a date unambiguously. + this.safeFormat = makeFullMomentFormat(this.dateFormat); // Use the default local timezone to format the placeholder date. - let defaultTimezone = moment.tz.guess(); + const defaultTimezone = moment.tz.guess(); let placeholder = moment.tz(defaultTimezone).format(this.safeFormat); if (options.readonly) { // clear placeholder for readonly mode @@ -48,13 +46,7 @@ function DateEditor(options) { } TextEditor.call(this, _.defaults(options, { placeholder: placeholder })); - const isValid = _.isNumber(options.cellValue); - const formatted = this.formatValue(options.cellValue, this.safeFormat); - // Formatted value will be empty if a cell contains an error, - // but for a readonly mode we actually want to show what user typed - // into the cell. - const readonlyValue = isValid ? formatted : options.cellValue; - const cellValue = options.readonly ? readonlyValue : formatted; + const cellValue = this.formatValue(options.cellValue, this.safeFormat, true); // Set the edited value, if not explicitly given, to the formatted version of cellValue. this.textInput.value = gutil.undef(options.state, options.editValue, cellValue); @@ -74,8 +66,17 @@ function DateEditor(options) { // or by script tag, i.e. // language : this.getLanguage(), - // Convert the stripped format string to one suitable for the datepicker. - format: DateEditor.parseSafeToCalendar(this.safeFormat) + // Use the stripped format converted to one suitable for the datepicker. + format: { + toDisplay: (date, format, language) => moment.utc(date).format(this.safeFormat), + toValue: (date, format, language) => { + const timestampSec = parseDate(date, { + dateFormat: this.safeFormat, + timezone: this.timezone, + }); + return (timestampSec === null) ? null : new Date(timestampSec * 1000); + }, + }, }); this.autoDisposeCallback(() => this._datePickerWidget.datepicker('destroy')); @@ -137,43 +138,15 @@ DateEditor.prototype._allowKeyboardNav = function(bool) { }; // Moment value formatting helper. -DateEditor.prototype.formatValue = function(value, formatString) { +DateEditor.prototype.formatValue = function(value, formatString, shouldFallBackToValue) { if (_.isNumber(value) && formatString) { return moment.tz(value*1000, this.timezone).format(formatString); } else { - return ""; + // If value is AltText, return it unchanged. This way we can see it and edit in the editor. + return (shouldFallBackToValue && typeof value === 'string') ? value : ""; } }; -// Formats Moment string to remove markers unsupported by the datepicker. -// Moment reference: http://momentjs.com/docs/#/displaying/ -DateEditor.parseMomentToSafe = function(mFormat) { - // Remove markers not representing year, month, or date, and also DDD, DDDo, DDDD, d, do, - // (and following whitespace/punctuation) since they are unsupported by the datepicker. - mFormat = mFormat.replace(/\b(?:[^DMY\W]+|D{3,4}o*)\b\W+/g, ''); - // Convert other markers unsupported by the datepicker to similar supported markers. - mFormat = mFormat.replace(/\b([MD])o\b/g, '$1'); // Mo -> M, Do -> D - // Check which information the format contains. Format is only valid for editing if it - // contains day, month and year information. - var dayRe = /D{1,2}/g; - var monthRe = /M{1,4}/g; - var yearRe = /Y{2,4}/g; - var valid = dayRe.test(mFormat) && monthRe.test(mFormat) && yearRe.test(mFormat); - return valid ? mFormat : 'YYYY-MM-DD'; // Use basic format if given is invalid. -}; - -// Formats Moment string without datepicker unsupported markers for the datepicker. -// Datepicker reference: http://bootstrap-datepicker.readthedocs.org/en/latest/options.html#format -DateEditor.parseSafeToCalendar = function(sFormat) { - // M -> m, MM -> mm, D -> d, DD -> dd, YY -> yy, YYYY -> yyyy - sFormat = sFormat.replace(/\b(?:[MD]{1,2}|Y{2,4})\b/g, function(x) { - return x.toLowerCase(); - }); - sFormat = sFormat.replace(/\bM{2}(?=M{1,2}\b)/g, ''); // MMM -> M, MMMM -> MM - sFormat = sFormat.replace(/\bddd\b/g, 'D'); // ddd -> D - return sFormat.replace(/\bdddd\b/g, 'DD'); // dddd -> DD -}; - // Gets the language based on the current locale. DateEditor.prototype.getLanguage = function() { // this requires a polyfill, i.e. https://www.npmjs.com/package/@formatjs/intl-locale @@ -182,5 +155,17 @@ DateEditor.prototype.getLanguage = function() { return this.locale.substr(0, this.locale.indexOf("-")); } +// Updates the given Moment format to specify a complete date, so that the datepicker sees an +// unambiguous date in the textbox input. If the format is incomplete, fall back to YYYY-MM-DD. +function makeFullMomentFormat(mFormat) { + let safeFormat = mFormat; + if (!safeFormat.includes('Y')) { + safeFormat += " YYYY"; + } + if (!safeFormat.includes('D') || !safeFormat.includes('M')) { + safeFormat = 'YYYY-MM-DD'; + } + return safeFormat; +} module.exports = DateEditor; diff --git a/app/client/widgets/DateTimeEditor.js b/app/client/widgets/DateTimeEditor.js index d4b6a92d..a4ba1edc 100644 --- a/app/client/widgets/DateTimeEditor.js +++ b/app/client/widgets/DateTimeEditor.js @@ -39,7 +39,7 @@ function DateTimeEditor(options) { this._dateInput = this.textInput; const isValid = _.isNumber(options.cellValue); - const formatted = this.formatValue(options.cellValue, this._timeFormat); + const formatted = this.formatValue(options.cellValue, this._timeFormat, false); // Use a placeholder of 12:00am, since that is the autofill time value. const placeholder = moment.tz('0', 'H', this.timezone).format(this._timeFormat); diff --git a/app/common/parseDate.ts b/app/common/parseDate.ts index 3bcd14e2..c2fb8d79 100644 --- a/app/common/parseDate.ts +++ b/app/common/parseDate.ts @@ -200,7 +200,9 @@ export function parseDateTime(dateTime: string, options: ParseOptions): number | return; } - const dateString = moment.unix(date).format("YYYY-MM-DD"); + // date is a timestamp of midnight in UTC, so to get a formatted representation (for parsing + // together with time), take care to interpret it in UTC. + const dateString = moment.unix(date).utc().format("YYYY-MM-DD"); dateTime = dateString + ' ' + parsedTime.time + tzOffset; const fullFormat = "YYYY-MM-DD HH:mm:ss" + (tzOffset ? 'Z' : ''); return moment.tz(dateTime, fullFormat, true, timezone).valueOf() / 1000; @@ -213,7 +215,7 @@ export function parseDateTime(dateTime: string, options: ParseOptions): number | // feature. function _getPartialFormat(input: string, format: string): string { // Define a regular expression to match contiguous non-separators. - const re = /Y+|M+|D+|[a-zA-Z0-9]+/g; + const re = /Y+|M+o?|D+o?|[a-zA-Z0-9]+/ig; // Count the number of meaningful parts in the input. const numInputParts = input.match(re)?.length || 0; diff --git a/static/app.html b/static/app.html index f89498c0..89f5032a 100644 --- a/static/app.html +++ b/static/app.html @@ -57,12 +57,12 @@ - - - - + + + + - + diff --git a/test/nbrowser/gristUtils.ts b/test/nbrowser/gristUtils.ts index 5726b246..2d2a688f 100644 --- a/test/nbrowser/gristUtils.ts +++ b/test/nbrowser/gristUtils.ts @@ -1012,7 +1012,7 @@ export async function setType(type: RegExp, options: {skipWait?: boolean} = {}) await toggleSidePanel('right', 'open'); await driver.find('.test-right-tab-field').click(); await driver.find('.test-fbuilder-type-select').click(); - await driver.findContent('.test-select-menu .test-select-row', type).click(); + await driver.findContentWait('.test-select-menu .test-select-row', type, 200).click(); if (!options.skipWait) { await waitForServer(); } } @@ -1710,7 +1710,7 @@ export async function getDateFormat(): Promise { */ export async function setDateFormat(format: string) { await driver.find('[data-test-id=Widget_dateFormat]').click(); - await driver.findContent('.test-select-menu .test-select-row', format).click(); + await driver.findContentWait('.test-select-menu .test-select-row', format, 200).click(); await waitForServer(); }