mirror of
https://github.com/gristlabs/grist-core.git
synced 2026-03-02 04:09:24 +00:00
(core) move client code to core
Summary: This moves all client code to core, and makes minimal fix-ups to get grist and grist-core to compile correctly. The client works in core, but I'm leaving clean-up around the build and bundles to follow-up. Test Plan: existing tests pass; server-dev bundle looks sane Reviewers: dsagal Reviewed By: dsagal Differential Revision: https://phab.getgrist.com/D2627
This commit is contained in:
369
app/client/models/NotifyModel.ts
Normal file
369
app/client/models/NotifyModel.ts
Normal file
@@ -0,0 +1,369 @@
|
||||
import * as log from 'app/client/lib/log';
|
||||
import {ConnectState, ConnectStateManager} from 'app/client/models/ConnectState';
|
||||
import {delay} from 'app/common/delay';
|
||||
import {isLongerThan} from 'app/common/gutil';
|
||||
import {InactivityTimer} from 'app/common/InactivityTimer';
|
||||
import {timeFormat} from 'app/common/timeFormat';
|
||||
import {bundleChanges, Disposable, Holder, IDisposable, IDisposableOwner } from 'grainjs';
|
||||
import {Computed, dom, DomElementArg, MutableObsArray, obsArray, Observable} from 'grainjs';
|
||||
import clamp = require('lodash/clamp');
|
||||
|
||||
// When rendering app errors, we'll only show the last few.
|
||||
const maxAppErrors = 5;
|
||||
|
||||
interface INotifier {
|
||||
// If you are looking to report errors, please do that via reportError rather
|
||||
// than these methods so that we have a chance to send the error to our logs.
|
||||
createUserError(message: string, options?: INotifyOptions): INotification;
|
||||
createAppError(error: Error): void;
|
||||
|
||||
createProgressIndicator(name: string, size: string, expireOnComplete: boolean): IProgress;
|
||||
createNotification(options: INotifyOptions): INotification;
|
||||
setConnectState(isConnected: boolean): void;
|
||||
slowNotification<T>(promise: Promise<T>, optTimeout?: number): Promise<T>;
|
||||
getFullAppErrors(): IAppError[];
|
||||
}
|
||||
|
||||
interface INotification extends Expirable {
|
||||
expire(): Promise<void>;
|
||||
}
|
||||
|
||||
export interface IProgress extends Expirable {
|
||||
setProgress(percent: number): void;
|
||||
}
|
||||
|
||||
// Identifies supported actions. These are implemented in NotifyUI.
|
||||
export type NotifyAction = 'upgrade' | 'renew' | 'report-problem' | 'ask-for-help';
|
||||
|
||||
export interface INotifyOptions {
|
||||
message: string | (() => DomElementArg); // A string, or a function that builds dom.
|
||||
timestamp?: number;
|
||||
title?: string;
|
||||
canUserClose?: boolean;
|
||||
inToast?: boolean;
|
||||
inDropdown?: boolean;
|
||||
expireSec?: number;
|
||||
badgeCounter?: boolean;
|
||||
|
||||
// cssToastAction class from NotifyUI will be applied automatically to action elements.
|
||||
actions?: NotifyAction[];
|
||||
|
||||
// When set, the notification will replace any previous notification with the same key.
|
||||
// This way, we can avoid accumulating many of substantially identical notifications.
|
||||
key?: string|null;
|
||||
}
|
||||
|
||||
type Status = 'active' | 'expiring';
|
||||
|
||||
export class Expirable extends Disposable {
|
||||
public static readonly fadeDelay = 250;
|
||||
public readonly status = Observable.create<Status>(this, 'active');
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets status to 'expiring', then calls dispose after a short delay.
|
||||
*/
|
||||
public async expire(): Promise<void> {
|
||||
this.status.set('expiring');
|
||||
await delay(Expirable.fadeDelay);
|
||||
if (!this.isDisposed()) {
|
||||
this.dispose();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export class Notification extends Expirable implements INotification {
|
||||
|
||||
public options: Required<INotifyOptions> = {
|
||||
title: '',
|
||||
message: '',
|
||||
timestamp: Date.now(),
|
||||
inDropdown: false,
|
||||
badgeCounter: false,
|
||||
inToast: true,
|
||||
expireSec: 0,
|
||||
canUserClose: false,
|
||||
actions: [],
|
||||
key: null,
|
||||
};
|
||||
|
||||
constructor(_opts: INotifyOptions) {
|
||||
super();
|
||||
Object.assign(this.options, _opts);
|
||||
|
||||
if (this.options.expireSec > 0) {
|
||||
const expireTimer = setTimeout(() => this.expire(), 1000 * this.options.expireSec);
|
||||
this.onDispose(() => clearTimeout(expireTimer));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
interface IProgressOptions {
|
||||
name: string;
|
||||
size: string;
|
||||
expireOnComplete?: boolean;
|
||||
}
|
||||
|
||||
export class Progress extends Expirable implements IProgress {
|
||||
|
||||
public readonly progress = Observable.create(this, 0);
|
||||
|
||||
constructor(public options: IProgressOptions) {
|
||||
super();
|
||||
|
||||
if (options.expireOnComplete) {
|
||||
this.autoDispose(this.progress.addListener(async progress => {
|
||||
if (progress >= 100) {
|
||||
await this.expire();
|
||||
}
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* progress should be between 0 and 100.
|
||||
*/
|
||||
public setProgress(progress: number) {
|
||||
this.progress.set(clamp(progress, 0, 100));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Similar to grainjs MultiHolder, but knows when items are disposed externally and releases them
|
||||
* (avoiding the "already disposed" warnings in that case). This is probably how grainjs's
|
||||
* MultiHolder should actually work, and maybe how `Disposable.autoDispose` should generally work.
|
||||
*/
|
||||
export class BetterMultiHolder implements IDisposableOwner {
|
||||
private _items = new Set<IDisposable>();
|
||||
|
||||
public autoDispose<T extends IDisposable>(obj: T): T {
|
||||
this._items.add(obj);
|
||||
if (obj instanceof Disposable) {
|
||||
obj.onDispose(() => this._items.delete(obj));
|
||||
}
|
||||
return obj;
|
||||
}
|
||||
|
||||
public dispose() {
|
||||
for (const item of this._items) {
|
||||
item.dispose();
|
||||
}
|
||||
this._items.clear();
|
||||
}
|
||||
}
|
||||
|
||||
export interface IAppError {
|
||||
error: Error;
|
||||
timestamp: number;
|
||||
seen?: boolean; // If seen, this will be hidden from the "app errors" toast
|
||||
}
|
||||
|
||||
export class Notifier extends Disposable implements INotifier {
|
||||
private _itemsHolder = this.autoDispose(new BetterMultiHolder());
|
||||
|
||||
private _toasts = this.autoDispose(obsArray<Notification>());
|
||||
private _dropdownItems = this.autoDispose(obsArray<Notification>());
|
||||
private _progressItems = this.autoDispose(obsArray<Progress>([]));
|
||||
private _keyedItems = new Map<string, Notification>();
|
||||
|
||||
private _connectStateManager = ConnectStateManager.create(this);
|
||||
private _connectState = this._connectStateManager.connectState;
|
||||
private _disconnectMsg = Computed.create(this, (use) => getDisconnectMessage(use(this._connectState)));
|
||||
|
||||
// Holds recent application errors, which the user may report to us.
|
||||
private _appErrorList = this.autoDispose(obsArray<IAppError>());
|
||||
|
||||
// The dropdown will show all recent errors; the toast only the "new" ones, i.e. those since the
|
||||
// last toast was closed.
|
||||
private _appErrorDropdownItem = Holder.create<INotification>(this);
|
||||
private _appErrorToast = Holder.create<INotification>(this);
|
||||
private _slowNotificationToast = Holder.create<INotification>(this);
|
||||
private _slowNotificationInactivityTimer = new InactivityTimer(() => this._slowNotificationToast.clear(), 0);
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
Computed.create(this, this._disconnectMsg, (use, msg) =>
|
||||
msg ? use.owner.autoDispose(this.createNotification({
|
||||
message: msg.message,
|
||||
title: msg.title,
|
||||
canUserClose: true,
|
||||
inToast: true,
|
||||
})) : null);
|
||||
}
|
||||
|
||||
/**
|
||||
* Exposes all the state needed for building UI. This is simply to clarify the intended usage:
|
||||
* these members aren't intended to be exposed, except to the UI-building code.
|
||||
*/
|
||||
public getStateForUI() {
|
||||
return {
|
||||
toasts: this._toasts,
|
||||
dropdownItems: this._dropdownItems,
|
||||
progressItems: this._progressItems,
|
||||
connectState: this._connectState,
|
||||
disconnectMsg: this._disconnectMsg,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a basic toast user error. By default, expires in 5 seconds.
|
||||
* Takes an options objects to configure `expireSec` and `canUserClose`.
|
||||
* Set `expireSec` to 0 to prevent expiration.
|
||||
*
|
||||
* If you are looking to report errors, please do that via reportError so
|
||||
* that we have a chance to send the error to our logs.
|
||||
*/
|
||||
public createUserError(message: string, options: Partial<INotifyOptions> = {}): INotification {
|
||||
const timestamp = Date.now();
|
||||
if (options.actions && options.actions.includes('ask-for-help')) {
|
||||
// If user should be able to ask for help, add this error to the notifier dropdown too for a
|
||||
// good while, so the user can find it after the toast disappears.
|
||||
this.createNotification({
|
||||
timestamp,
|
||||
message,
|
||||
inToast: false,
|
||||
expireSec: 300,
|
||||
canUserClose: true,
|
||||
inDropdown: true,
|
||||
...options,
|
||||
key: options.key && ("dropdown:" + options.key),
|
||||
});
|
||||
}
|
||||
return this.createNotification({
|
||||
timestamp,
|
||||
message,
|
||||
inToast: true,
|
||||
expireSec: 10,
|
||||
canUserClose: true,
|
||||
inDropdown: false,
|
||||
...options,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* If you are looking to report errors, please do that via reportError so
|
||||
* that we have a chance to send the error to our logs.
|
||||
*/
|
||||
public createAppError(error: Error): void {
|
||||
bundleChanges(() => {
|
||||
// Remove old messages, to keep a max of maxAppErrors.
|
||||
if (this._appErrorList.get().length >= maxAppErrors) {
|
||||
this._appErrorList.splice(0, this._appErrorList.get().length - maxAppErrors + 1);
|
||||
}
|
||||
this._appErrorList.push({error, timestamp: Date.now()});
|
||||
});
|
||||
|
||||
// Create a dropdown item for errors if we don't have one yet.
|
||||
if (this._appErrorDropdownItem.isEmpty()) {
|
||||
this._appErrorDropdownItem.autoDispose(this._createAppErrorItem('dropdown'));
|
||||
}
|
||||
|
||||
// Create a toast for errors if we don't have one yet. When it's closed, mark the items as
|
||||
// "seen" (i.e. not to be shown when the toast pops up again).
|
||||
if (this._appErrorToast.isEmpty()) {
|
||||
const n = this._appErrorToast.autoDispose(this._createAppErrorItem('toast'));
|
||||
n.onDispose(() => this._appErrorList.get().forEach((appErr) => { appErr.seen = true; }));
|
||||
}
|
||||
}
|
||||
|
||||
public createNotification(opts: INotifyOptions): INotification {
|
||||
const n = Notification.create(this._itemsHolder, opts);
|
||||
this._addNotification(n).catch((e) => { log.warn('_addNotification failed', e); });
|
||||
return n;
|
||||
}
|
||||
|
||||
public createProgressIndicator(name: string, size: string, expireOnComplete = false): IProgress {
|
||||
// Progress objects normally dispose themselves; constructor disposes any leftover items.
|
||||
const p = Progress.create(this._itemsHolder, {name, size, expireOnComplete});
|
||||
this._progressItems.push(p);
|
||||
p.onDispose(() => this.isDisposed() || arrayRemove(this._progressItems, p));
|
||||
return p;
|
||||
}
|
||||
|
||||
public setConnectState(isConnected: boolean): void {
|
||||
this._connectStateManager.setConnected(isConnected);
|
||||
}
|
||||
|
||||
public getFullAppErrors() {
|
||||
return this._appErrorList.get();
|
||||
}
|
||||
|
||||
// This is exposed primarily for tests.
|
||||
public clearAppErrors() {
|
||||
this._appErrorList.splice(0);
|
||||
}
|
||||
|
||||
/**
|
||||
* Show a notification when promise takes longer than optTimeout to resolve. Returns the passed in
|
||||
* promise.
|
||||
*/
|
||||
public async slowNotification<T>(promise: Promise<T>, optTimeout: number = 1000): Promise<T> {
|
||||
if (await isLongerThan(promise, optTimeout)) {
|
||||
if (this._slowNotificationToast.isEmpty()) {
|
||||
this._slowNotificationToast.autoDispose(this.createNotification({
|
||||
message: "Still working...",
|
||||
canUserClose: false,
|
||||
inToast: true,
|
||||
}));
|
||||
}
|
||||
await this._slowNotificationInactivityTimer.disableUntilFinish(promise);
|
||||
}
|
||||
return promise;
|
||||
}
|
||||
|
||||
private async _addNotification(n: Notification): Promise<void> {
|
||||
const key = n.options.key;
|
||||
if (key) {
|
||||
const prev = this._keyedItems.get(key);
|
||||
if (prev) {
|
||||
await prev.expire();
|
||||
}
|
||||
this._keyedItems.set(key, n);
|
||||
n.onDispose(() => this.isDisposed() || this._keyedItems.delete(key));
|
||||
}
|
||||
if (n.options.inToast) {
|
||||
this._toasts.push(n);
|
||||
n.onDispose(() => this.isDisposed() || arrayRemove(this._toasts, n));
|
||||
}
|
||||
if (n.options.inDropdown) {
|
||||
this._dropdownItems.push(n);
|
||||
n.onDispose(() => this.isDisposed() || arrayRemove(this._dropdownItems, n));
|
||||
}
|
||||
}
|
||||
|
||||
private _createAppErrorItem(where: 'toast' | 'dropdown') {
|
||||
return this.createNotification({
|
||||
// Building DOM here in NotifyModel seems wrong, but I haven't come up with a better way.
|
||||
message: () => dom.forEach(this._appErrorList, (appErr: IAppError) =>
|
||||
(where === 'toast' && appErr.seen ? null :
|
||||
dom('div', timeFormat('T', new Date(appErr.timestamp)), ' ', appErr.error.message)
|
||||
)
|
||||
),
|
||||
title: 'Unexpected error',
|
||||
canUserClose: true,
|
||||
inToast: where === 'toast',
|
||||
expireSec: where === 'toast' ? 10 : 0,
|
||||
inDropdown: where === 'dropdown',
|
||||
actions: ['report-problem'],
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function arrayRemove<T>(arr: MutableObsArray<T>, elem: T) {
|
||||
const removeIdx = arr.get().findIndex(e => e === elem);
|
||||
if (removeIdx !== -1) {
|
||||
arr.splice(removeIdx, 1);
|
||||
}
|
||||
}
|
||||
|
||||
function getDisconnectMessage(state: ConnectState): {title: string, message: string}|undefined {
|
||||
switch (state) {
|
||||
case ConnectState.RecentlyDisconnected:
|
||||
return {title: 'Connection is lost', message: 'Attempting to reconnect...'};
|
||||
case ConnectState.ReallyDisconnected:
|
||||
return {title: 'Not connected', message: 'The document is in read-only mode until you are back online.'};
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user