(core) Add support for locales to DateEditor and DateTimeEditor.

Summary:
Addresses request https://github.com/gristlabs/grist-core/issues/443

The bootstrap-datepicker used for the Date/DateTime dropdown calendar does
support a number of locales. This diff loads them on-demand, if available,
based on the document's locale setting.

Also:
- Improves NewBaseEditor typings, reduces some casts, adds comments.
- Converts DateEditor and DateTimeEditor to typescript.
- Moves DateEditor nbrowser test to core.

Test Plan: Added a test case for locales to DateEditor test.

Reviewers: jarek

Reviewed By: jarek

Differential Revision: https://phab.getgrist.com/D4335
This commit is contained in:
Dmitry S 2024-09-04 17:27:53 -04:00
parent 08b91c4cb7
commit d35574c198
12 changed files with 593 additions and 364 deletions

View File

@ -335,3 +335,7 @@ interface Location {
// historical accident than an intentional choice. // historical accident than an intentional choice.
reload(forceGet?: boolean): void; reload(forceGet?: boolean): void;
} }
interface JQuery {
datepicker(options: unknown): JQuery;
}

View File

@ -1,172 +0,0 @@
/* global $, document */
const moment = require('moment-timezone');
const _ = require('underscore');
const gutil = require('app/common/gutil');
const commands = require('../components/commands');
const dispose = require('../lib/dispose');
const dom = require('../lib/dom');
const kd = require('../lib/koDom');
const TextEditor = require('./TextEditor');
const { parseDate, TWO_DIGIT_YEAR_THRESHOLD } = require('app/common/parseDate');
// DatePicker unfortunately requires an <input> (not <textarea>). But textarea is better for us,
// because sometimes it's taller than a line, and an <input> looks worse. The following
// unconsionable hack tricks Datepicker into thinking anything it's attached to is an input.
// It's more reasonable to just modify boostrap-datepicker, but that has its own downside (with
// upgrading and minification). This hack, however, is simpler than other workarounds.
var Datepicker = $.fn.datepicker.Constructor;
// datepicker.isInput can now be set to anything, but when read, always returns true. Tricksy.
Object.defineProperty(Datepicker.prototype, 'isInput', {
get: function() { return true; },
set: function(v) {},
});
/**
* DateEditor - Editor for Date type. Includes a dropdown datepicker.
* See reference: http://bootstrap-datepicker.readthedocs.org/en/latest/index.html
*
* @param {String} options.timezone: Optional timezone to use instead of UTC.
*/
function DateEditor(options) {
// A string that is always `UTC` in the DateEditor, eases DateTimeEditor inheritance.
this.timezone = options.timezone || 'UTC';
this.dateFormat = options.field.widgetOptionsJson.peek().dateFormat;
this.locale = options.field.documentSettings.peek().locale;
// Update moment format string to represent a date unambiguously.
this.safeFormat = makeFullMomentFormat(this.dateFormat);
// Use the default local timezone to format the placeholder date.
const defaultTimezone = moment.tz.guess();
let placeholder = moment.tz(defaultTimezone).format(this.safeFormat);
if (options.readonly) {
// clear placeholder for readonly mode
placeholder = null;
}
TextEditor.call(this, _.defaults(options, { placeholder: placeholder }));
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);
if (!options.readonly) {
// Indicates whether keyboard navigation is active for the datepicker.
this._keyboardNav = false;
// Attach the datepicker.
this._datePickerWidget = $(this.textInput).datepicker({
keyboardNavigation: false,
forceParse: false,
todayHighlight: true,
todayBtn: 'linked',
assumeNearbyYear: TWO_DIGIT_YEAR_THRESHOLD,
// Datepicker supports most of the languages. They just need to be included in the bundle
// or by script tag, i.e.
// <script src="bootstrap-datepicker/dist/locales/bootstrap-datepicker.pl.min.js"></script>
language : this.getLanguage(),
// 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,
// datepicker reads date in utc (ie: using date.getUTCDate()).
timezone: 'UTC',
});
return (timestampSec === null) ? null : new Date(timestampSec * 1000);
},
},
});
this.autoDisposeCallback(() => this._datePickerWidget.datepicker('destroy'));
// NOTE: Datepicker interferes with normal enter and escape functionality. Add an event handler
// to the DatePicker to prevent interference with normal behavior.
this._datePickerWidget.on('keydown', e => {
// If enter or escape is pressed, destroy the datepicker and re-dispatch the event.
if (e.keyCode === 13 || e.keyCode === 27) {
this._datePickerWidget.datepicker('destroy');
// The current target of the event will be the textarea.
setTimeout(() => e.currentTarget.dispatchEvent(e.originalEvent), 0);
}
});
// When the up/down arrow is pressed, modify the datepicker options to take control of
// the arrow keys for date selection.
let datepickerCommands = Object.assign({}, options.commands, {
datepickerFocus: () => { this._allowKeyboardNav(true); }
});
this._datepickerCommands = this.autoDispose(commands.createGroup(datepickerCommands, this, true));
this._datePickerWidget.on('show', () => {
// A workaround to allow clicking in the datepicker without losing focus.
dom(document.querySelector('.datepicker'),
kd.attr('tabIndex', 0), // allows datepicker to gain focus
kd.toggleClass('clipboard_focus', true) // tells clipboard to not steal focus from us
);
// Attach command group to the input to allow switching keyboard focus to the datepicker.
dom(this.textInput,
// If the user inputs text into the textbox, take keyboard focus from the datepicker.
dom.on('input', () => { this._allowKeyboardNav(false); }),
this._datepickerCommands.attach()
);
});
}
}
dispose.makeDisposable(DateEditor);
_.extend(DateEditor.prototype, TextEditor.prototype);
/** @inheritdoc */
DateEditor.prototype.getCellValue = function() {
let timestamp = parseDate(this.textInput.value, {
dateFormat: this.safeFormat,
timezone: this.timezone
});
return timestamp !== null ? timestamp : this.textInput.value;
};
// Helper to allow/disallow keyboard navigation within the datepicker.
DateEditor.prototype._allowKeyboardNav = function(bool) {
if (this._keyboardNav !== bool) {
this._keyboardNav = bool;
$(this.textInput).data().datepicker.o.keyboardNavigation = bool;
// Force parse must be turned on with keyboard navigation, since it forces the highlighted date
// to be used when enter is pressed. Otherwise, keyboard date selection will have no effect.
$(this.textInput).data().datepicker.o.forceParse = bool;
}
};
// Moment value formatting helper.
DateEditor.prototype.formatValue = function(value, formatString, shouldFallBackToValue) {
if (_.isNumber(value) && formatString) {
return moment.tz(value*1000, this.timezone).format(formatString);
} else {
// If value is AltText, return it unchanged. This way we can see it and edit in the editor.
return (shouldFallBackToValue && typeof value === 'string') ? value : "";
}
};
// 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
// more info about ts: https://github.com/microsoft/TypeScript/issues/37326
// return new Intl.Locale(locale).language;
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;

View File

@ -0,0 +1,216 @@
import {CommandGroup, createGroup} from 'app/client/components/commands';
import {loadScript} from 'app/client/lib/loadScript';
import {detectCurrentLang} from 'app/client/lib/localization';
import {FieldOptions} from 'app/client/widgets/NewBaseEditor';
import {NTextEditor} from 'app/client/widgets/NTextEditor';
import {CellValue} from "app/common/DocActions";
import {parseDate, TWO_DIGIT_YEAR_THRESHOLD} from 'app/common/parseDate';
import moment from 'moment-timezone';
import {dom} from 'grainjs';
// These are all the locales available for the datepicker. Having a prepared list lets us find a
// suitable one without trying combinations that don't exist. This list can be rebuilt using:
// ls bower_components/bootstrap-datepicker/dist/locales/bootstrap-datepicker.* | cut -d. -f2 | xargs echo
// eslint-disable-next-line max-len
const availableLocales = 'ar-tn ar az bg bm bn br bs ca cs cy da de el en-AU en-CA en-GB en-IE en-NZ en-ZA eo es et eu fa fi fo fr-CH fr gl he hi hr hu hy id is it-CH it ja ka kh kk km ko kr lt lv me mk mn ms nl-BE nl no oc pl pt-BR pt ro rs-latin rs ru si sk sl sq sr-latin sr sv sw ta tg th tk tr uk uz-cyrl uz-latn vi zh-CN zh-TW';
monkeyPatchDatepicker();
/**
* DateEditor - Editor for Date type. Includes a dropdown datepicker.
* See reference: http://bootstrap-datepicker.readthedocs.org/en/latest/index.html
*/
export class DateEditor extends NTextEditor {
protected safeFormat: string; // Format that specifies a complete date.
private _dateFormat: string|undefined = this.options.field.widgetOptionsJson.peek().dateFormat;
private _locale = detectCurrentLang();
private _keyboardNav = false; // Whether keyboard navigation is active for the datepicker.
constructor(
options: FieldOptions,
protected timezone: string = 'UTC', // For use by the derived DateTimeEditor.
) {
super(options);
// Update moment format string to represent a date unambiguously.
this.safeFormat = makeFullMomentFormat(this._dateFormat || '');
// Set placeholder to current date(time), unless in read-only mode.
if (!options.readonly) {
// Use the default local timezone to format the placeholder date.
// TODO: this.timezone is better for DateTime; gristDoc.docInfo.timezone.peek() is better for Date.
const defaultTimezone = moment.tz.guess();
const placeholder = moment.tz(defaultTimezone).format(this.safeFormat);
this.textInput.setAttribute('placeholder', placeholder);
}
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 = options.state ?? options.editValue ?? cellValue;
if (!options.readonly) {
// When the up/down arrow is pressed, modify the datepicker options to take control of
// the arrow keys for date selection.
const datepickerCommands = {
...options.commands,
datepickerFocus: () => { this._allowKeyboardNav(true); }
};
const datepickerCommandGroup = this.autoDispose(createGroup(datepickerCommands, this, true));
this._attachDatePicker(datepickerCommandGroup)
.catch(e => console.error("Error attaching datepicker", e));
}
}
public getCellValue() {
const timestamp = parseDate(this.textInput.value, {
dateFormat: this.safeFormat,
timezone: this.timezone
});
return timestamp !== null ? timestamp : this.textInput.value;
}
// Moment value formatting helper.
protected formatValue(value: CellValue, formatString: string|undefined, shouldFallBackToValue: boolean) {
if (typeof value === 'number' && formatString) {
return moment.tz(value*1000, this.timezone).format(formatString);
} else {
// If value is AltText, return it unchanged. This way we can see it and edit in the editor.
return (shouldFallBackToValue && typeof value === 'string') ? value : "";
}
}
// Helper to allow/disallow keyboard navigation within the datepicker.
private _allowKeyboardNav(bool: boolean) {
if (this._keyboardNav !== bool) {
this._keyboardNav = bool;
$(this.textInput).data().datepicker.o.keyboardNavigation = bool;
// Force parse must be turned on with keyboard navigation, since it forces the highlighted date
// to be used when enter is pressed. Otherwise, keyboard date selection will have no effect.
$(this.textInput).data().datepicker.o.forceParse = bool;
}
}
// Attach the datepicker.
private async _attachDatePicker(datepickerCommands: CommandGroup) {
const localeToUse = await loadLocale(this._locale);
if (this.isDisposed()) { return; } // Good idea to check after 'await'.
const datePickerWidget = $(this.textInput).datepicker({
keyboardNavigation: false,
forceParse: false,
todayHighlight: true,
todayBtn: 'linked',
assumeNearbyYear: TWO_DIGIT_YEAR_THRESHOLD,
language: localeToUse,
// Use the stripped format converted to one suitable for the datepicker.
format: {
toDisplay: (date: string, format: unknown, lang: unknown) => moment.utc(date).format(this.safeFormat),
toValue: (date: string, format: unknown, lang: unknown) => {
const timestampSec = parseDate(date, {
dateFormat: this.safeFormat,
// datepicker reads date in utc (ie: using date.getUTCDate()).
timezone: 'UTC',
});
return (timestampSec === null) ? null : new Date(timestampSec * 1000);
},
},
});
this.onDispose(() => datePickerWidget.datepicker('destroy'));
// NOTE: Datepicker interferes with normal enter and escape functionality. Add an event handler
// to the DatePicker to prevent interference with normal behavior.
datePickerWidget.on('keydown', (e) => {
// If enter or escape is pressed, destroy the datepicker and re-dispatch the event.
if (e.keyCode === 13 || e.keyCode === 27) {
datePickerWidget.datepicker('destroy');
// The current target of the event will be the textarea.
setTimeout(() => e.currentTarget?.dispatchEvent(e.originalEvent!), 0);
}
});
datePickerWidget.on('show', () => {
// A workaround to allow clicking in the datepicker without losing focus.
const datepickerElem: HTMLElement|null = document.querySelector('.datepicker');
if (datepickerElem) {
dom.update(datepickerElem,
dom.attr('tabIndex', '0'), // allows datepicker to gain focus
dom.cls('clipboard_focus') // tells clipboard to not steal focus from us
);
}
// Attach command group to the input to allow switching keyboard focus to the datepicker.
dom.update(this.textInput,
// If the user inputs text into the textbox, take keyboard focus from the datepicker.
dom.on('input', () => { this._allowKeyboardNav(false); }),
datepickerCommands.attach()
);
});
datePickerWidget.datepicker('show');
}
}
// 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: string): string {
let safeFormat = mFormat;
if (!safeFormat.includes('Y')) {
safeFormat += " YYYY";
}
if (!safeFormat.includes('D') || !safeFormat.includes('M')) {
safeFormat = 'YYYY-MM-DD';
}
return safeFormat;
}
let availableLocaleSet: Set<string>|undefined;
const loadedLocaleMap = new Map<string, string>(); // Maps requested locale to the one to use.
// Datepicker supports many languages. They just need to be loaded. Here we load the language we
// need on-demand, taking care not to load any language more than once (we don't need to assume
// there is only one language being used on the page, though in practice that may well be true).
async function loadLocale(locale: string): Promise<string> {
return loadedLocaleMap.get(locale) ||
loadedLocaleMap.set(locale, await doLoadLocale(locale)).get(locale)!;
}
async function doLoadLocale(locale: string): Promise<string> {
if (!availableLocaleSet) {
availableLocaleSet = new Set(availableLocales.split(/\s+/));
}
if (!availableLocaleSet.has(locale)) {
const shortLocale = locale.split("-")[0]; // If "xx-YY" is not available, try "xx"
if (!availableLocaleSet.has(shortLocale)) {
// No special locale available. (This is even true for "en", which is fine since that's
// loaded by default.)
return locale;
}
locale = shortLocale;
}
console.debug(`DateEditor: loading locale ${locale}`);
try {
await loadScript(`bootstrap-datepicker/dist/locales/bootstrap-datepicker.${locale}.min.js`);
} catch (e) {
console.warn(`DateEditor: failed to load ${locale}`);
}
return locale;
}
// DatePicker unfortunately requires an <input> (not <textarea>). But textarea is better for us,
// because sometimes it's taller than a line, and an <input> looks worse. The following
// unconsionable hack tricks Datepicker into thinking anything it's attached to is an input.
// It's more reasonable to just modify boostrap-datepicker, but that has its own downside (with
// upgrading and minification). This hack, however, is simpler than other workarounds.
function monkeyPatchDatepicker() {
const Datepicker = ($.fn as any).datepicker?.Constructor;
if (Datepicker?.prototype) {
// datepicker.isInput can now be set to anything, but when read, always returns true. Tricksy.
Object.defineProperty(Datepicker.prototype, 'isInput', {
get: function() { return true; },
set: function(v) {},
});
}
}

View File

@ -1,173 +0,0 @@
/* global document */
const moment = require('moment-timezone');
const _ = require('underscore');
const dom = require('../lib/dom');
const dispose = require('../lib/dispose');
const kd = require('../lib/koDom');
const DateEditor = require('./DateEditor');
const gutil = require('app/common/gutil');
const { parseDate } = require('app/common/parseDate');
const TextEditor = require('./TextEditor');
/**
* DateTimeEditor - Editor for DateTime type. Includes a dropdown datepicker.
* See reference: http://bootstrap-datepicker.readthedocs.org/en/latest/index.html
*/
function DateTimeEditor(options) {
// Get the timezone from the end of the type string.
options.timezone = gutil.removePrefix(options.field.column().type(), "DateTime:");
// Adjust the command group.
var origCommands = options.commands;
// don't modify navigation for readonly mode
if (!options.readonly) {
options.commands = Object.assign({}, origCommands, {
prevField: () => this._focusIndex() === 1 ? this._setFocus(0) : origCommands.prevField(),
nextField: () => this._focusIndex() === 0 ? this._setFocus(1) : origCommands.nextField(),
});
}
// Call the superclass.
DateEditor.call(this, options);
this._timeFormat = options.field.widgetOptionsJson.peek().timeFormat;
// To reuse code, this knows all about the DOM that DateEditor builds (using TextEditor), and
// modifies that to be two side-by-side textareas.
this._dateSizer = this.contentSizer; // For consistency with _timeSizer and _timeInput.
this._dateInput = this.textInput;
const isValid = _.isNumber(options.cellValue);
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);
// for readonly
if (options.readonly) {
if (!isValid) {
// do nothing - DateEditor will show correct error
} else {
// append time format or a placeholder
const time = (formatted || placeholder);
const sep = time ? ' ' : '';
this.textInput.value = this.textInput.value + sep + time;
}
} else {
dom(this.dom, kd.toggleClass('celleditor_datetime', true));
dom(this.dom.firstChild, kd.toggleClass('celleditor_datetime_editor', true));
this.dom.appendChild(
dom('div.celleditor_cursor_editor.celleditor_datetime_editor',
this._timeSizer = dom('div.celleditor_content_measure'),
this._timeInput = dom('textarea.celleditor_text_editor',
kd.attr('placeholder', placeholder),
kd.value(formatted),
this.commandGroup.attach(),
dom.on('input', () => this.onChange())
)
)
);
}
// If the edit value is encoded json, use those values as a starting point
if (typeof options.state == 'string') {
try {
const { date, time } = JSON.parse(options.state);
this._dateInput.value = date;
this._timeInput.value = time;
this.onChange();
} catch(e) {
console.error("DateTimeEditor can't restore its previous state")
}
}
}
dispose.makeDisposable(DateTimeEditor);
_.extend(DateTimeEditor.prototype, DateEditor.prototype);
DateTimeEditor.prototype.setSizerLimits = function() {
var maxSize = this.editorPlacement.calcSize({width: Infinity, height: Infinity}, {calcOnly: true});
if (this.options.readonly) {
return;
}
this._dateSizer.style.maxWidth =
this._timeSizer.style.maxWidth = Math.ceil(maxSize.width / 2 - 6) + 'px';
};
/**
* Returns which element has focus: 0 if date, 1 if time, null if neither.
*/
DateTimeEditor.prototype._focusIndex = function() {
return document.activeElement === this._dateInput ? 0 :
(document.activeElement === this._timeInput ? 1 : null);
};
/**
* Sets focus to date if index is 0, or time if index is 1.
*/
DateTimeEditor.prototype._setFocus = function(index) {
var elem = (index === 0 ? this._dateInput : (index === 1 ? this._timeInput : null));
if (elem) {
elem.focus();
elem.selectionStart = 0;
elem.selectionEnd = elem.value.length;
}
};
/**
* Occurs when user types something into the editor
*/
DateTimeEditor.prototype.onChange = function() {
this._resizeInput();
// store editor state as an encoded JSON string
const date = this._dateInput.value;
const time = this._timeInput.value;
this.editorState.set(JSON.stringify({ date, time}));
}
DateTimeEditor.prototype.getCellValue = function() {
let date = this._dateInput.value;
let time = this._timeInput.value;
let timestamp = parseDate(date, {
dateFormat: this.safeFormat,
time: time,
timeFormat: this._timeFormat,
timezone: this.timezone
});
return timestamp !== null ? timestamp :
(date && time ? `${date} ${time}` : date || time);
};
/**
* Overrides the resizing function in TextEditor.
*/
DateTimeEditor.prototype._resizeInput = function() {
// for readonly field, we will use logic from a super class
if (this.options.readonly) {
TextEditor.prototype._resizeInput.call(this);
return;
}
// Use the size calculation provided in options.calcSize (that takes into account cell size and
// screen size), with both date and time parts as the input. The resulting size is applied to
// the parent (containing date + time), with date and time each expanding or shrinking from the
// measured sizes using flexbox logic.
this._dateSizer.textContent = this._dateInput.value;
this._timeSizer.textContent = this._timeInput.value;
var dateRect = this._dateSizer.getBoundingClientRect();
var timeRect = this._timeSizer.getBoundingClientRect();
// Textboxes get 3px of padding on left/right/top (see TextEditor.css); we specify it manually
// since editorPlacement can't do a good job figuring it out with the flexbox arrangement.
var size = this.editorPlacement.calcSize({
width: dateRect.width + timeRect.width + 12,
height: Math.max(dateRect.height, timeRect.height) + 3
});
this.dom.style.width = size.width + 'px';
this._dateInput.parentNode.style.flexBasis = (dateRect.width + 6) + 'px';
this._timeInput.parentNode.style.flexBasis = (timeRect.width + 6) + 'px';
this._dateInput.style.height = Math.ceil(size.height - 3) + 'px';
this._timeInput.style.height = Math.ceil(size.height - 3) + 'px';
};
module.exports = DateTimeEditor;

View File

@ -0,0 +1,173 @@
import {DateEditor} from 'app/client/widgets/DateEditor';
import {FieldOptions} from 'app/client/widgets/NewBaseEditor';
import {removePrefix} from 'app/common/gutil';
import {parseDate} from 'app/common/parseDate';
import moment from 'moment-timezone';
import {dom} from 'grainjs';
/**
* DateTimeEditor - Editor for DateTime type. Includes a dropdown datepicker.
* See reference: http://bootstrap-datepicker.readthedocs.org/en/latest/index.html
*/
export class DateTimeEditor extends DateEditor {
private _timeFormat: string|undefined;
private _dateSizer: HTMLElement;
private _timeSizer: HTMLElement;
private _dateInput: HTMLTextAreaElement;
private _timeInput: HTMLTextAreaElement;
constructor(options: FieldOptions) {
// Get the timezone from the end of the type string.
const timezone = removePrefix(options.field.column().type(), "DateTime:");
// Adjust the command group, but not for readonly mode.
if (!options.readonly) {
const origCommands = options.commands;
options.commands = {
...origCommands,
prevField: () => this._focusIndex() === 1 ? this._setFocus(0) : origCommands.prevField(),
nextField: () => this._focusIndex() === 0 ? this._setFocus(1) : origCommands.nextField(),
};
}
// Call the superclass.
super(options, timezone || 'UTC');
this._timeFormat = this.options.field.widgetOptionsJson.peek().timeFormat;
// To reuse code, this knows all about the DOM that DateEditor builds (using TextEditor), and
// modifies that to be two side-by-side textareas.
this._dateSizer = this.contentSizer; // For consistency with _timeSizer.
this._dateInput = this.textInput; // For consistency with _timeInput.
const isValid = (typeof options.cellValue === 'number');
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);
// for readonly
if (options.readonly) {
if (!isValid) {
// do nothing - DateEditor will show correct error
} else {
// append time format or a placeholder
const time = (formatted || placeholder);
const sep = time ? ' ' : '';
this.textInput.value = this.textInput.value + sep + time;
}
} else {
const widgetElem = this.getDom();
dom.update(widgetElem, dom.cls('celleditor_datetime'));
dom.update(this.cellEditorDiv, dom.cls('celleditor_datetime_editor'));
widgetElem.appendChild(
dom('div',
dom.cls('celleditor_cursor_editor'),
dom.cls('celleditor_datetime_editor'),
this._timeSizer = dom('div', dom.cls('celleditor_content_measure')),
this._timeInput = dom('textarea', dom.cls('celleditor_text_editor'),
dom.attr('placeholder', placeholder),
dom.prop('value', formatted),
this.commandGroup.attach(),
dom.on('input', () => this._onChange())
)
)
);
}
// If the edit value is encoded json, use those values as a starting point
if (typeof options.state == 'string') {
try {
const { date, time } = JSON.parse(options.state);
this._dateInput.value = date;
this._timeInput.value = time;
this._onChange();
} catch(e) {
console.error("DateTimeEditor can't restore its previous state");
}
}
}
public getCellValue() {
const date = this._dateInput.value;
const time = this._timeInput.value;
const timestamp = parseDate(date, {
dateFormat: this.safeFormat,
time: time,
timeFormat: this._timeFormat,
timezone: this.timezone
});
return timestamp !== null ? timestamp :
(date && time ? `${date} ${time}` : date || time);
}
public setSizerLimits() {
const maxSize = this.editorPlacement.calcSize({width: Infinity, height: Infinity}, {calcOnly: true});
if (this.options.readonly) {
return;
}
this._dateSizer.style.maxWidth =
this._timeSizer.style.maxWidth = Math.ceil(maxSize.width / 2 - 6) + 'px';
}
/**
* Overrides the resizing function in TextEditor.
*/
protected resizeInput() {
// for readonly field, we will use logic from a super class
if (this.options.readonly) {
return super.resizeInput();
}
// Use the size calculation provided in options.calcSize (that takes into account cell size and
// screen size), with both date and time parts as the input. The resulting size is applied to
// the parent (containing date + time), with date and time each expanding or shrinking from the
// measured sizes using flexbox logic.
this._dateSizer.textContent = this._dateInput.value;
this._timeSizer.textContent = this._timeInput.value;
const dateRect = this._dateSizer.getBoundingClientRect();
const timeRect = this._timeSizer.getBoundingClientRect();
// Textboxes get 3px of padding on left/right/top (see TextEditor.css); we specify it manually
// since editorPlacement can't do a good job figuring it out with the flexbox arrangement.
const size = this.editorPlacement.calcSize({
width: dateRect.width + timeRect.width + 12,
height: Math.max(dateRect.height, timeRect.height) + 3
});
this.getDom().style.width = size.width + 'px';
this._dateInput.parentElement!.style.flexBasis = (dateRect.width + 6) + 'px';
this._timeInput.parentElement!.style.flexBasis = (timeRect.width + 6) + 'px';
this._dateInput.style.height = Math.ceil(size.height - 3) + 'px';
this._timeInput.style.height = Math.ceil(size.height - 3) + 'px';
}
/**
* Returns which element has focus: 0 if date, 1 if time, null if neither.
*/
private _focusIndex() {
return document.activeElement === this._dateInput ? 0 :
(document.activeElement === this._timeInput ? 1 : null);
}
/**
* Sets focus to date if index is 0, or time if index is 1.
*/
private _setFocus(index: 0|1) {
const elem = (index === 0 ? this._dateInput : (index === 1 ? this._timeInput : null));
if (elem) {
elem.focus();
elem.selectionStart = 0;
elem.selectionEnd = elem.value.length;
}
}
/**
* Occurs when user types something into the editor
*/
private _onChange() {
this.resizeInput();
// store editor state as an encoded JSON string
const date = this._dateInput.value;
const time = this._timeInput.value;
this.editorState.set(JSON.stringify({ date, time}));
}
}

View File

@ -29,7 +29,7 @@ import { FieldEditor, saveWithoutEditor } from 'app/client/widgets/FieldEditor';
import { CellDiscussionPopup, EmptyCell } from 'app/client/widgets/DiscussionEditor'; import { CellDiscussionPopup, EmptyCell } from 'app/client/widgets/DiscussionEditor';
import { openFormulaEditor } from 'app/client/widgets/FormulaEditor'; import { openFormulaEditor } from 'app/client/widgets/FormulaEditor';
import { NewAbstractWidget } from 'app/client/widgets/NewAbstractWidget'; import { NewAbstractWidget } from 'app/client/widgets/NewAbstractWidget';
import { NewBaseEditor } from "app/client/widgets/NewBaseEditor"; import { IEditorConstructor } from "app/client/widgets/NewBaseEditor";
import * as UserType from 'app/client/widgets/UserType'; import * as UserType from 'app/client/widgets/UserType';
import * as UserTypeImpl from 'app/client/widgets/UserTypeImpl'; import * as UserTypeImpl from 'app/client/widgets/UserTypeImpl';
import * as gristTypes from 'app/common/gristTypes'; import * as gristTypes from 'app/common/gristTypes';
@ -758,7 +758,7 @@ export class FieldBuilder extends Disposable {
return clearOwn(); return clearOwn();
} }
const editorCtor: typeof NewBaseEditor = const editorCtor: IEditorConstructor =
UserTypeImpl.getEditorConstructor(this.options().widget, this._readOnlyPureType()); UserTypeImpl.getEditorConstructor(this.options().widget, this._readOnlyPureType());
// constructor may be null for a read-only non-formula field, though not today. // constructor may be null for a read-only non-formula field, though not today.
if (!editorCtor) { if (!editorCtor) {

View File

@ -8,7 +8,7 @@ import {ViewFieldRec} from 'app/client/models/entities/ViewFieldRec';
import {reportError} from 'app/client/models/errors'; import {reportError} from 'app/client/models/errors';
import {showTooltipToCreateFormula} from 'app/client/widgets/EditorTooltip'; import {showTooltipToCreateFormula} from 'app/client/widgets/EditorTooltip';
import {FormulaEditor, getFormulaError} from 'app/client/widgets/FormulaEditor'; import {FormulaEditor, getFormulaError} from 'app/client/widgets/FormulaEditor';
import {IEditorCommandGroup, NewBaseEditor} from 'app/client/widgets/NewBaseEditor'; import {IEditorCommandGroup, IEditorConstructor, NewBaseEditor} from 'app/client/widgets/NewBaseEditor';
import {asyncOnce} from "app/common/AsyncCreate"; import {asyncOnce} from "app/common/AsyncCreate";
import {CellValue} from "app/common/DocActions"; import {CellValue} from "app/common/DocActions";
import * as gutil from 'app/common/gutil'; import * as gutil from 'app/common/gutil';
@ -18,8 +18,6 @@ import {CursorPos} from 'app/plugin/GristAPI';
import isEqual = require('lodash/isEqual'); import isEqual = require('lodash/isEqual');
import {Disposable, dom, Emitter, Holder, MultiHolder, Observable} from 'grainjs'; import {Disposable, dom, Emitter, Holder, MultiHolder, Observable} from 'grainjs';
type IEditorConstructor = typeof NewBaseEditor;
const t = makeT('FieldEditor'); const t = makeT('FieldEditor');
/** /**

View File

@ -118,11 +118,12 @@ export class FormulaEditor extends NewBaseEditor {
const hideErrDetails = Observable.create(this, true); const hideErrDetails = Observable.create(this, true);
const raisedException = Computed.create(this, use => { const raisedException = Computed.create(this, use => {
if (!options.formulaError || !use(options.formulaError)) { const formulaError = options.formulaError && use(options.formulaError);
if (!formulaError) {
return null; return null;
} }
const error = isRaisedException(use(options.formulaError)!) ? const error = isRaisedException(formulaError) ?
decodeObject(use(options.formulaError)!) as RaisedException: decodeObject(formulaError) as RaisedException:
new RaisedException(["Unknown error"]); new RaisedException(["Unknown error"]);
return error; return error;
}); });
@ -382,7 +383,7 @@ export class FormulaEditor extends NewBaseEditor {
// If we have an error to show, ask for a larger size for formulaEditor. // If we have an error to show, ask for a larger size for formulaEditor.
const desiredSize = { const desiredSize = {
width: Math.max(desiredElemSize.width, (this.options.formulaError.get() ? minFormulaErrorWidth : 0)), width: Math.max(desiredElemSize.width, (this.options.formulaError?.get() ? minFormulaErrorWidth : 0)),
// Ask for extra space for the error; we'll decide how to allocate it below. // Ask for extra space for the error; we'll decide how to allocate it below.
height: desiredElemSize.height + (errorBoxDesiredHeight - errorBoxStartHeight), height: desiredElemSize.height + (errorBoxDesiredHeight - errorBoxStartHeight),
}; };
@ -488,7 +489,7 @@ export function openFormulaEditor(options: {
column?: ColumnRec, column?: ColumnRec,
// Associated formula from a view field. If provided together with column, this field is used // Associated formula from a view field. If provided together with column, this field is used
field?: ViewFieldRec, field?: ViewFieldRec,
editingFormula?: ko.Computed<boolean>, editingFormula: ko.Computed<boolean>,
// Needed to get exception value, if any. // Needed to get exception value, if any.
editRow?: DataRowModel, editRow?: DataRowModel,
// Element over which to position the editor. // Element over which to position the editor.
@ -555,7 +556,7 @@ export function openFormulaEditor(options: {
column, column,
field: options.field, field: options.field,
}) : undefined; }) : undefined;
const editor = FormulaEditor.create(null, { const editorOptions: IFormulaEditorOptions = {
gristDoc, gristDoc,
column, column,
field: options.field, field: options.field,
@ -569,7 +570,8 @@ export function openFormulaEditor(options: {
cssClass: 'formula_editor_sidepane', cssClass: 'formula_editor_sidepane',
readonly : false, readonly : false,
canDetach: options.canDetach canDetach: options.canDetach
} as IFormulaEditorOptions) as FormulaEditor; };
const editor = FormulaEditor.create(null, editorOptions);
editor.autoDispose(attachedHolder); editor.autoDispose(attachedHolder);
editor.attach(refElem); editor.attach(refElem);

View File

@ -96,6 +96,14 @@ export class NTextEditor extends NewBaseEditor {
this._contentSizer.style.maxWidth = Math.ceil(maxSize.width) + 'px'; this._contentSizer.style.maxWidth = Math.ceil(maxSize.width) + 'px';
} }
public get contentSizer(): HTMLElement {
return this._contentSizer;
}
public get editorPlacement(): EditorPlacement {
return this._editorPlacement;
}
/** /**
* Occurs when user types text in the textarea * Occurs when user types text in the textarea
* *

View File

@ -13,11 +13,13 @@ export interface IEditorCommandGroup {
[cmd: string]: () => void; [cmd: string]: () => void;
} }
// Usually an editor is created for a field and provided FieldOptions, but it's possible to have
// no field object, e.g. for a FormulaEditor for a conditional style rule.
export interface Options { export interface Options {
gristDoc: GristDoc; gristDoc: GristDoc;
cellValue: CellValue; cellValue: CellValue;
rowId: number; rowId: number;
formulaError: Observable<CellValue|undefined>; formulaError?: Observable<CellValue|undefined>;
editValue?: string; editValue?: string;
cursorPos: number; cursorPos: number;
commands: IEditorCommandGroup; commands: IEditorCommandGroup;
@ -29,6 +31,9 @@ export interface FieldOptions extends Options {
field: ViewFieldRec; field: ViewFieldRec;
} }
// This represents any of the derived editor classes; the part after "&" restricts to non-abstract ones.
export type IEditorConstructor = typeof NewBaseEditor & { new (...args: any[]): NewBaseEditor };
/** /**
* Required parameters: * Required parameters:
* @param {RowModel} options.field: ViewSectionField (i.e. column) being edited. * @param {RowModel} options.field: ViewSectionField (i.e. column) being edited.
@ -44,8 +49,10 @@ export abstract class NewBaseEditor extends Disposable {
* Editors and provided by FieldBuilder. TODO: remove this method once all editors have been * Editors and provided by FieldBuilder. TODO: remove this method once all editors have been
* updated to new-style Disposables. * updated to new-style Disposables.
*/ */
public static create<Opt extends Options>(owner: IDisposableOwner|null, options: Opt): NewBaseEditor; public static create<T extends new (...args: any[]) => any, Opt extends Options>(
public static create<Opt extends Options>(options: Opt): NewBaseEditor; this: T, owner: IDisposableOwner|null, options: Opt): InstanceType<T>;
public static create<T extends new (...args: any[]) => any, Opt extends Options>(
this: T, options: Opt): InstanceType<T>;
public static create(ownerOrOptions: any, options?: any): NewBaseEditor { public static create(ownerOrOptions: any, options?: any): NewBaseEditor {
return options ? return options ?
Disposable.create.call(this as any, ownerOrOptions, options) : Disposable.create.call(this as any, ownerOrOptions, options) :

View File

@ -5,15 +5,15 @@ import ChoiceEditor from 'app/client/widgets/ChoiceEditor';
import {ChoiceListCell} from 'app/client/widgets/ChoiceListCell'; import {ChoiceListCell} from 'app/client/widgets/ChoiceListCell';
import {ChoiceListEditor} from 'app/client/widgets/ChoiceListEditor'; import {ChoiceListEditor} from 'app/client/widgets/ChoiceListEditor';
import {ChoiceTextBox} from 'app/client/widgets/ChoiceTextBox'; import {ChoiceTextBox} from 'app/client/widgets/ChoiceTextBox';
import DateEditor from 'app/client/widgets/DateEditor'; import {DateEditor} from 'app/client/widgets/DateEditor';
import DateTextBox from 'app/client/widgets/DateTextBox'; import DateTextBox from 'app/client/widgets/DateTextBox';
import DateTimeEditor from 'app/client/widgets/DateTimeEditor'; import {DateTimeEditor} from 'app/client/widgets/DateTimeEditor';
import DateTimeTextBox from 'app/client/widgets/DateTimeTextBox'; import DateTimeTextBox from 'app/client/widgets/DateTimeTextBox';
import {HyperLinkEditor} from 'app/client/widgets/HyperLinkEditor'; import {HyperLinkEditor} from 'app/client/widgets/HyperLinkEditor';
import {HyperLinkTextBox} from 'app/client/widgets/HyperLinkTextBox'; import {HyperLinkTextBox} from 'app/client/widgets/HyperLinkTextBox';
import {MarkdownTextBox} from 'app/client/widgets/MarkdownTextBox'; import {MarkdownTextBox} from 'app/client/widgets/MarkdownTextBox';
import {NewAbstractWidget} from 'app/client/widgets/NewAbstractWidget'; import {NewAbstractWidget} from 'app/client/widgets/NewAbstractWidget';
import {NewBaseEditor} from 'app/client/widgets/NewBaseEditor'; import {IEditorConstructor} from 'app/client/widgets/NewBaseEditor';
import {NTextBox} from 'app/client/widgets/NTextBox'; import {NTextBox} from 'app/client/widgets/NTextBox';
import {NTextEditor} from 'app/client/widgets/NTextEditor'; import {NTextEditor} from 'app/client/widgets/NTextEditor';
import {NumericEditor} from 'app/client/widgets/NumericEditor'; import {NumericEditor} from 'app/client/widgets/NumericEditor';
@ -74,7 +74,7 @@ export function getFormWidgetConstructor(widget: string, type: string): WidgetCo
} }
/** return a good class to instantiate for editing a widget/type combination */ /** return a good class to instantiate for editing a widget/type combination */
export function getEditorConstructor(widget: string, type: string): typeof NewBaseEditor { export function getEditorConstructor(widget: string, type: string): IEditorConstructor {
const {config} = getWidgetConfiguration(widget, type as GristType); const {config} = getWidgetConfiguration(widget, type as GristType);
return nameToWidget[config.editCons as keyof typeof nameToWidget] as any; return nameToWidget[config.editCons as keyof typeof nameToWidget] as any;
} }

166
test/nbrowser/DateEditor.ts Normal file
View File

@ -0,0 +1,166 @@
import {assert, driver, Key, WebElement} from 'mocha-webdriver';
import * as gu from 'test/nbrowser/gristUtils';
import {setupTestSuite} from 'test/nbrowser/testUtils';
import escapeRegExp = require('lodash/escapeRegExp');
async function setCustomDateFormat(format: string) {
await gu.setDateFormat("Custom");
await driver.find('[data-test-id=Widget_dateCustomFormat]').click();
await gu.selectAll();
await driver.sendKeys(format, Key.ENTER);
}
async function testDateFormat(initialDateStr: string, newDay: string, finalDateStr: string) {
const cell = await gu.getCell({col: 'A', rowNum: 1});
await cell.click();
assert.equal(await cell.getText(), initialDateStr);
// Open the date for editing, and check that we see it in the new format.
await driver.sendKeys(Key.ENTER);
await gu.checkTextEditor(new RegExp(escapeRegExp(initialDateStr)));
// Pick a new date in the editor; check that it's shown in the new format.
await driver.findContent('td.day', newDay).click();
await gu.checkTextEditor(new RegExp(escapeRegExp(finalDateStr)));
await driver.sendKeys(Key.ENTER);
await gu.waitForServer();
assert.equal(await gu.getCell({col: 'A', rowNum: 1}).getText(), finalDateStr);
// Reopen the editor, check that our previously-selected date is still selected.
await gu.getCell({col: 'A', rowNum: 1}).click();
await driver.sendKeys(Key.ENTER);
await gu.checkTextEditor(new RegExp(escapeRegExp(finalDateStr)));
assert.isTrue(await driver.findContent('td.day', newDay).matches('.active'));
await driver.sendKeys(Key.ESCAPE);
await gu.waitAppFocus();
await gu.undo();
}
describe('DateEditor', function() {
this.timeout(20000);
const cleanup = setupTestSuite();
let session: gu.Session;
afterEach(() => gu.checkForErrors());
before(async function() {
session = await gu.session().login();
await session.tempNewDoc(cleanup, 'DateEditor');
await gu.waitForServer();
await driver.executeAsyncScript(async (done: () => unknown) => {
await (window as any).loadScript('sinon.js');
window.sinon.useFakeTimers({
now: 1580568300000, // Sat Feb 01 2020 14:45:00 UTC
shouldAdvanceTime: true
});
done();
});
});
it('should allow editing dates in standard format', async function() {
await gu.getCell({col: 'A', rowNum: 1}).click();
await gu.setType(/Date/);
assert.equal(await gu.getDateFormat(), "YYYY-MM-DD");
// Use shortcut to populate today's date, mainly to ensure that our date-mocking is working.
await gu.getCell({col: 'A', rowNum: 1}).click();
await gu.sendKeys(Key.chord(await gu.modKey(), ';'));
await gu.waitForServer();
assert.equal(await gu.getCell({col: 'A', rowNum: 1}).getText(), '2020-02-01');
// Change the format and check that date gets updated.
await gu.setDateFormat("MMMM Do, YYYY");
await testDateFormat('February 1st, 2020', '18', 'February 18th, 2020');
});
it('should allow editing dates in rarer formats', async function() {
await setCustomDateFormat("MMM Do, 'YY");
await testDateFormat("Feb 1st, '20", "18", "Feb 18th, '20");
await setCustomDateFormat("YYYY-MM-DD dd");
await testDateFormat("2020-02-01 Sa", "18", "2020-02-18 Tu");
});
it('should allow editing invalid alt-text', async function() {
let cell = await gu.getCell({col: 'A', rowNum: 2});
await cell.click();
await driver.sendKeys(Key.ENTER);
await gu.waitAppFocus(false);
// Enter an invalid date.
await driver.sendKeys('2020-03-14pi', Key.ENTER);
await gu.waitForServer();
// Check that it's saved, and shows up as invalid.
cell = await gu.getCell({col: 'A', rowNum: 2});
assert.equal(await cell.getText(), '2020-03-14pi');
assert.isTrue(await cell.find('.field_clip').matches('.invalid'));
// Open for editing, and check that the invalid value is present in the editor.
await cell.click();
await driver.sendKeys(Key.ENTER);
await gu.waitAppFocus(false);
await gu.checkTextEditor(/2020-03-14pi/);
// Edit it down to something valid, save, and check.
await driver.sendKeys(Key.BACK_SPACE, Key.BACK_SPACE, Key.ENTER);
await gu.waitForServer();
cell = await gu.getCell({col: 'A', rowNum: 2});
assert.equal(await cell.getText(), '2020-03-14 Sa');
assert.isFalse(await cell.find('.field_clip').matches('.invalid'));
});
async function openCellEditor(cell: WebElement) {
await cell.click();
await driver.sendKeys(Key.ENTER);
await gu.waitAppFocus(false);
}
it('should respect locale for datepicker', async function() {
let cell = await gu.getCell({col: 'A', rowNum: 1});
await cell.click();
await gu.setDateFormat("YYYY-MM-DD");
assert.equal(await cell.getText(), '2020-02-01');
await openCellEditor(cell);
// Check that the date input contains the correct date.
assert.equal(await driver.find('.celleditor_text_editor').value(), '2020-02-01');
// Wait for datepicker, and check that it's showing the expected (default English) locale.
await driver.findWait('.datepicker', 200);
assert.equal(await driver.find('.datepicker .datepicker-days .datepicker-switch').getText(), 'February 2020');
assert.deepEqual(await driver.findAll('.datepicker .datepicker-days .dow', el => el.getText()),
['Su', 'Mo', 'Tu', 'We', 'Th', 'Fr', 'Sa']);
// Check that it works to click into it to change date.
await driver.findContent('.datepicker .day', '19').click();
assert.equal(await driver.find('.celleditor_text_editor').value(), '2020-02-19');
await driver.sendKeys(Key.ENTER);
await gu.waitForServer();
assert.equal(await cell.getText(), '2020-02-19');
assert.equal(await driver.find('.datepicker').isPresent(), false);
// Change locale to something quite different.
const api = session.createHomeApi();
await api.updateUserLocale('fr-CA');
cleanup.addAfterEach(() => api.updateUserLocale(null)); // Restore after this test case.
await gu.reloadDoc();
// Check that the datepicker now opens to show the new language.
cell = await gu.getCell({col: 'A', rowNum: 1});
await openCellEditor(cell);
assert.equal(await driver.find('.datepicker .datepicker-days .datepicker-switch').getText(),
'février 2020');
assert.deepEqual(await driver.findAll('.datepicker .datepicker-days .dow', el => el.getText()),
['l', 'ma', 'me', 'j', 'v', 's', 'd']);
// Check that it can still be used to pick a new date.
await driver.findContent('.datepicker .day', '26').click();
assert.equal(await driver.find('.celleditor_text_editor').value(), '2020-02-26');
await driver.sendKeys(Key.ENTER);
await gu.waitForServer();
assert.equal(await cell.getText(), '2020-02-26');
assert.equal(await driver.find('.datepicker').isPresent(), false);
});
});