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.
|
// historical accident than an intentional choice.
|
||||||
reload(forceGet?: boolean): void;
|
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 { 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) {
|
||||||
|
@ -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');
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -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);
|
||||||
|
|
||||||
|
@ -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
|
||||||
*
|
*
|
||||||
|
@ -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) :
|
||||||
|
@ -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
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