mirror of
https://github.com/gristlabs/grist-core.git
synced 2026-03-02 04:09:24 +00:00
(core) Add support for auto-copying docs on signup
Summary: The new "copyDoc" query parameter on the login page sets a short-lived cookie, which is then read when welcoming a new user to copy that document to their Home workspace, and redirect to it. Currently, only templates and bare forks set this parameter. A new API endpoint for copying a document to a workspace was also added. Test Plan: Browser tests. Reviewers: paulfitz Reviewed By: paulfitz Differential Revision: https://phab.getgrist.com/D3992
This commit is contained in:
@@ -1,5 +1,6 @@
|
||||
import {GristDoc} from 'app/client/components/GristDoc';
|
||||
import {IUndoState} from 'app/client/components/UndoStack';
|
||||
import {UnsavedChange} from 'app/client/components/UnsavedChanges';
|
||||
import {loadGristDoc} from 'app/client/lib/imports';
|
||||
import {AppModel, getOrgNameOrGuest, reportError} from 'app/client/models/AppModel';
|
||||
import {getDoc} from 'app/client/models/gristConfigCache';
|
||||
@@ -94,6 +95,7 @@ export interface DocPageModel {
|
||||
// the error that prompted the offer. If user is not owner, just flag that
|
||||
// document needs attention of an owner.
|
||||
offerRecovery(err: Error): void;
|
||||
clearUnsavedChanges(): void;
|
||||
}
|
||||
|
||||
export interface ImportSource {
|
||||
@@ -154,6 +156,15 @@ export class DocPageModelImpl extends Disposable implements DocPageModel {
|
||||
// (with the previous promise cancelled) when _openerDocKey changes.
|
||||
private _openerHolder = Holder.create<FlowRunner>(this);
|
||||
|
||||
private readonly _unsavedChangeHolder = Holder.create<UnsavedChange>(this);
|
||||
|
||||
private readonly _isUnsavedFork = Computed.create(this,
|
||||
this.isFork,
|
||||
this.isSnapshot,
|
||||
this.isTutorialFork,
|
||||
(use, isFork, isSnapshot, isTutorialFork) => isFork && !isSnapshot && !isTutorialFork
|
||||
);
|
||||
|
||||
constructor(private _appObj: App, public readonly appModel: AppModel, private _api: UserAPI = appModel.api) {
|
||||
super();
|
||||
|
||||
@@ -185,6 +196,14 @@ export class DocPageModelImpl extends Disposable implements DocPageModel {
|
||||
this.currentProduct.set(org?.billingAccount?.product ?? null);
|
||||
}
|
||||
}));
|
||||
|
||||
this.autoDispose(this._isUnsavedFork.addListener((isUnsavedFork) => {
|
||||
if (isUnsavedFork) {
|
||||
UnsavedChange.create(this._unsavedChangeHolder);
|
||||
} else {
|
||||
this._unsavedChangeHolder.clear();
|
||||
}
|
||||
}));
|
||||
}
|
||||
|
||||
public createLeftPane(leftPanelOpen: Observable<boolean>) {
|
||||
@@ -289,6 +308,10 @@ It also disables formulas. [{{error}}]", {error: err.message})
|
||||
);
|
||||
}
|
||||
|
||||
public clearUnsavedChanges(): void {
|
||||
this._unsavedChangeHolder.clear();
|
||||
}
|
||||
|
||||
private _onOpenError(err: Error) {
|
||||
if (err instanceof CancelledError) {
|
||||
// This means that we started loading a new doc before the previous one finished loading.
|
||||
|
||||
@@ -66,24 +66,30 @@ export function getMainOrgUrl(): string { return urlState().makeUrl({}); }
|
||||
// When on a document URL, returns the URL with just the doc ID, omitting other bits (like page).
|
||||
export function getCurrentDocUrl(): string { return urlState().makeUrl({docPage: undefined}); }
|
||||
|
||||
// Get url for the login page, which will then redirect to `nextUrl` (current page by default).
|
||||
export function getLoginUrl(nextUrl: string | null = _getCurrentUrl()): string {
|
||||
return _getLoginLogoutUrl('login', nextUrl);
|
||||
export interface GetLoginOrSignupUrlOptions {
|
||||
srcDocId?: string | null;
|
||||
/** Defaults to the current URL. */
|
||||
nextUrl?: string | null;
|
||||
}
|
||||
|
||||
// Get url for the signup page, which will then redirect to `nextUrl` (current page by default).
|
||||
export function getSignupUrl(nextUrl: string = _getCurrentUrl()): string {
|
||||
return _getLoginLogoutUrl('signup', nextUrl);
|
||||
// Get URL for the login page.
|
||||
export function getLoginUrl(options: GetLoginOrSignupUrlOptions = {}): string {
|
||||
return _getLoginLogoutUrl('login', options);
|
||||
}
|
||||
|
||||
// Get url for the logout page.
|
||||
// Get URL for the signup page.
|
||||
export function getSignupUrl(options: GetLoginOrSignupUrlOptions = {}): string {
|
||||
return _getLoginLogoutUrl('signup', options);
|
||||
}
|
||||
|
||||
// Get URL for the logout page.
|
||||
export function getLogoutUrl(): string {
|
||||
return _getLoginLogoutUrl('logout');
|
||||
}
|
||||
|
||||
// Get url for the signin page, which will then redirect to `nextUrl` (current page by default).
|
||||
export function getLoginOrSignupUrl(nextUrl: string = _getCurrentUrl()): string {
|
||||
return _getLoginLogoutUrl('signin', nextUrl);
|
||||
// Get URL for the signin page.
|
||||
export function getLoginOrSignupUrl(options: GetLoginOrSignupUrlOptions = {}): string {
|
||||
return _getLoginLogoutUrl('signin', options);
|
||||
}
|
||||
|
||||
export function getWelcomeHomeUrl() {
|
||||
@@ -100,9 +106,14 @@ function _getCurrentUrl(): string {
|
||||
return parseFirstUrlPart('o', pathname).path + search + hash;
|
||||
}
|
||||
|
||||
// Returns the URL for the given login page, with 'next' param optionally set.
|
||||
function _getLoginLogoutUrl(page: 'login'|'logout'|'signin'|'signup', nextUrl?: string | null): string {
|
||||
// Returns the URL for the given login page.
|
||||
function _getLoginLogoutUrl(
|
||||
page: 'login'|'logout'|'signin'|'signup',
|
||||
options: GetLoginOrSignupUrlOptions = {}
|
||||
): string {
|
||||
const {srcDocId, nextUrl = _getCurrentUrl()} = options;
|
||||
const startUrl = _buildUrl(page);
|
||||
if (srcDocId) { startUrl.searchParams.set('srcDocId', srcDocId); }
|
||||
if (nextUrl) { startUrl.searchParams.set('next', nextUrl); }
|
||||
return startUrl.href;
|
||||
}
|
||||
|
||||
@@ -81,10 +81,10 @@ export class AccountWidget extends Disposable {
|
||||
private _buildUseThisTemplateButton() {
|
||||
return cssUseThisTemplateButton(t('Use This Template'),
|
||||
dom.attr('href', use => {
|
||||
// Keep the redirect param of the login/signup URL fresh.
|
||||
use(urlState().state);
|
||||
return getLoginOrSignupUrl();
|
||||
const {doc: srcDocId} = use(urlState().state);
|
||||
return getLoginOrSignupUrl({srcDocId});
|
||||
}),
|
||||
dom.on('click', () => { this._docPageModel?.clearUnsavedChanges(); }),
|
||||
testId('dm-account-use-this-template'),
|
||||
);
|
||||
}
|
||||
@@ -109,7 +109,7 @@ export class AccountWidget extends Disposable {
|
||||
t("Toggle Mobile Mode"),
|
||||
cssCheckmark('Tick', dom.show(viewport.viewportEnabled)),
|
||||
testId('usermenu-toggle-mobile'),
|
||||
);
|
||||
);
|
||||
|
||||
if (!user) {
|
||||
return [
|
||||
|
||||
@@ -172,10 +172,9 @@ export class App extends DisposableWithEvents {
|
||||
G.window.addEventListener('beforeunload', (ev: BeforeUnloadEvent) => {
|
||||
if (unsavedChanges.haveUnsavedChanges()) {
|
||||
// Following https://developer.mozilla.org/en-US/docs/Web/API/Window/beforeunload_event
|
||||
const msg = 'You have some unsaved changes';
|
||||
ev.returnValue = msg;
|
||||
ev.returnValue = true;
|
||||
ev.preventDefault();
|
||||
return msg;
|
||||
return true;
|
||||
}
|
||||
this.dispose();
|
||||
});
|
||||
|
||||
@@ -5,8 +5,8 @@
|
||||
|
||||
import {makeT} from 'app/client/lib/localization';
|
||||
import {AppModel, reportError} from 'app/client/models/AppModel';
|
||||
import {DocPageModel} from "app/client/models/DocPageModel";
|
||||
import {getLoginOrSignupUrl, urlState} from 'app/client/models/gristUrlState';
|
||||
import {DocPageModel} from 'app/client/models/DocPageModel';
|
||||
import {urlState} from 'app/client/models/gristUrlState';
|
||||
import {getWorkspaceInfo, ownerName, workspaceName} from 'app/client/models/WorkspaceInfo';
|
||||
import {cssInput} from 'app/client/ui/cssInput';
|
||||
import {bigBasicButton, bigPrimaryButtonLink} from 'app/client/ui2018/buttons';
|
||||
@@ -14,16 +14,7 @@ import {cssRadioCheckboxOptions, labeledSquareCheckbox, radioCheckboxOption} fro
|
||||
import {testId, theme, vars} from 'app/client/ui2018/cssVars';
|
||||
import {loadingSpinner} from 'app/client/ui2018/loaders';
|
||||
import {select} from 'app/client/ui2018/menus';
|
||||
import {
|
||||
confirmModal,
|
||||
cssModalBody,
|
||||
cssModalButtons,
|
||||
cssModalTitle,
|
||||
cssModalWidth,
|
||||
modal,
|
||||
saveModal
|
||||
} from 'app/client/ui2018/modals';
|
||||
import {FullUser} from 'app/common/LoginSessionAPI';
|
||||
import {confirmModal, cssModalBody, cssModalButtons, cssModalTitle, modal, saveModal} from 'app/client/ui2018/modals';
|
||||
import * as roles from 'app/common/roles';
|
||||
import {Document, isTemplatesOrg, Organization, Workspace} from 'app/common/UserAPI';
|
||||
import {Computed, Disposable, dom, input, Observable, styled, subscribe} from 'grainjs';
|
||||
@@ -31,8 +22,9 @@ import sortBy = require('lodash/sortBy');
|
||||
|
||||
const t = makeT('MakeCopyMenu');
|
||||
|
||||
export async function replaceTrunkWithFork(user: FullUser|null, doc: Document, app: AppModel, origUrlId: string) {
|
||||
const trunkAccess = (await app.api.getDoc(origUrlId)).access;
|
||||
export async function replaceTrunkWithFork(doc: Document, pageModel: DocPageModel, origUrlId: string) {
|
||||
const {appModel} = pageModel;
|
||||
const trunkAccess = (await appModel.api.getDoc(origUrlId)).access;
|
||||
if (!roles.canEdit(trunkAccess)) {
|
||||
modal((ctl) => [
|
||||
cssModalBody(t("Replacing the original requires editing rights on the original document.")),
|
||||
@@ -42,7 +34,7 @@ export async function replaceTrunkWithFork(user: FullUser|null, doc: Document, a
|
||||
]);
|
||||
return;
|
||||
}
|
||||
const docApi = app.api.getDocAPI(origUrlId);
|
||||
const docApi = appModel.api.getDocAPI(origUrlId);
|
||||
const cmp = await docApi.compareDoc(doc.id);
|
||||
let titleText = t("Update Original");
|
||||
let buttonText = t("Update");
|
||||
@@ -64,6 +56,7 @@ not in this document. Those changes will be overwritten.")}`;
|
||||
async () => {
|
||||
try {
|
||||
await docApi.replace({sourceDocId: doc.id});
|
||||
pageModel.clearUnsavedChanges();
|
||||
await urlState().pushUrl({doc: origUrlId});
|
||||
} catch (e) {
|
||||
reportError(e); // For example: no write access on trunk.
|
||||
@@ -71,18 +64,6 @@ not in this document. Those changes will be overwritten.")}`;
|
||||
}, {explanation: warningText});
|
||||
}
|
||||
|
||||
// Show message in a modal with a `Sign up` button that redirects to the login page.
|
||||
function signupModal(message: string) {
|
||||
return modal((ctl) => [
|
||||
cssModalBody(message),
|
||||
cssModalButtons(
|
||||
bigPrimaryButtonLink(t("Sign up"), {href: getLoginOrSignupUrl(), target: '_blank'}, testId('modal-signup')),
|
||||
bigBasicButton(t("Cancel"), dom.on('click', () => ctl.close())),
|
||||
),
|
||||
cssModalWidth('normal'),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether we should offer user the option to copy this doc to other orgs.
|
||||
* We allow copying out of source org when the source org is a personal org, or user has owner
|
||||
@@ -104,12 +85,14 @@ function allowOtherOrgs(doc: Document, app: AppModel): boolean {
|
||||
/**
|
||||
* Ask user for the destination and new name, and make a copy of the doc using those.
|
||||
*/
|
||||
export async function makeCopy(doc: Document, app: AppModel, modalTitle: string): Promise<void> {
|
||||
if (!app.currentValidUser) {
|
||||
signupModal(t("To save your changes, please sign up, then reload this page."));
|
||||
return;
|
||||
}
|
||||
let orgs = allowOtherOrgs(doc, app) ? await app.api.getOrgs(true) : null;
|
||||
export async function makeCopy(options: {
|
||||
pageModel: DocPageModel,
|
||||
doc: Document,
|
||||
modalTitle: string,
|
||||
}): Promise<void> {
|
||||
const {pageModel, doc, modalTitle} = options;
|
||||
const {appModel} = pageModel;
|
||||
let orgs = allowOtherOrgs(doc, appModel) ? await appModel.api.getOrgs(true) : null;
|
||||
if (orgs) {
|
||||
// Don't show the templates org since it's selected by default, and
|
||||
// is not writable to.
|
||||
@@ -118,7 +101,7 @@ export async function makeCopy(doc: Document, app: AppModel, modalTitle: string)
|
||||
|
||||
// Show a dialog with a form to select destination.
|
||||
saveModal((ctl, owner) => {
|
||||
const saveCopyModal = SaveCopyModal.create(owner, doc, app, orgs);
|
||||
const saveCopyModal = SaveCopyModal.create(owner, {pageModel, doc, orgs});
|
||||
return {
|
||||
title: modalTitle,
|
||||
body: saveCopyModal.buildDom(),
|
||||
@@ -129,7 +112,17 @@ export async function makeCopy(doc: Document, app: AppModel, modalTitle: string)
|
||||
});
|
||||
}
|
||||
|
||||
interface SaveCopyModalParams {
|
||||
pageModel: DocPageModel;
|
||||
doc: Document;
|
||||
orgs: Organization[]|null;
|
||||
}
|
||||
|
||||
class SaveCopyModal extends Disposable {
|
||||
private _pageModel = this._params.pageModel;
|
||||
private _app = this._pageModel.appModel;
|
||||
private _doc = this._params.doc;
|
||||
private _orgs = this._params.orgs;
|
||||
private _workspaces = Observable.create<Workspace[]|null>(this, null);
|
||||
private _destName = Observable.create<string>(this, '');
|
||||
private _destOrg = Observable.create<Organization|null>(this, this._app.currentOrg);
|
||||
@@ -142,10 +135,10 @@ class SaveCopyModal extends Disposable {
|
||||
private _showWorkspaces = Computed.create(this, this._destOrg, (use, org) => Boolean(org && !org.owner));
|
||||
|
||||
// If orgs is non-null, then we show a selector for orgs.
|
||||
constructor(private _doc: Document, private _app: AppModel, private _orgs: Organization[]|null) {
|
||||
constructor(private _params: SaveCopyModalParams) {
|
||||
super();
|
||||
if (_doc.name !== 'Untitled') {
|
||||
this._destName.set(_doc.name + ' (copy)');
|
||||
if (this._doc.name !== 'Untitled') {
|
||||
this._destName.set(this._doc.name + ' (copy)');
|
||||
}
|
||||
if (this._orgs && this._app.currentOrg) {
|
||||
// Set _destOrg to an Organization object from _orgs array; there should be one equivalent
|
||||
@@ -163,12 +156,14 @@ class SaveCopyModal extends Disposable {
|
||||
if (!ws) { throw new Error(t("No destination workspace")); }
|
||||
const api = this._app.api;
|
||||
const org = this._destOrg.get();
|
||||
const docWorker = await api.getWorkerAPI('import');
|
||||
const destName = this._destName.get() + '.grist';
|
||||
const destName = this._destName.get();
|
||||
try {
|
||||
const uploadId = await docWorker.copyDoc(this._doc.id, this._asTemplate.get(), destName);
|
||||
const {id} = await docWorker.importDocToWorkspace(uploadId, ws.id);
|
||||
await urlState().pushUrl({org: org?.domain || undefined, doc: id, docPage: urlState().state.get().docPage});
|
||||
const doc = await api.copyDoc(this._doc.id, ws.id, {
|
||||
documentName: destName,
|
||||
asTemplate: this._asTemplate.get(),
|
||||
});
|
||||
this._pageModel.clearUnsavedChanges();
|
||||
await urlState().pushUrl({org: org?.domain || undefined, doc, docPage: urlState().state.get().docPage});
|
||||
} catch(err) {
|
||||
// Convert access denied errors to normal Error to make it consistent with other endpoints.
|
||||
// TODO: Should not allow to click this button when user doesn't have permissions.
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import {loadUserManager} from 'app/client/lib/imports';
|
||||
import {AppModel, reportError} from 'app/client/models/AppModel';
|
||||
import {DocInfo, DocPageModel} from 'app/client/models/DocPageModel';
|
||||
import {docUrl, urlState} from 'app/client/models/gristUrlState';
|
||||
import {docUrl, getLoginOrSignupUrl, urlState} from 'app/client/models/gristUrlState';
|
||||
import {GristTooltips} from 'app/client/ui/GristTooltips';
|
||||
import {downloadDocModal, makeCopy, replaceTrunkWithFork} from 'app/client/ui/MakeCopyMenu';
|
||||
import {sendToDrive} from 'app/client/ui/sendToDrive';
|
||||
@@ -34,20 +34,19 @@ export function buildShareMenuButton(pageModel: DocPageModel): DomContents {
|
||||
// to render its contents, but we handle by merely skipping such content if gristDoc is not yet
|
||||
// available (a user quick enough to open the menu in this state would have to re-open it).
|
||||
return dom.maybe(pageModel.currentDoc, (doc) => {
|
||||
const appModel = pageModel.appModel;
|
||||
const saveCopy = () => makeCopy(doc, appModel, t("Save Document")).catch(reportError);
|
||||
const saveCopy = () => handleSaveCopy({pageModel, doc, modalTitle: t("Save Document")});
|
||||
if (doc.isSnapshot) {
|
||||
const backToCurrent = () => urlState().pushUrl({doc: buildOriginalUrlId(doc.id, true)});
|
||||
return shareButton(t("Back to Current"), () => [
|
||||
menuManageUsers(doc, pageModel),
|
||||
menuSaveCopy(t("Save Copy"), doc, appModel),
|
||||
menuOriginal(doc, appModel, {isSnapshot: true}),
|
||||
menuSaveCopy({pageModel, doc, saveActionTitle: t("Save Copy")}),
|
||||
menuOriginal(doc, pageModel, {isSnapshot: true}),
|
||||
menuExports(doc, pageModel),
|
||||
], {buttonAction: backToCurrent});
|
||||
} else if (doc.isTutorialFork) {
|
||||
return shareButton(t("Save Copy"), () => [
|
||||
menuSaveCopy(t("Save Copy"), doc, appModel),
|
||||
menuOriginal(doc, appModel, {isTutorialFork: true}),
|
||||
menuSaveCopy({pageModel, doc, saveActionTitle: t("Save Copy")}),
|
||||
menuOriginal(doc, pageModel, {isTutorialFork: true}),
|
||||
menuExports(doc, pageModel),
|
||||
], {buttonAction: saveCopy});
|
||||
} else if (doc.isPreFork || doc.isBareFork) {
|
||||
@@ -55,7 +54,7 @@ export function buildShareMenuButton(pageModel: DocPageModel): DomContents {
|
||||
const saveActionTitle = doc.isBareFork ? t("Save Document") : t("Save Copy");
|
||||
return shareButton(saveActionTitle, () => [
|
||||
menuManageUsers(doc, pageModel),
|
||||
menuSaveCopy(saveActionTitle, doc, appModel),
|
||||
menuSaveCopy({pageModel, doc, saveActionTitle}),
|
||||
menuExports(doc, pageModel),
|
||||
], {buttonAction: saveCopy});
|
||||
} else if (doc.isFork) {
|
||||
@@ -66,22 +65,22 @@ export function buildShareMenuButton(pageModel: DocPageModel): DomContents {
|
||||
if (!roles.canEdit(doc.trunkAccess || null)) {
|
||||
return shareButton(t("Save Copy"), () => [
|
||||
menuManageUsers(doc, pageModel),
|
||||
menuSaveCopy(t("Save Copy"), doc, appModel),
|
||||
menuOriginal(doc, appModel),
|
||||
menuSaveCopy({pageModel, doc, saveActionTitle: t("Save Copy")}),
|
||||
menuOriginal(doc, pageModel),
|
||||
menuExports(doc, pageModel),
|
||||
], {buttonAction: saveCopy});
|
||||
} else {
|
||||
return shareButton(t("Unsaved"), () => [
|
||||
menuManageUsers(doc, pageModel),
|
||||
menuSaveCopy(t("Save Copy"), doc, appModel),
|
||||
menuOriginal(doc, appModel),
|
||||
menuSaveCopy({pageModel, doc, saveActionTitle: t("Save Copy")}),
|
||||
menuOriginal(doc, pageModel),
|
||||
menuExports(doc, pageModel),
|
||||
]);
|
||||
}
|
||||
} else {
|
||||
return shareButton(null, () => [
|
||||
menuManageUsers(doc, pageModel),
|
||||
menuSaveCopy(t("Duplicate Document"), doc, appModel),
|
||||
menuSaveCopy({pageModel, doc, saveActionTitle: t("Duplicate Document")}),
|
||||
menuWorkOnCopy(pageModel),
|
||||
menuExports(doc, pageModel),
|
||||
]);
|
||||
@@ -134,6 +133,22 @@ function shareButton(buttonText: string|null, menuCreateFunc: MenuCreateFunc,
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSaveCopy(options: {
|
||||
pageModel: DocPageModel,
|
||||
doc: Document,
|
||||
modalTitle: string,
|
||||
}) {
|
||||
const {pageModel} = options;
|
||||
const {appModel} = pageModel;
|
||||
if (!appModel.currentValidUser) {
|
||||
pageModel.clearUnsavedChanges();
|
||||
window.location.href = getLoginOrSignupUrl({srcDocId: urlState().state.get().doc});
|
||||
return;
|
||||
}
|
||||
|
||||
return makeCopy(options);
|
||||
}
|
||||
|
||||
// Renders "Manage Users" menu item.
|
||||
function menuManageUsers(doc: DocInfo, pageModel: DocPageModel) {
|
||||
return [
|
||||
@@ -163,7 +178,7 @@ interface MenuOriginalOptions {
|
||||
* setting the open mode in the URL to "/m/default" - if the menu item were to ever be included
|
||||
* again, it should likely be a shortcut to setting the open mode back to default.
|
||||
*/
|
||||
function menuOriginal(doc: Document, appModel: AppModel, options: MenuOriginalOptions = {}) {
|
||||
function menuOriginal(doc: Document, pageModel: DocPageModel, options: MenuOriginalOptions = {}) {
|
||||
const {isSnapshot = false, isTutorialFork = false} = options;
|
||||
const termToUse = isSnapshot ? t("Current Version") : t("Original");
|
||||
const origUrlId = buildOriginalUrlId(doc.id, isSnapshot);
|
||||
@@ -183,15 +198,17 @@ function menuOriginal(doc: Document, appModel: AppModel, options: MenuOriginalOp
|
||||
const comparingSnapshots: boolean = isSnapshot && Boolean(compareUrlId && parseUrlId(compareUrlId).snapshotId);
|
||||
|
||||
function replaceOriginal() {
|
||||
const user = appModel.currentValidUser;
|
||||
replaceTrunkWithFork(user, doc, appModel, origUrlId).catch(reportError);
|
||||
replaceTrunkWithFork(doc, pageModel, origUrlId).catch(reportError);
|
||||
}
|
||||
return [
|
||||
isTutorialFork ? null : cssMenuSplitLink({href: originalUrl},
|
||||
cssMenuSplitLinkText(t("Return to {{termToUse}}", {termToUse})), testId('return-to-original'),
|
||||
cssMenuIconLink({href: originalUrl, target: '_blank'}, testId('open-original'),
|
||||
cssMenuSplitLinkText(t("Return to {{termToUse}}", {termToUse})),
|
||||
cssMenuIconLink({href: originalUrl, target: '_blank'},
|
||||
cssMenuIcon('FieldLink'),
|
||||
)
|
||||
testId('open-original'),
|
||||
),
|
||||
dom.on('click', () => { pageModel.clearUnsavedChanges(); }),
|
||||
testId('return-to-original'),
|
||||
),
|
||||
menuItem(replaceOriginal, t("Replace {{termToUse}}...", {termToUse}),
|
||||
// Disable if original is not writable, and also when comparing snapshots (since it's
|
||||
@@ -201,6 +218,7 @@ function menuOriginal(doc: Document, appModel: AppModel, options: MenuOriginalOp
|
||||
),
|
||||
isTutorialFork ? null : menuItemLink(compareHref, {target: '_blank'}, t("Compare to {{termToUse}}", {termToUse}),
|
||||
menuAnnotate('Beta'),
|
||||
dom.on('click', () => { pageModel.clearUnsavedChanges(); }),
|
||||
testId('compare-original'),
|
||||
),
|
||||
];
|
||||
@@ -208,8 +226,13 @@ function menuOriginal(doc: Document, appModel: AppModel, options: MenuOriginalOp
|
||||
|
||||
// Renders "Save Copy..." and "Copy as Template..." menu items. The name of the first action is
|
||||
// specified in saveActionTitle.
|
||||
function menuSaveCopy(saveActionTitle: string, doc: Document, appModel: AppModel) {
|
||||
const saveCopy = () => makeCopy(doc, appModel, saveActionTitle).catch(reportError);
|
||||
function menuSaveCopy(options: {
|
||||
pageModel: DocPageModel,
|
||||
doc: Document,
|
||||
saveActionTitle: string,
|
||||
}) {
|
||||
const {pageModel, doc, saveActionTitle} = options;
|
||||
const saveCopy = () => handleSaveCopy({pageModel, doc, modalTitle: saveActionTitle});
|
||||
return [
|
||||
// TODO Disable these when user has no accessible destinations.
|
||||
menuItem(saveCopy, `${saveActionTitle}...`, testId('save-copy')),
|
||||
|
||||
@@ -99,7 +99,7 @@ export class WelcomePage extends Disposable {
|
||||
`If you already have a Grist account as `,
|
||||
dom('b', email.get()),
|
||||
` you can just `,
|
||||
cssLink({href: getLoginUrl('')}, 'log in'),
|
||||
cssLink({href: getLoginUrl({nextUrl: null})}, 'log in'),
|
||||
` now. Otherwise, please pick a password.`
|
||||
),
|
||||
cssSeparatedLabel('The email address you activated Grist with:'),
|
||||
@@ -184,7 +184,7 @@ export class WelcomePage extends Disposable {
|
||||
'Apply verification code' : 'Resend verification email')
|
||||
),
|
||||
bigBasicButtonLink('More sign-up options',
|
||||
{href: getSignupUrl('')})
|
||||
{href: getSignupUrl({nextUrl: null})})
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user