gristlabs_grist-core/app/client/components/Drafts.ts
Dmitry S 8d68c1c567 (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
2021-07-23 08:02:05 -04:00

467 lines
14 KiB
TypeScript

import { CellPosition, toCursor } from "app/client/components/CellPosition";
import {
Disposable, dom, Emitter, Holder, IDisposable, IDisposableOwner,
IDomArgs, MultiHolder, styled, TagElem
} from "grainjs";
import { GristDoc } from "app/client/components/GristDoc";
import { ITooltipControl, showTooltip, tooltipCloseButton } from "app/client/ui/tooltips";
import { FieldEditorStateEvent } from "app/client/widgets/FieldEditor";
import { colors, testId } from "app/client/ui2018/cssVars";
import { cssLink } from "app/client/ui2018/links";
/**
* Component that keeps track of editor's state (draft value). If user hits an escape button
* by accident, this component will provide a way to continue the work.
* Each editor can report its current state, that will be remembered and restored
* when user whishes to continue his work.
* Each document can have only one draft at a particular time, that
* is cleared when changes occur on any other cell or the cursor navigates await from a cell.
*
* This component is built as a plugin for GristDoc. GristDoc, FieldBuilder, FieldEditor were just
* extended in order to provide some public interface that this objects plugs into.
* To disable the drafts, just simple remove it from GristDoc.
*/
export class Drafts extends Disposable {
constructor(
doc: GristDoc
) {
super();
// Here are all the parts that play some role in this feature
// Cursor will navigate the cursor on a view to a proper cell
const cursor: Cursor = CursorAdapter.create(this, doc);
// Storage will remember last draft
const storage: Storage = StorageAdapter.create(this);
// Notification will show notification with button to undo discard
const notification: Notification = NotificationAdapter.create(this, doc);
// Tooltip will hover above the editor and offer to continue from last edit
const tooltip: Tooltip = TooltipAdapter.create(this, doc);
// Editor will restore its previous state and inform about keyboard events
const editor: Editor = EditorAdapter.create(this, doc);
// Here is the main use case describing how parts are connected
const when = makeWhen(this);
// When user cancels the editor
when(editor.cellCancelled, (ev: StateChanged) => {
// if the state of the editor hasn't changed
if (!ev.modified) {
// close the tooltip and notification
tooltip.close();
notification.close();
// don't store the draft - we assume that user
// actually wanted to discard the draft by pressing
// escape again
return;
}
// Show notification
notification.showUndoDiscard();
// Save draft in memory
storage.save(ev);
// Make sure that tooltip is not visible
tooltip.close();
});
// When user clicks notification to continue with the draft
when(notification.pressed, async () => {
// if the draft is there
const draft = storage.get();
if (draft) {
// restore the position of a cell
await cursor.goToCell(draft.position);
// activate the editor
await editor.activate();
// and restore last draft
editor.setState(draft.state);
}
// We don't need the draft any more.
// If user presses escape one more time it will be crated
// once again
storage.clear();
// Close the notification
notification.close();
// tooltip is not visible here, and will be shown
// when editor is activated
});
// When user doesn't do anything while the notification is visible
// remove the draft when it disappears
when(notification.disappeared, () => {
storage.clear();
});
// When editor is activated (user typed something or double clicked a cell)
when(editor.activated, (pos: CellPosition) => {
// if there was a draft for a cell
if (storage.hasDraftFor(pos)) {
// show tooltip to continue with a draft
tooltip.showContinueDraft();
}
// make sure that notification is not visible
notification.close();
});
// When editor is modified, close tooltip after some time
when(editor.cellModified, (_: StateChanged) => {
tooltip.scheduleClose();
});
// When user saves a cell
when(editor.cellSaved, (_: StateChanged) => {
// just close everything and clear draft
storage.clear();
tooltip.close();
notification.close();
});
// When a user clicks a tooltip to continue with a draft
when(tooltip.click, () => {
const draft = storage.get();
// if there was a draft
if (draft) {
// restore the draft
editor.setState(draft.state);
}
// close the tooltip
tooltip.close();
});
}
}
///////////////////////////////////////////////////////////
// Roles definition that abstract the way this feature interacts with Grist
/**
* Cursor role can navigate the cursor to a proper cell
*/
interface Cursor {
goToCell(pos: CellPosition): Promise<void>;
}
/**
* Editor role represents active editor that is attached to a cell.
*/
interface Editor {
// Occurs when user triggers the save operation (by the enter key, clicking away)
cellSaved: TypedEmitter<StateChanged>;
// Occurs when user triggers the save operation (by the enter key, clicking away)
cellModified: TypedEmitter<StateChanged>;
// Occurs when user typed something on a cell or double clicked it
activated: TypedEmitter<CellPosition>;
// Occurs when user cancels the edit (mainly by the escape key or by icon on mobile)
cellCancelled: TypedEmitter<StateChanged>;
// Editor can restore its state
setState(state: any): void;
// Editor can be shown up to the user on active cell
activate(): Promise<void>;
}
/**
* Notification that is shown to the user on the right bottom corner
*/
interface Notification {
// Occurs when user clicked the notification
pressed: Signal;
// Occurs when notification disappears with no action from a user
disappeared: Signal;
// Notification can be closed if it is visible
close(): void;
// Show notification to the user, to inform him that he can continue with the draft
showUndoDiscard(): void;
}
/**
* Storage abstraction. Is responsible for storing latest
* draft (position and state)
*/
interface Storage {
// Retrieves latest draft data
get(): State | null;
// Stores latest draft data
save(ev: State): void;
// Checks if there is draft data at the position
hasDraftFor(position: CellPosition): boolean;
// Removes draft data
clear(): void;
}
/**
* Tooltip role is responsible for showing tooltip over active field editor with an information
* that the drafts is available, and a button to continue with the draft
*/
interface Tooltip {
// Occurs when user clicks the button on the tooltip - so he wants
// to continue with the draft
click: Signal;
// Show tooltip over active cell editor
showContinueDraft(): void;
// Close tooltip
close(): void;
// Close tooltip after some time
scheduleClose(): void;
}
/**
* Schema of the information that is stored in the storage.
*/
interface State {
// State of the editor
state: any;
// Cell position where the draft was created
position: CellPosition;
}
/**
* Event that is emitted when editor state has changed
*/
interface StateChanged extends State {
modified: boolean;
}
///////////////////////////////////////////////////////////
// Here are all the adapters for the roles above. They
// abstract the way this feature interacts with the GristDoc
class CursorAdapter extends Disposable implements Cursor {
constructor(private _doc: GristDoc) {
super();
}
public async goToCell(pos: CellPosition): Promise<void> {
await this._doc.recursiveMoveToCursorPos(toCursor(pos, this._doc.docModel), true);
}
}
class StorageAdapter extends Disposable implements Storage {
private _memory: State | null;
public get(): State | null {
return this._memory;
}
public save(ev: State) {
this._memory = ev;
}
public hasDraftFor(position: CellPosition): boolean {
const item = this._memory;
if (item && CellPosition.equals(item.position, position)) {
return true;
}
return false;
}
public clear(): void {
this._memory = null;
}
}
class NotificationAdapter extends Disposable implements Notification {
public readonly pressed: Signal;
public readonly disappeared: Signal;
private _hadAction = false;
private _holder = Holder.create(this);
constructor(private _doc: GristDoc) {
super();
this.pressed = this.autoDispose(new Emitter());
this.disappeared = this.autoDispose(new Emitter());
}
public close(): void {
this._hadAction = true;
this._holder.clear();
}
public showUndoDiscard() {
const notifier = this._doc.app.topAppModel.notifier;
const notification = notifier.createUserError("Undo discard", {
message: () =>
discardNotification(
dom.on("click", () => {
this._hadAction = true;
this.pressed.emit();
})
)
});
notification.onDispose(() => {
if (!this._hadAction) {
this.disappeared.emit();
}
});
this._holder.autoDispose(notification);
this._hadAction = false;
}
}
class TooltipAdapter extends Disposable implements Tooltip {
public readonly click: Signal;
// there can be only one tooltip at a time
private _tooltip: ITooltipControl | null = null;
private _scheduled = false;
constructor(private _doc: GristDoc) {
super();
this.click = this.autoDispose(new Emitter());
// make sure that the tooltip is closed when this object gets disposed
this.onDispose(() => {
this.close();
});
}
public scheduleClose(): void {
if (this._tooltip && !this._scheduled) {
this._scheduled = true;
const origClose = this._tooltip.close;
this._tooltip.close = () => { clearTimeout(timer); origClose(); };
const timer = setTimeout(this._tooltip.close, 6000);
}
}
public showContinueDraft(): void {
// close tooltip if there was a previous one
this.close();
// get the editor dom
const editorDom = this._doc.activeEditor.get()?.getDom();
if (!editorDom) {
return;
}
// attach the tooltip
this._tooltip = showTooltip(
editorDom,
cellTooltip(() => this.click.emit()));
}
public close(): void {
this._scheduled = false;
this._tooltip?.close();
this._tooltip = null;
}
}
class EditorAdapter extends Disposable implements Editor {
public readonly cellSaved: TypedEmitter<StateChanged> = this.autoDispose(new Emitter());
public readonly cellModified: TypedEmitter<StateChanged> = this.autoDispose(new Emitter());
public readonly activated: TypedEmitter<CellPosition> = this.autoDispose(new Emitter());
public readonly cellCancelled: TypedEmitter<StateChanged> = this.autoDispose(new Emitter());
private _holder = Holder.create<MultiHolder>(this);
constructor(private _doc: GristDoc) {
super();
// observe active editor
this.autoDispose(_doc.activeEditor.addListener((editor) => {
if (!editor) {
return;
}
// when the editor is created we assume that it is visible to the user
this.activated.emit(editor.cellPosition());
// 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);
mholder.autoDispose(editor.changeEmitter.addListener((e: FieldEditorStateEvent) => {
this.cellModified.emit({
position: e.position,
state: e.currentState,
modified: e.wasModified
});
}));
// when user presses escape
mholder.autoDispose(editor.cancelEmitter.addListener((e: FieldEditorStateEvent) => {
this.cellCancelled.emit({
position: e.position,
state: e.currentState,
modified: e.wasModified
});
}));
// when user presses enter to save the value
mholder.autoDispose(editor.saveEmitter.addListener((e: FieldEditorStateEvent) => {
this.cellSaved.emit({
position: e.position,
state: e.currentState,
modified: e.wasModified
});
}));
}));
}
public setState(state: any): void {
// rebuild active editor with a state from a draft
this._doc.activeEditor.get()?.rebuildEditor(undefined, Number.POSITIVE_INFINITY, state);
}
public async activate() {
// open up the editor at current position
await this._doc.activateEditorAtCursor({});
}
}
///////////////////////////////////////////////////////////
// Ui components
// Cell tooltip to restore the draft - it is visible over active editor
const styledTooltip = styled('div', `
display: flex;
align-items: center;
--icon-color: ${colors.lightGreen};
& > .${cssLink.className} {
margin-left: 8px;
}
`);
function cellTooltip(clb: () => any) {
return function (ctl: ITooltipControl) {
return styledTooltip(
cssLink('Restore last edit',
dom.on('mousedown', (ev) => { ev.preventDefault(); ctl.close(); clb(); }),
testId('draft-tooltip'),
),
tooltipCloseButton(ctl),
);
};
}
// Discard notification dom
const styledNotification = styled('div', `
cursor: pointer;
color: ${colors.lightGreen};
&:hover {
text-decoration: underline;
}
`);
function discardNotification(...args: IDomArgs<TagElem<"div">>) {
return styledNotification(
"Undo Discard",
testId("draft-notification"),
...args
);
}
///////////////////////////////////////////////////////////
// Internal implementations - not relevant to main use case
// helper method to listen to the Emitter and dispose the listener with a parent
function makeWhen(owner: IDisposableOwner) {
return function <T extends EmitterType<any>>(emitter: T, handler: EmitterHandler<T>) {
owner.autoDispose(emitter.addListener(handler as any));
};
}
// Default emitter is not typed, this augments the Emitter interface
interface TypedEmitter<T> {
emit(item: T): void;
addListener(clb: (e: T) => any): IDisposable;
}
interface Signal {
emit(): void;
addListener(clb: () => any): IDisposable;
}
type EmitterType<T> = T extends TypedEmitter<infer E> ? TypedEmitter<E> : Signal;
type EmitterHandler<T> = T extends TypedEmitter<infer E> ? ((e: E) => any) : () => any;