mirror of
https://github.com/gristlabs/grist-core.git
synced 2024-10-27 20:44:07 +00:00
(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:
parent
08b91c4cb7
commit
d35574c198
4
app/client/declarations.d.ts
vendored
4
app/client/declarations.d.ts
vendored
@ -335,3 +335,7 @@ interface Location {
|
||||
// historical accident than an intentional choice.
|
||||
reload(forceGet?: boolean): void;
|
||||
}
|
||||
|
||||
interface JQuery {
|
||||
datepicker(options: unknown): JQuery;
|
||||
}
|
||||
|
@ -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;
|
216
app/client/widgets/DateEditor.ts
Normal file
216
app/client/widgets/DateEditor.ts
Normal 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) {},
|
||||
});
|
||||
}
|
||||
}
|
@ -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;
|
173
app/client/widgets/DateTimeEditor.ts
Normal file
173
app/client/widgets/DateTimeEditor.ts
Normal 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}));
|
||||
}
|
||||
}
|
@ -29,7 +29,7 @@ import { FieldEditor, saveWithoutEditor } from 'app/client/widgets/FieldEditor';
|
||||
import { CellDiscussionPopup, EmptyCell } from 'app/client/widgets/DiscussionEditor';
|
||||
import { openFormulaEditor } from 'app/client/widgets/FormulaEditor';
|
||||
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 UserTypeImpl from 'app/client/widgets/UserTypeImpl';
|
||||
import * as gristTypes from 'app/common/gristTypes';
|
||||
@ -758,7 +758,7 @@ export class FieldBuilder extends Disposable {
|
||||
return clearOwn();
|
||||
}
|
||||
|
||||
const editorCtor: typeof NewBaseEditor =
|
||||
const editorCtor: IEditorConstructor =
|
||||
UserTypeImpl.getEditorConstructor(this.options().widget, this._readOnlyPureType());
|
||||
// constructor may be null for a read-only non-formula field, though not today.
|
||||
if (!editorCtor) {
|
||||
|
@ -8,7 +8,7 @@ import {ViewFieldRec} from 'app/client/models/entities/ViewFieldRec';
|
||||
import {reportError} from 'app/client/models/errors';
|
||||
import {showTooltipToCreateFormula} from 'app/client/widgets/EditorTooltip';
|
||||
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 {CellValue} from "app/common/DocActions";
|
||||
import * as gutil from 'app/common/gutil';
|
||||
@ -18,8 +18,6 @@ import {CursorPos} from 'app/plugin/GristAPI';
|
||||
import isEqual = require('lodash/isEqual');
|
||||
import {Disposable, dom, Emitter, Holder, MultiHolder, Observable} from 'grainjs';
|
||||
|
||||
type IEditorConstructor = typeof NewBaseEditor;
|
||||
|
||||
const t = makeT('FieldEditor');
|
||||
|
||||
/**
|
||||
|
@ -118,11 +118,12 @@ export class FormulaEditor extends NewBaseEditor {
|
||||
|
||||
const hideErrDetails = Observable.create(this, true);
|
||||
const raisedException = Computed.create(this, use => {
|
||||
if (!options.formulaError || !use(options.formulaError)) {
|
||||
const formulaError = options.formulaError && use(options.formulaError);
|
||||
if (!formulaError) {
|
||||
return null;
|
||||
}
|
||||
const error = isRaisedException(use(options.formulaError)!) ?
|
||||
decodeObject(use(options.formulaError)!) as RaisedException:
|
||||
const error = isRaisedException(formulaError) ?
|
||||
decodeObject(formulaError) as RaisedException:
|
||||
new RaisedException(["Unknown 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.
|
||||
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.
|
||||
height: desiredElemSize.height + (errorBoxDesiredHeight - errorBoxStartHeight),
|
||||
};
|
||||
@ -488,7 +489,7 @@ export function openFormulaEditor(options: {
|
||||
column?: ColumnRec,
|
||||
// Associated formula from a view field. If provided together with column, this field is used
|
||||
field?: ViewFieldRec,
|
||||
editingFormula?: ko.Computed<boolean>,
|
||||
editingFormula: ko.Computed<boolean>,
|
||||
// Needed to get exception value, if any.
|
||||
editRow?: DataRowModel,
|
||||
// Element over which to position the editor.
|
||||
@ -555,7 +556,7 @@ export function openFormulaEditor(options: {
|
||||
column,
|
||||
field: options.field,
|
||||
}) : undefined;
|
||||
const editor = FormulaEditor.create(null, {
|
||||
const editorOptions: IFormulaEditorOptions = {
|
||||
gristDoc,
|
||||
column,
|
||||
field: options.field,
|
||||
@ -569,7 +570,8 @@ export function openFormulaEditor(options: {
|
||||
cssClass: 'formula_editor_sidepane',
|
||||
readonly : false,
|
||||
canDetach: options.canDetach
|
||||
} as IFormulaEditorOptions) as FormulaEditor;
|
||||
};
|
||||
const editor = FormulaEditor.create(null, editorOptions);
|
||||
editor.autoDispose(attachedHolder);
|
||||
editor.attach(refElem);
|
||||
|
||||
|
@ -96,6 +96,14 @@ export class NTextEditor extends NewBaseEditor {
|
||||
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
|
||||
*
|
||||
|
@ -13,11 +13,13 @@ export interface IEditorCommandGroup {
|
||||
[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 {
|
||||
gristDoc: GristDoc;
|
||||
cellValue: CellValue;
|
||||
rowId: number;
|
||||
formulaError: Observable<CellValue|undefined>;
|
||||
formulaError?: Observable<CellValue|undefined>;
|
||||
editValue?: string;
|
||||
cursorPos: number;
|
||||
commands: IEditorCommandGroup;
|
||||
@ -29,6 +31,9 @@ export interface FieldOptions extends Options {
|
||||
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:
|
||||
* @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
|
||||
* updated to new-style Disposables.
|
||||
*/
|
||||
public static create<Opt extends Options>(owner: IDisposableOwner|null, options: Opt): NewBaseEditor;
|
||||
public static create<Opt extends Options>(options: Opt): NewBaseEditor;
|
||||
public static create<T extends new (...args: any[]) => any, Opt extends Options>(
|
||||
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 {
|
||||
return options ?
|
||||
Disposable.create.call(this as any, ownerOrOptions, options) :
|
||||
|
@ -5,15 +5,15 @@ import ChoiceEditor from 'app/client/widgets/ChoiceEditor';
|
||||
import {ChoiceListCell} from 'app/client/widgets/ChoiceListCell';
|
||||
import {ChoiceListEditor} from 'app/client/widgets/ChoiceListEditor';
|
||||
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 DateTimeEditor from 'app/client/widgets/DateTimeEditor';
|
||||
import {DateTimeEditor} from 'app/client/widgets/DateTimeEditor';
|
||||
import DateTimeTextBox from 'app/client/widgets/DateTimeTextBox';
|
||||
import {HyperLinkEditor} from 'app/client/widgets/HyperLinkEditor';
|
||||
import {HyperLinkTextBox} from 'app/client/widgets/HyperLinkTextBox';
|
||||
import {MarkdownTextBox} from 'app/client/widgets/MarkdownTextBox';
|
||||
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 {NTextEditor} from 'app/client/widgets/NTextEditor';
|
||||
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 */
|
||||
export function getEditorConstructor(widget: string, type: string): typeof NewBaseEditor {
|
||||
export function getEditorConstructor(widget: string, type: string): IEditorConstructor {
|
||||
const {config} = getWidgetConfiguration(widget, type as GristType);
|
||||
return nameToWidget[config.editCons as keyof typeof nameToWidget] as any;
|
||||
}
|
||||
|
166
test/nbrowser/DateEditor.ts
Normal file
166
test/nbrowser/DateEditor.ts
Normal 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);
|
||||
});
|
||||
});
|
Loading…
Reference in New Issue
Block a user