mirror of
https://github.com/gristlabs/grist-core.git
synced 2024-10-27 20:44:07 +00:00
693f2f6325
Summary: This diff brings in the new welcome tour. It builds upon `client/ui/OnBoardingPopup` that was committed to that purposes. Per this diff, the tour is accessible behind a flag and won't be visible to user: few caveats listed below needs to be adressed first. This diff also brings few changes to onboarding module. - allow to refer to element with selector - usually dynamic selection of element sounds useful for when the element does not exist yet when the tour starts. But the actual reason when add it here, is to allow selecting the first cell. - if the selector yields undefined (missing element), the popup is simply skipped - got rid of the internal registry to link between popup contents and popup options. All is now define in the same interface. Registry overall felt overkill and not needed. - adds an option to show message as a simple modal that is centered on the screen This diff also brings the new welcome tour and hide it behind a flag CAVEATS that need to be addressed in follow up commit: - The url needs cleanup, #repeat-welcome-tour sticks to it and so even when navigating to home page. This could eventually become an issue: if user opens another document it would starts the onboarding tour again. - For now you have to manually make sure the right panel is opened with the Column tab selected before starting the tour. - On boarding tours were not designed with mobile support in mind. So probably a good idea to disable. - Backend support needs to be done (persistence of first time user). Test Plan: Updated `projects/OnBoardingPopup` and adds new `nbrowser/welcomeTour` To launch the tour: - open any document - open manually the right panel and the field tab - append the flag `#repeat-welcome-tour` at the end of the url in the url bar and reload the page Reviewers: dsagal Reviewed By: dsagal Differential Revision: https://phab.getgrist.com/D2917
369 lines
16 KiB
TypeScript
369 lines
16 KiB
TypeScript
import {GristDoc} from 'app/client/components/GristDoc';
|
|
import {IUndoState} from 'app/client/components/UndoStack';
|
|
import {loadGristDoc} from 'app/client/lib/imports';
|
|
import {AppModel, getOrgNameOrGuest, reportError} from 'app/client/models/AppModel';
|
|
import {getDoc} from 'app/client/models/gristConfigCache';
|
|
import {docUrl, urlState} from 'app/client/models/gristUrlState';
|
|
import {addNewButton, cssAddNewButton} from 'app/client/ui/AddNewButton';
|
|
import {App} from 'app/client/ui/App';
|
|
import {cssLeftPanel, cssScrollPane} from 'app/client/ui/LeftPanelCommon';
|
|
import {buildPagesDom} from 'app/client/ui/Pages';
|
|
import {openPageWidgetPicker} from 'app/client/ui/PageWidgetPicker';
|
|
import {tools} from 'app/client/ui/Tools';
|
|
import {testId} from 'app/client/ui2018/cssVars';
|
|
import {bigBasicButton} from 'app/client/ui2018/buttons';
|
|
import {menu, menuDivider, menuIcon, menuItem, menuText} from 'app/client/ui2018/menus';
|
|
import {confirmModal} from 'app/client/ui2018/modals';
|
|
import {AsyncFlow, CancelledError, FlowRunner} from 'app/common/AsyncFlow';
|
|
import {delay} from 'app/common/delay';
|
|
import {OpenDocMode, UserOverride} from 'app/common/DocListAPI';
|
|
import {IGristUrlState, parseUrlId, UrlIdParts} from 'app/common/gristUrls';
|
|
import {getReconnectTimeout} from 'app/common/gutil';
|
|
import {canEdit} from 'app/common/roles';
|
|
import {Document, NEW_DOCUMENT_CODE, Organization, UserAPI, Workspace} from 'app/common/UserAPI';
|
|
import {Computed, Disposable, dom, DomArg, DomElementArg} from 'grainjs';
|
|
import {Holder, Observable, subscribe} from 'grainjs';
|
|
|
|
// tslint:disable:no-console
|
|
|
|
export interface DocInfo extends Document {
|
|
isReadonly: boolean;
|
|
isSample: boolean;
|
|
isPreFork: boolean;
|
|
isFork: boolean;
|
|
isRecoveryMode: boolean;
|
|
userOverride: UserOverride|null;
|
|
isBareFork: boolean; // a document created without logging in, which is treated as a
|
|
// fork without an original.
|
|
idParts: UrlIdParts;
|
|
openMode: OpenDocMode;
|
|
}
|
|
|
|
export interface DocPageModel {
|
|
pageType: "doc";
|
|
|
|
appModel: AppModel;
|
|
currentDoc: Observable<DocInfo|null>;
|
|
|
|
// This block is to satisfy previous interface, but usable as this.currentDoc.get().id, etc.
|
|
currentDocId: Observable<string|undefined>;
|
|
currentWorkspace: Observable<Workspace|null>;
|
|
// We may be given information about the org, because of our access to the doc, that
|
|
// we can't get otherwise.
|
|
currentOrg: Observable<Organization|null>;
|
|
currentOrgName: Observable<string>;
|
|
currentDocTitle: Observable<string>;
|
|
isReadonly: Observable<boolean>;
|
|
isPrefork: Observable<boolean>;
|
|
isFork: Observable<boolean>;
|
|
isRecoveryMode: Observable<boolean>;
|
|
userOverride: Observable<UserOverride|null>;
|
|
isBareFork: Observable<boolean>;
|
|
isSample: Observable<boolean>;
|
|
|
|
importSources: ImportSource[];
|
|
|
|
undoState: Observable<IUndoState|null>; // See UndoStack for details.
|
|
|
|
gristDoc: Observable<GristDoc|null>; // Instance of GristDoc once it exists.
|
|
|
|
createLeftPane(leftPanelOpen: Observable<boolean>): DomArg;
|
|
renameDoc(value: string): Promise<void>;
|
|
updateCurrentDoc(urlId: string, openMode: OpenDocMode): Promise<Document>;
|
|
refreshCurrentDoc(doc: DocInfo): Promise<Document>;
|
|
}
|
|
|
|
export interface ImportSource {
|
|
label: string;
|
|
action: () => void;
|
|
}
|
|
|
|
|
|
export class DocPageModelImpl extends Disposable implements DocPageModel {
|
|
public readonly pageType = "doc";
|
|
|
|
public readonly currentDoc = Observable.create<DocInfo|null>(this, null);
|
|
|
|
public readonly currentUrlId = Computed.create(this, this.currentDoc, (use, doc) => doc ? doc.urlId : undefined);
|
|
public readonly currentDocId = Computed.create(this, this.currentDoc, (use, doc) => doc ? doc.id : undefined);
|
|
public readonly currentWorkspace = Computed.create(this, this.currentDoc, (use, doc) => doc && doc.workspace);
|
|
public readonly currentOrg = Computed.create(this, this.currentWorkspace, (use, ws) => ws && ws.org);
|
|
public readonly currentOrgName = Computed.create(this, this.currentOrg,
|
|
(use, org) => getOrgNameOrGuest(org, this.appModel.currentUser));
|
|
public readonly currentDocTitle = Computed.create(this, this.currentDoc, (use, doc) => doc ? doc.name : '');
|
|
public readonly isReadonly = Computed.create(this, this.currentDoc, (use, doc) => doc ? doc.isReadonly : false);
|
|
public readonly isPrefork = Computed.create(this, this.currentDoc, (use, doc) => doc ? doc.isPreFork : false);
|
|
public readonly isFork = Computed.create(this, this.currentDoc, (use, doc) => doc ? doc.isFork : false);
|
|
public readonly isRecoveryMode = Computed.create(this, this.currentDoc,
|
|
(use, doc) => doc ? doc.isRecoveryMode : false);
|
|
public readonly userOverride = Computed.create(this, this.currentDoc, (use, doc) => doc ? doc.userOverride : null);
|
|
public readonly isBareFork = Computed.create(this, this.currentDoc, (use, doc) => doc ? doc.isBareFork : false);
|
|
public readonly isSample = Computed.create(this, this.currentDoc, (use, doc) => doc ? doc.isSample : false);
|
|
|
|
public readonly importSources: ImportSource[] = [];
|
|
|
|
// Contains observables indicating whether undo/redo are disabled. See UndoStack for details.
|
|
public readonly undoState: Observable<IUndoState|null> = Observable.create(this, null);
|
|
|
|
// Observable set to the instance of GristDoc once it's created.
|
|
public readonly gristDoc = Observable.create<GristDoc|null>(this, null);
|
|
|
|
// Combination of arguments needed to open a doc (docOrUrlId + openMod). It's obtained from the
|
|
// URL, and when it changes, we need to re-open.
|
|
// If making a comparison, the id of the document we are comparing with is also included
|
|
// in the openerDocKey.
|
|
private _openerDocKey: string = "";
|
|
|
|
// Holds a FlowRunner for _openDoc, which is essentially a cancellable promise. It gets replaced
|
|
// (with the previous promise cancelled) when _openerDocKey changes.
|
|
private _openerHolder = Holder.create<FlowRunner>(this);
|
|
|
|
constructor(private _appObj: App, public readonly appModel: AppModel, private _api: UserAPI = appModel.api) {
|
|
super();
|
|
|
|
this.autoDispose(subscribe(urlState().state, (use, state) => {
|
|
const urlId = state.doc;
|
|
const urlOpenMode = state.mode || 'default';
|
|
const linkParameters = state.params?.linkParameters;
|
|
const docKey = this._getDocKey(state);
|
|
if (docKey !== this._openerDocKey) {
|
|
this._openerDocKey = docKey;
|
|
this.gristDoc.set(null);
|
|
this.currentDoc.set(null);
|
|
this.undoState.set(null);
|
|
if (!urlId) {
|
|
this._openerHolder.clear();
|
|
} else {
|
|
FlowRunner.create(this._openerHolder,
|
|
(flow: AsyncFlow) => this._openDoc(flow, urlId, urlOpenMode, state.params?.compare, linkParameters)
|
|
)
|
|
.resultPromise.catch(err => this._onOpenError(err));
|
|
}
|
|
}
|
|
}));
|
|
}
|
|
|
|
public createLeftPane(leftPanelOpen: Observable<boolean>) {
|
|
return cssLeftPanel(
|
|
dom.maybe(this.gristDoc, (activeDoc) => [
|
|
addNewButton(leftPanelOpen,
|
|
menu(() => addMenu(this.importSources, activeDoc, this.isReadonly.get()), {
|
|
placement: 'bottom-start',
|
|
// "Add New" menu should have the same width as the "Add New" button that opens it.
|
|
stretchToSelector: `.${cssAddNewButton.className}`
|
|
}),
|
|
testId('dp-add-new'),
|
|
dom.cls('tour-add-new'),
|
|
),
|
|
cssScrollPane(
|
|
dom.create(buildPagesDom, activeDoc, leftPanelOpen),
|
|
dom.create(tools, activeDoc, leftPanelOpen),
|
|
)
|
|
]),
|
|
);
|
|
}
|
|
|
|
public async renameDoc(value: string): Promise<void> {
|
|
// The docId should never be unset when this option is available.
|
|
const doc = this.currentDoc.get();
|
|
if (doc) {
|
|
if (value.length > 0) {
|
|
await this._api.renameDoc(doc.id, value).catch(reportError);
|
|
const newDoc = await this.refreshCurrentDoc(doc);
|
|
// a "slug" component of the URL may change when the document name is changed.
|
|
await urlState().pushUrl({...urlState().state.get(), ...docUrl(newDoc)}, {replace: true, avoidReload: true});
|
|
} else {
|
|
// This error won't be shown to user (caught by editableLabel).
|
|
throw new Error(`doc name should not be empty`);
|
|
}
|
|
}
|
|
}
|
|
|
|
public async updateCurrentDoc(urlId: string, openMode: OpenDocMode) {
|
|
// TODO It would be bad if a new doc gets opened while this getDoc() is pending...
|
|
const newDoc = await getDoc(this._api, urlId);
|
|
this.currentDoc.set(buildDocInfo(newDoc, openMode));
|
|
return newDoc;
|
|
}
|
|
|
|
public async refreshCurrentDoc(doc: DocInfo) {
|
|
return this.updateCurrentDoc(doc.urlId || doc.id, doc.openMode);
|
|
}
|
|
|
|
// Replace the URL without reloading the doc.
|
|
public updateUrlNoReload(urlId: string, urlOpenMode: OpenDocMode, options: {replace: boolean}) {
|
|
const state = urlState().state.get();
|
|
const nextState = {...state, doc: urlId, mode: urlOpenMode === 'default' ? undefined : urlOpenMode};
|
|
// We preemptively update _openerDocKey so that the URL update doesn't trigger a reload.
|
|
this._openerDocKey = this._getDocKey(nextState);
|
|
return urlState().pushUrl(nextState, {avoidReload: true, ...options});
|
|
}
|
|
|
|
private _onOpenError(err: Error) {
|
|
if (err instanceof CancelledError) {
|
|
// This means that we started loading a new doc before the previous one finished loading.
|
|
console.log("DocPageModel _openDoc cancelled");
|
|
return;
|
|
}
|
|
// Expected errors (e.g. Access Denied) produce a separate error page. For unexpected errors,
|
|
// show a modal, and include a toast for the sake of the "Report error" link.
|
|
reportError(err);
|
|
const isOwner = this.currentDoc.get()?.access === 'owners';
|
|
confirmModal(
|
|
"Error opening document",
|
|
"Reload",
|
|
async () => window.location.reload(true),
|
|
isOwner ? `You can try reloading the document, or using recovery mode. ` +
|
|
`Recovery mode opens the document to be fully accessible to owners, and ` +
|
|
`inaccessible to others. ` +
|
|
`[${err.message}]` : err.message,
|
|
{ hideCancel: true,
|
|
extraButtons: isOwner ? bigBasicButton('Enter recovery mode', dom.on('click', async () => {
|
|
await this._api.getDocAPI(this.currentDocId.get()!).recover(true);
|
|
window.location.reload(true);
|
|
}), testId('modal-recovery-mode')) : null,
|
|
},
|
|
);
|
|
}
|
|
|
|
private async _openDoc(flow: AsyncFlow, urlId: string, urlOpenMode: OpenDocMode,
|
|
comparisonUrlId: string | undefined,
|
|
linkParameters: Record<string, string> | undefined): Promise<void> {
|
|
console.log(`DocPageModel _openDoc starting for ${urlId} (mode ${urlOpenMode})` +
|
|
(comparisonUrlId ? ` (compare ${comparisonUrlId})` : ''));
|
|
const gristDocModulePromise = loadGristDoc();
|
|
|
|
const docResponse = await retryOnNetworkError(flow, getDoc.bind(null, this._api, urlId));
|
|
const doc = buildDocInfo(docResponse, urlOpenMode);
|
|
flow.checkIfCancelled();
|
|
|
|
if (doc.urlId && doc.urlId !== urlId) {
|
|
// Replace the URL to reflect the canonical urlId.
|
|
await this.updateUrlNoReload(doc.urlId, doc.openMode, {replace: true});
|
|
}
|
|
|
|
this.currentDoc.set(doc);
|
|
|
|
// Maintain a connection to doc-worker while opening a document. After it's opened, the DocComm
|
|
// object created by GristDoc will maintain the connection.
|
|
const comm = this._appObj.comm;
|
|
comm.useDocConnection(doc.id);
|
|
flow.onDispose(() => comm.releaseDocConnection(doc.id));
|
|
|
|
const openDocResponse = await comm.openDoc(doc.id, doc.openMode, linkParameters);
|
|
if (openDocResponse.recoveryMode || openDocResponse.userOverride) {
|
|
doc.isRecoveryMode = Boolean(openDocResponse.recoveryMode);
|
|
doc.userOverride = openDocResponse.userOverride || null;
|
|
this.currentDoc.set({...doc});
|
|
}
|
|
const gdModule = await gristDocModulePromise;
|
|
const docComm = gdModule.DocComm.create(flow, comm, openDocResponse, doc.id, this.appModel.notifier);
|
|
flow.checkIfCancelled();
|
|
|
|
docComm.changeUrlIdEmitter.addListener(async (newUrlId: string) => {
|
|
// The current document has been forked, and should now be referred to using a new docId.
|
|
const currentDoc = this.currentDoc.get();
|
|
if (currentDoc) {
|
|
await this.updateUrlNoReload(newUrlId, 'default', {replace: false});
|
|
await this.updateCurrentDoc(newUrlId, 'default');
|
|
}
|
|
});
|
|
|
|
// If a document for comparison is given, load the comparison, and provide it to the Gristdoc.
|
|
const comparison = comparisonUrlId ?
|
|
await this._api.getDocAPI(urlId).compareDoc(comparisonUrlId, { detail: true }) : undefined;
|
|
|
|
const gristDoc = gdModule.GristDoc.create(flow, this._appObj, docComm, this, openDocResponse,
|
|
{comparison});
|
|
|
|
// Move ownership of docComm to GristDoc.
|
|
gristDoc.autoDispose(flow.release(docComm));
|
|
|
|
// Move ownership of GristDoc to its final owner.
|
|
this.gristDoc.autoDispose(flow.release(gristDoc));
|
|
}
|
|
|
|
private _getDocKey(state: IGristUrlState) {
|
|
const urlId = state.doc;
|
|
const urlOpenMode = state.mode || 'default';
|
|
const compareUrlId = state.params?.compare;
|
|
const docKey = `${urlOpenMode}:${urlId}:${compareUrlId}`;
|
|
return docKey;
|
|
}
|
|
}
|
|
|
|
|
|
function addMenu(importSources: ImportSource[], gristDoc: GristDoc, isReadonly: boolean): DomElementArg[] {
|
|
const selectBy = gristDoc.selectBy.bind(gristDoc);
|
|
return [
|
|
menuItem(
|
|
(elem) => openPageWidgetPicker(elem, gristDoc.docModel, (val) => gristDoc.addNewPage(val).catch(reportError),
|
|
{isNewPage: true, buttonLabel: 'Add Page'}),
|
|
menuIcon("Page"), "Add Page", testId('dp-add-new-page'),
|
|
dom.cls('disabled', isReadonly)
|
|
),
|
|
menuItem(
|
|
(elem) => openPageWidgetPicker(elem, gristDoc.docModel, (val) => gristDoc.addWidgetToPage(val).catch(reportError),
|
|
{isNewPage: false, selectBy}),
|
|
menuIcon("Widget"), "Add Widget to Page", testId('dp-add-widget-to-page'),
|
|
dom.cls('disabled', isReadonly)
|
|
),
|
|
menuItem(() => gristDoc.addEmptyTable().catch(reportError),
|
|
menuIcon("TypeTable"), "Add Empty Table", testId('dp-empty-table'),
|
|
dom.cls('disabled', isReadonly)
|
|
),
|
|
menuDivider(),
|
|
...importSources.map((importSource, i) =>
|
|
menuItem(importSource.action,
|
|
menuIcon('Import'),
|
|
importSource.label,
|
|
testId(`dp-import-option`),
|
|
dom.cls('disabled', isReadonly)
|
|
)
|
|
),
|
|
isReadonly ? menuText('You do not have edit access to this document') : null,
|
|
testId('dp-add-new-menu')
|
|
];
|
|
}
|
|
|
|
function buildDocInfo(doc: Document, mode: OpenDocMode): DocInfo {
|
|
const idParts = parseUrlId(doc.urlId || doc.id);
|
|
const isFork = Boolean(idParts.forkId || idParts.snapshotId);
|
|
const isSample = !isFork && Boolean(doc.workspace.isSupportWorkspace);
|
|
const openMode = isSample ? 'fork' : mode;
|
|
const isPreFork = (openMode === 'fork');
|
|
const isBareFork = isFork && idParts.trunkId === NEW_DOCUMENT_CODE;
|
|
const isEditable = canEdit(doc.access) || isPreFork;
|
|
return {
|
|
...doc,
|
|
isFork,
|
|
isRecoveryMode: false, // we don't know yet, will learn when doc is opened.
|
|
userOverride: null, // ditto.
|
|
isSample,
|
|
isPreFork,
|
|
isBareFork,
|
|
isReadonly: !isEditable,
|
|
idParts,
|
|
openMode,
|
|
};
|
|
}
|
|
|
|
const reconnectIntervals = [1000, 1000, 2000, 5000, 10000];
|
|
|
|
async function retryOnNetworkError<R>(flow: AsyncFlow, func: () => Promise<R>): Promise<R> {
|
|
for (let attempt = 0; ; attempt++) {
|
|
try {
|
|
return await func();
|
|
} catch (err) {
|
|
// fetch() promises that network errors are reported as TypeError. We'll accept NetworkError too.
|
|
if (err.name !== "TypeError" && err.name !== "NetworkError") {
|
|
throw err;
|
|
}
|
|
const reconnectTimeout = getReconnectTimeout(attempt, reconnectIntervals);
|
|
console.warn(`Call to ${func.name} failed, will retry in ${reconnectTimeout} ms`, err);
|
|
await delay(reconnectTimeout);
|
|
flow.checkIfCancelled();
|
|
}
|
|
}
|
|
}
|