(core) Replace time zone selector with one based on the newer autocomplete.

Summary:
Flaky Dates test failures related to the use of JQuery autocomplete for time
zones, which wasn't working well.

This diff replaces that autocomplete (as well as a similar select box in
DocumentSettings) with our newer autocomplete, adding some select-box like
behavior.

Most of the behavior is factored out into ACSelect, which could be more
generally useful.

Adds an option to autocomplete to keep options ordered according to their
initial order.

Unrelated: fix up usage of MultiHolder in Drafts to avoid 'already disposed'
warnings.

Test Plan: Fixed several affected tests.

Reviewers: jarek

Reviewed By: jarek

Differential Revision: https://phab.getgrist.com/D2919
pull/9/head
Dmitry S 3 years ago
parent a07395855a
commit 8d68c1c567

@ -344,7 +344,7 @@ class EditorAdapter extends Disposable implements Editor {
public readonly activated: TypedEmitter<CellPosition> = this.autoDispose(new Emitter());
public readonly cellCancelled: TypedEmitter<StateChanged> = this.autoDispose(new Emitter());
private _holder = MultiHolder.create(this);
private _holder = Holder.create<MultiHolder>(this);
constructor(private _doc: GristDoc) {
super();
@ -358,11 +358,11 @@ class EditorAdapter extends Disposable implements Editor {
// when the editor is created we assume that it is visible to the user
this.activated.emit(editor.cellPosition());
// auto dispose all the previous listeners
this._holder.dispose();
this._holder = MultiHolder.create(this);
// Auto dispose the previous MultiHolder along with all the previous listeners, and create a
// new MultiHolder for the new ones.
const mholder = MultiHolder.create(this._holder);
this._holder.autoDispose(editor.changeEmitter.addListener((e: FieldEditorStateEvent) => {
mholder.autoDispose(editor.changeEmitter.addListener((e: FieldEditorStateEvent) => {
this.cellModified.emit({
position: e.position,
state: e.currentState,
@ -371,7 +371,7 @@ class EditorAdapter extends Disposable implements Editor {
}));
// when user presses escape
this._holder.autoDispose(editor.cancelEmitter.addListener((e: FieldEditorStateEvent) => {
mholder.autoDispose(editor.cancelEmitter.addListener((e: FieldEditorStateEvent) => {
this.cellCancelled.emit({
position: e.position,
state: e.currentState,
@ -380,7 +380,7 @@ class EditorAdapter extends Disposable implements Editor {
}));
// when user presses enter to save the value
this._holder.autoDispose(editor.saveEmitter.addListener((e: FieldEditorStateEvent) => {
mholder.autoDispose(editor.saveEmitter.addListener((e: FieldEditorStateEvent) => {
this.cellSaved.emit({
position: e.position,
state: e.currentState,

@ -31,6 +31,7 @@ import {urlState} from 'app/client/models/gristUrlState';
import {QuerySetManager} from 'app/client/models/QuerySet';
import {App} from 'app/client/ui/App';
import {DocHistory} from 'app/client/ui/DocHistory';
import {showDocSettingsModal} from 'app/client/ui/DocumentSettings';
import {IPageWidget, toPageWidget} from 'app/client/ui/PageWidgetPicker';
import {IPageWidgetLink, linkFromId, selectBy} from 'app/client/ui/selectBy';
import {startWelcomeTour} from 'app/client/ui/welcomeTour';
@ -60,8 +61,8 @@ import { Drafts } from "app/client/components/Drafts";
const G = getBrowserGlobals('document', 'window');
// Re-export DocComm to move it from main webpack bundle to the one with GristDoc.
export {DocComm};
// Re-export some tools to move them from main webpack bundle to the one with GristDoc.
export {DocComm, showDocSettingsModal};
export interface TabContent {
showObs?: any;

@ -9,6 +9,7 @@
import {localeCompare, nativeCompare, sortedIndex} from 'app/common/gutil';
import {DomContents} from 'grainjs';
import escapeRegExp = require("lodash/escapeRegExp");
export interface ACItem {
// This should be a trimmed lowercase version of the item's text. It may be an accessor.
@ -67,7 +68,9 @@ export class ACIndexImpl<Item extends ACItem> implements ACIndex<Item> {
// Creates an index for the given list of items.
// The max number of items to suggest may be set using _maxResults (default is 50).
constructor(items: Item[], private _maxResults: number = 50) {
// If _keepOrder is true, best matches will be suggested in the order they occur in items,
// rather than order by best score.
constructor(items: Item[], private _maxResults: number = 50, private _keepOrder = false) {
this._allItems = items.slice(0);
// Collects [word, occurrence, position] tuples for all words in _allItems.
@ -116,15 +119,20 @@ export class ACIndexImpl<Item extends ACItem> implements ACIndex<Item> {
.sort((a, b) => nativeCompare(b[1], a[1]) || nativeCompare(a[0], b[0]))
.slice(0, this._maxResults);
const items: Item[] = sortedMatches.map(([index, score]) => this._allItems[index]);
const itemIndices: number[] = sortedMatches.map(([index, score]) => index);
// Append enough non-matching items to reach maxResults.
for (let i = 0; i < this._allItems.length && items.length < this._maxResults; i++) {
// Append enough non-matching indices to reach maxResults.
for (let i = 0; i < this._allItems.length && itemIndices.length < this._maxResults; i++) {
if (this._allItems[i].cleanText && !myMatches.has(i)) {
items.push(this._allItems[i]);
itemIndices.push(i);
}
}
if (this._keepOrder) {
itemIndices.sort(nativeCompare);
}
const items = itemIndices.map(index => this._allItems[index]);
if (!cleanedSearchText) {
// In this case we are just returning the first few items.
return {items, highlightFunc: highlightNone, selectIndex: -1};
@ -132,11 +140,11 @@ export class ACIndexImpl<Item extends ACItem> implements ACIndex<Item> {
const highlightFunc = highlightMatches.bind(null, searchWords);
// The best match is the first item. If any word in the item actually starts with the search
// text, highlight it as a default selection. Otherwise, no item will be auto-selected.
let selectIndex = -1;
if (items.length > 0 && sortedMatches.length > 0 && startsWithText(items[0], cleanedSearchText)) {
selectIndex = 0;
// If we have a best match, and any word in it actually starts with the search text, report it
// as a default selection for highlighting. Otherwise, no item will be auto-selected.
let selectIndex = sortedMatches.length > 0 ? itemIndices.indexOf(sortedMatches[0][0]) : -1;
if (selectIndex >= 0 && !startsWithText(items[selectIndex], cleanedSearchText, searchWords)) {
selectIndex = -1;
}
return {items, highlightFunc, selectIndex};
}
@ -248,11 +256,13 @@ function findCommonPrefixLength(text1: string, text2: string): number {
}
/**
* Checks whether `item` starts with `text`, or has any words that start with `text`.
* Checks whether `item` starts with `text`, or whether all words of text are prefixes of the
* words of `item`. (E.g. it would return true if item is "New York", and text is "ne yo".)
*/
function startsWithText(item: ACItem, text: string): boolean {
function startsWithText(item: ACItem, text: string, searchWords: string[]): boolean {
if (item.cleanText.startsWith(text)) { return true; }
const words = item.cleanText.split(wordSepRegexp);
return words.some(w => w.startsWith(text));
const regexp = new RegExp(searchWords.map(w => `\\b` + escapeRegExp(w)).join('.*'));
const cleanText = item.cleanText.split(wordSepRegexp).join(' ');
return regexp.test(cleanText);
}

@ -0,0 +1,149 @@
import {ACIndex, ACItem, buildHighlightedDom} from 'app/client/lib/ACIndex';
import {Autocomplete, IAutocompleteOptions} from 'app/client/lib/autocomplete';
import {colors} from "app/client/ui2018/cssVars";
import {icon} from "app/client/ui2018/icons";
import {menuCssClass} from 'app/client/ui2018/menus';
import {dom, DomElementArg, Holder, IDisposableOwner, Observable, styled} from 'grainjs';
export interface ACSelectItem extends ACItem {
value: string;
label: string;
}
/**
* Builds a text input with an autocomplete dropdown.
* Note that because it is currently only used in the right-side panel, it is designed to avoid
* keeping focus.
*/
export function buildACSelect(
owner: IDisposableOwner,
options: {
acIndex: ACIndex<ACSelectItem>,
valueObs: Observable<string>,
save: (value: string, item: ACSelectItem|undefined) => Promise<void>|void
},
...args: DomElementArg[]
) {
const {acIndex, valueObs, save} = options;
const acHolder = Holder.create<Autocomplete<ACSelectItem>>(owner);
let textInput: HTMLInputElement;
const isOpen = () => !acHolder.isEmpty();
const acOpen = () => acHolder.isEmpty() && Autocomplete.create(acHolder, textInput, acOptions);
const acClose = () => acHolder.clear();
const finish = () => { acClose(); textInput.blur(); };
const revert = () => { textInput.value = valueObs.get(); finish(); };
const commitOrRevert = async () => { (await commitIfValid()) || revert(); };
const openOrCommit = () => { isOpen() ? commitOrRevert().catch(() => {}) : acOpen(); };
const commitIfValid = async () => {
const item = acHolder.get()?.getSelectedItem();
if (item) {
textInput.value = item.value;
}
textInput.disabled = true;
try {
await save(textInput.value, item);
finish();
return true;
} catch (e) {
return false;
} finally {
textInput.disabled = false;
}
};
const onMouseDown = (ev: MouseEvent) => {
ev.preventDefault(); // Don't let it affect focus, since we focus/blur manually.
if (!isOpen()) { textInput.focus(); }
openOrCommit();
};
const acOptions: IAutocompleteOptions<ACSelectItem> = {
menuCssClass: `${menuCssClass} test-acselect-dropdown`,
search: async (term: string) => acIndex.search(term),
renderItem: (item, highlightFunc) =>
cssSelectItem(buildHighlightedDom(item.label, highlightFunc, cssMatchText)),
getItemText: (item) => item.value,
onClick: commitIfValid,
};
return cssSelectBtn(
textInput = cssInput({type: 'text'},
dom.prop('value', valueObs),
dom.on('focus', (ev, elem) => elem.select()),
dom.on('blur', commitOrRevert),
dom.onKeyDown({
Escape: revert,
Enter: openOrCommit,
ArrowDown: acOpen,
Tab: commitIfValid,
}),
dom.on('input', acOpen),
),
dom.on('mousedown', onMouseDown),
cssIcon('Dropdown'),
...args
);
}
const cssSelectBtn = styled('div', `
position: relative;
width: 100%;
height: 30px;
color: ${colors.dark};
--icon-color: ${colors.dark};
`);
const cssSelectItem = styled('li', `
display: block;
white-space: pre;
overflow: hidden;
text-overflow: ellipsis;
outline: none;
padding: var(--weaseljs-menu-item-padding, 8px 24px);
cursor: pointer;
&.selected {
background-color: var(--weaseljs-selected-background-color, #5AC09C);
color: var(--weaseljs-selected-color, white);
}
`);
const cssInput = styled('input', `
appearance: none;
-webkit-appearance: none;
-moz-appearance: none;
height: 100%;
width: 100%;
padding: 0 6px;
outline: none;
border: 1px solid ${colors.darkGrey};
border-radius: 3px;
cursor: pointer;
line-height: 16px;
cursor: pointer;
&:disabled {
color: grey;
background-color: initial;
}
&:focus {
cursor: initial;
outline: none;
box-shadow: 0px 0px 2px 2px #5E9ED6;
}
`);
const cssIcon = styled(icon, `
position: absolute;
right: 6px;
top: calc(50% - 8px);
`);
const cssMatchText = styled('span', `
color: ${colors.lightGreen};
.selected > & {
color: ${colors.lighterGreen};
}
`);

@ -1,8 +1,7 @@
import {loadUserManager} from 'app/client/lib/imports';
import {loadGristDoc, loadUserManager} from 'app/client/lib/imports';
import {AppModel, reportError} from 'app/client/models/AppModel';
import {DocPageModel} from 'app/client/models/DocPageModel';
import {getLoginOrSignupUrl, getLoginUrl, getLogoutUrl, urlState} from 'app/client/models/gristUrlState';
import {showDocSettingsModal} from 'app/client/ui/DocumentSettings';
import {showProfileModal} from 'app/client/ui/ProfileDialog';
import {createUserImage} from 'app/client/ui/UserImage';
import * as viewport from 'app/client/ui/viewport';
@ -82,7 +81,8 @@ export class AccountWidget extends Disposable {
// The 'Document Settings' item, when there is an open document.
const documentSettingsItem = (gristDoc ?
menuItem(() => showDocSettingsModal(gristDoc.docInfo, this._docPageModel!), 'Document Settings',
menuItem(async () => (await loadGristDoc()).showDocSettingsModal(gristDoc.docInfo, this._docPageModel!),
'Document Settings',
testId('dm-doc-settings')) :
null);

@ -2,49 +2,15 @@
* This module export a component for editing some document settings consisting of the timezone,
* (new settings to be added here ...).
*/
import { dom, IOptionFull, select, styled } from 'grainjs';
import { dom, styled } from 'grainjs';
import { Computed, Observable } from 'grainjs';
import { loadMomentTimezone, MomentTimezone } from 'app/client/lib/imports';
import { loadMomentTimezone } from 'app/client/lib/imports';
import { DocInfoRec } from 'app/client/models/DocModel';
import { DocPageModel } from 'app/client/models/DocPageModel';
import { testId, vars } from 'app/client/ui2018/cssVars';
import { vars } from 'app/client/ui2018/cssVars';
import { saveModal } from 'app/client/ui2018/modals';
import { nativeCompare } from 'app/common/gutil';
/**
* Returns the ordered list of offsets for names at time timestamp. See timezoneOptions for details
* on the sorting order.
*/
// exported for testing
export function timezoneOptionsImpl(
timestamp: number, names: string[], moment: MomentTimezone
): Array<IOptionFull<string>> {
// What we want is moment(timestamp) but the dynamic import with our compiling settings produces
// "moment is not a function". The following is equivalent, and easier than fixing import setup.
const m = moment.unix(timestamp / 1000);
const options = names.map((value) => ({
value,
label: `(GMT${m.tz(value).format('Z')}) ${value}`,
// A quick test reveal that it is a bit more efficient (~0.02ms) to get the offset using
// `moment.tz.Zone#parse` than creating a Moment instance for each zone and then getting the
// offset with `moment#utcOffset`.
offset: -moment.tz.zone(value)!.parse(timestamp)
}));
options.sort((a, b) => nativeCompare(a.offset, b.offset) || nativeCompare(a.value, b.value));
return options.map(({value, label}) => ({value, label}));
}
/**
* Returns the array of IOptionFull<string> expected by `select` to create the list of timezones
* options. The returned list is sorted based on the current offset (GMT-11:00 before GMT-10:00),
* and then on alphabetical order of the name.
*/
function timezoneOptions(moment: MomentTimezone): Array<IOptionFull<string>> {
return timezoneOptionsImpl(Date.now(), moment.tz.names(), moment);
}
import { buildTZAutocomplete } from 'app/client/widgets/TZAutocomplete';
/**
* Builds a simple saveModal for saving settings.
@ -59,7 +25,7 @@ export async function showDocSettingsModal(docInfo: DocInfoRec, docPageModel: Do
cssDataRow("This document's ID (for API use):"),
cssDataRow(dom('tt', docPageModel.currentDocId.get())),
cssDataRow('Time Zone:'),
cssDataRow(select(timezone, timezoneOptions(moment)), testId('ds-tz')),
cssDataRow(dom.create(buildTZAutocomplete, moment, timezone, (val) => timezone.set(val))),
],
// At this point, we only need to worry about saving this one setting.
saveFunc: () => docInfo.timezone.saveOnly(timezone.get()),

@ -1,4 +1,3 @@
/* globals $ */
var _ = require('underscore');
var ko = require('knockout');
var moment = require('moment-timezone');
@ -11,11 +10,11 @@ var gutil = require('app/common/gutil');
const {fromKoSave} = require('app/client/lib/fromKoSave');
const {alignmentSelect} = require('app/client/ui2018/buttonSelect');
const {testId} = require('app/client/ui2018/cssVars');
const {cssRow, cssLabel} = require('app/client/ui/RightPanel');
const {cssTextInput} = require("app/client/ui2018/editableLabel");
const {styled, fromKo} = require('grainjs');
const {dom: gdom, styled, fromKo} = require('grainjs');
const {select} = require('app/client/ui2018/menus');
const {buildTZAutocomplete} = require('app/client/widgets/TZAutocomplete');
/**
@ -24,25 +23,11 @@ const {select} = require('app/client/ui2018/menus');
function DateTimeTextBox(field) {
DateTextBox.call(this, field);
this.timezoneOptions = moment.tz.names();
this.isInvalidTimezone = ko.observable(false);
// Returns the timezone from the end of the type string
this.timezone = this.autoDispose(ko.computed({
owner: this,
read: function() {
return gutil.removePrefix(field.column().type(), "DateTime:");
},
write: function(val) {
if (_.contains(this.timezoneOptions, val)) {
field.column().type.setAndSave('DateTime:' + val);
this.isInvalidTimezone(false);
} else {
this.isInvalidTimezone(true);
}
}
}));
this._timezone = this.autoDispose(ko.computed(() =>
gutil.removePrefix(field.column().type(), "DateTime:")));
this._setTimezone = (val) => field.column().type.setAndSave('DateTime:' + val);
this.timeFormat = this.options.prop('timeFormat');
this.isCustomTimeFormat = this.options.prop('isCustomTimeFormat');
@ -79,38 +64,9 @@ _.extend(DateTimeTextBox.prototype, DateTextBox.prototype);
*/
DateTimeTextBox.prototype.buildConfigDom = function(isTransformConfig) {
var self = this;
// Set up autocomplete for the timezone entry.
var textDom = textbox(self.timezone);
var tzInput = textDom.querySelector('input');
$(tzInput).autocomplete({
source: self.timezoneOptions,
classes : {
"ui-autocomplete": cssAutocomplete.className
},
minLength: 1,
delay: 10,
position : { my: "left top", at: "left bottom+4" },
select: function(event, ui) {
self.timezone(ui.item.value);
return false;
}
});
return dom('div',
cssLabel("Timezone"),
cssRow(
dom(textDom,
kd.toggleClass('invalid-text', this.isInvalidTimezone),
dom.testId("Widget_tz"),
dom.on('keydown', (e) => {
switch (e.keyCode) {
case 13: $(tzInput).autocomplete('close'); break;
}
}),
testId('widget-tz')
)
),
cssRow(gdom.create(buildTZAutocomplete, moment, fromKo(this._timezone), this._setTimezone)),
self.buildDateConfigDom(),
cssLabel("Time Format"),
cssRow(dom(select(fromKo(self.standardTimeFormat), self.timeFormatOptions), dom.testId("Widget_timeFormat"))),
@ -142,35 +98,6 @@ const cssFocus = styled('div', `
}
`)
// override styles for jquery auto-complete - to make it look like weasel select menu
const cssAutocomplete = styled('ui', `
min-width: 208px;
font-family: var(--grist-font-family);
font-size: var(--grist-medium-font-size);
line-height: initial;
max-width: 400px;
border: 0px !important;
max-height: 500px;
overflow-y: auto;
margin-top: 3px;
padding: 8px 0px 16px 0px;
box-shadow: 0 2px 20px 0 rgb(38 38 51 / 60%);
& li {
padding: 8px 16px;
}
& li:hover {
background: #5AC09C;
}
& li div {
border: 0px !important;
margin: 0px !important;
}
& li:hover div {
background: transparent !important;
color: white !important;
}
`)
// helper method to create old style textbox that looks like a new one
function textbox(value) {

@ -0,0 +1,75 @@
import {MomentTimezone} from 'app/client/lib/imports';
import {ACIndexImpl} from 'app/client/lib/ACIndex';
import {ACSelectItem, buildACSelect} from 'app/client/lib/ACSelect';
import {testId} from "app/client/ui2018/cssVars";
import {nativeCompare} from 'app/common/gutil';
import {IDisposableOwner, Observable} from 'grainjs';
/**
* Returns the ordered list of offsets for names at time timestamp. See timezoneOptions for details
* on the sorting order.
*/
// exported for testing
export function timezoneOptionsImpl(
timestamp: number, names: string[], moment: MomentTimezone
): ACSelectItem[] {
// What we want is moment(timestamp) but the dynamic import with our compiling settings produces
// "moment is not a function". The following is equivalent, and easier than fixing import setup.
const m = moment.unix(timestamp / 1000);
const options = names.map((value) => ({
cleanText: value.toLowerCase().trim(),
value,
label: `(GMT${m.tz(value).format('Z')}) ${value}`,
// A quick test reveal that it is a bit more efficient (~0.02ms) to get the offset using
// `moment.tz.Zone#parse` than creating a Moment instance for each zone and then getting the
// offset with `moment#utcOffset`.
offset: -moment.tz.zone(value)!.parse(timestamp)
}));
options.sort((a, b) => nativeCompare(a.offset, b.offset) || nativeCompare(a.value, b.value));
return options;
}
/**
* Returns the array of IOptionFull<string> expected by `select` to create the list of timezones
* options. The returned list is sorted based on the current offset (GMT-11:00 before GMT-10:00),
* and then on alphabetical order of the name.
*/
function timezoneOptions(moment: MomentTimezone): ACSelectItem[] {
return timezoneOptionsImpl(Date.now(), moment.tz.names(), moment);
}
/**
* Creates a textbox with an autocomplete dropdown to select a time zone.
* Usage: dom.create(buildTZAutocomplete, momentModule, valueObs, saveCallback)
*/
export function buildTZAutocomplete(
owner: IDisposableOwner,
moment: MomentTimezone,
valueObs: Observable<string>,
save: (value: string) => Promise<void>|void
) {
// Set a large maxResults, since it's sometimes nice to see all supported timezones (there are
// fewer than 1000 in practice).
const acIndex = new ACIndexImpl<ACSelectItem>(timezoneOptions(moment), 1000, true);
// Only save valid time zones. If there is no selected item, we'll auto-select and save only
// when there is a good match.
const saveTZ = (value: string, item: ACSelectItem|undefined) => {
if (!item) {
const results = acIndex.search(value);
if (results.selectIndex >= 0 && results.items.length > 0) {
item = results.items[results.selectIndex];
value = item.value;
}
}
if (!item) { throw new Error("Invalid time zone"); }
if (value !== valueObs.get()) {
return save(value);
}
};
return buildACSelect(owner,
{acIndex, valueObs, save: saveTZ},
testId("tz-autocomplete")
);
}
Loading…
Cancel
Save