From a77170c4bdc97586701d4a23ccfba86becd65551 Mon Sep 17 00:00:00 2001 From: George Gevoian Date: Wed, 26 Jul 2023 15:31:02 -0700 Subject: [PATCH] (core) Tweak navbar, breadcrumbs, and sign-in buttons Summary: The changes are intended to smooth over some sharp edges when a signed-out user is using Grist (particularly while on the templates site). Test Plan: Browser tests. Reviewers: paulfitz Reviewed By: paulfitz Differential Revision: https://phab.getgrist.com/D3957 --- README.md | 1 + app/client/models/AppModel.ts | 31 +++-- app/client/models/DocPageModel.ts | 28 +++-- app/client/models/HomeModel.ts | 12 +- app/client/ui/AccountPage.ts | 2 +- app/client/ui/AccountWidget.ts | 91 ++++++++++++--- app/client/ui/AppHeader.ts | 174 ++++++++++++++++++++++++----- app/client/ui/AppUI.ts | 4 +- app/client/ui/HomeLeftPane.ts | 6 +- app/client/ui/SupportGristPage.ts | 2 +- app/client/ui/TopBar.ts | 38 ++++--- app/client/ui/WelcomePage.ts | 2 +- app/client/ui/errorPages.ts | 2 +- app/client/ui2018/breadcrumbs.ts | 5 +- app/common/UserAPI.ts | 15 ++- app/common/gristUrls.ts | 4 + app/gen-server/ApiServer.ts | 13 ++- app/server/lib/ActiveDoc.ts | 7 +- app/server/lib/AppEndpoint.ts | 8 +- app/server/lib/sendAppPage.ts | 14 +++ test/nbrowser/DuplicateDocument.ts | 6 + test/nbrowser/Features.ts | 3 +- test/nbrowser/Fork.ts | 28 ++--- test/nbrowser/HomeIntro.ts | 1 + test/nbrowser/homeUtil.ts | 8 +- 25 files changed, 379 insertions(+), 126 deletions(-) diff --git a/README.md b/README.md index 4f579d7f..ca000a78 100644 --- a/README.md +++ b/README.md @@ -282,6 +282,7 @@ GRIST_SESSION_DOMAIN | if set, associates the cookie with the given domain - oth GRIST_SESSION_SECRET | a key used to encode sessions GRIST_FORCE_LOGIN | when set to 'true' disables anonymous access GRIST_SINGLE_ORG | set to an org "domain" to pin client to that org +GRIST_TEMPLATE_ORG | set to an org "domain" to show public docs from that org GRIST_HELP_CENTER | set the help center link ref GRIST_SUPPORT_ANON | if set to 'true', show UI for anonymous access (not shown by default) GRIST_SUPPORT_EMAIL | if set, give a user with the specified email support powers. The main extra power is the ability to share sites, workspaces, and docs with all users in a listed way. diff --git a/app/client/models/AppModel.ts b/app/client/models/AppModel.ts index bd4c20f3..abd2b44c 100644 --- a/app/client/models/AppModel.ts +++ b/app/client/models/AppModel.ts @@ -13,7 +13,7 @@ import {SupportGristNudge} from 'app/client/ui/SupportGristNudge'; import {attachCssThemeVars, prefersDarkModeObs} from 'app/client/ui2018/cssVars'; import {OrgUsageSummary} from 'app/common/DocUsage'; import {Features, isLegacyPlan, Product} from 'app/common/Features'; -import {GristLoadConfig} from 'app/common/gristUrls'; +import {GristLoadConfig, IGristUrlState} from 'app/common/gristUrls'; import {FullUser} from 'app/common/LoginSessionAPI'; import {LocalPlugin} from 'app/common/plugin'; import {DismissedPopup, DismissedReminder, UserPrefs} from 'app/common/Prefs'; @@ -23,7 +23,7 @@ import {getDefaultThemePrefs, Theme, ThemeAppearance, ThemeColors, ThemePrefs, ThemePrefsChecker} from 'app/common/ThemePrefs'; import {getThemeColors} from 'app/common/Themes'; import {getGristConfig} from 'app/common/urlUtils'; -import {getOrgName, Organization, OrgError, UserAPI, UserAPIImpl} from 'app/common/UserAPI'; +import {getOrgName, isTemplatesOrg, Organization, OrgError, UserAPI, UserAPIImpl} from 'app/common/UserAPI'; import {getUserPrefObs, getUserPrefsObs, markAsSeen, markAsUnSeen} from 'app/client/models/UserPrefs'; import {bundleChanges, Computed, Disposable, Observable, subscribe} from 'grainjs'; @@ -93,6 +93,7 @@ export interface AppModel { isPersonal: boolean; // Is it a personal site? isTeamSite: boolean; // Is it a team site? isLegacySite: boolean; // Is it a legacy site? + isTemplatesSite: boolean; // Is it the templates site? orgError?: OrgError; // If currentOrg is null, the error that caused it. lastVisitedOrgDomain: Observable; @@ -249,6 +250,7 @@ export class AppModelImpl extends Disposable implements AppModel { public readonly isPersonal = Boolean(this.currentOrg?.owner); public readonly isTeamSite = Boolean(this.currentOrg) && !this.isPersonal; public readonly isLegacySite = Boolean(this.currentProduct && isLegacyPlan(this.currentProduct.name)); + public readonly isTemplatesSite = isTemplatesOrg(this.currentOrg); public readonly userPrefsObs = getUserPrefsObs(this); public readonly themePrefs = getUserPrefObs(this.userPrefsObs, 'theme', { @@ -325,12 +327,8 @@ export class AppModelImpl extends Disposable implements AppModel { this.behavioralPromptsManager.reset(); }; - this.autoDispose(subscribe(urlState().state, async (_use, {doc, org}) => { - // Keep track of the last valid org domain the user visited, ignoring those - // with a document id in the URL. - if (!this.currentOrg || doc) { return; } - - this.lastVisitedOrgDomain.set(org ?? null); + this.autoDispose(subscribe(urlState().state, this.topAppModel.orgs, async (_use, s, orgs) => { + this._updateLastVisitedOrgDomain(s, orgs); })); } @@ -404,6 +402,23 @@ export class AppModelImpl extends Disposable implements AppModel { return computed; } + private _updateLastVisitedOrgDomain({doc, org}: IGristUrlState, availableOrgs: Organization[]) { + if ( + !org || + // Invalid or inaccessible sites shouldn't be counted as visited. + !this.currentOrg || + // Visits to a document shouldn't be counted either. + doc + ) { + return; + } + + // Only count sites that a user has access to (i.e. those listed in the Site Switcher). + if (!availableOrgs.some(({domain}) => domain === org)) { return; } + + this.lastVisitedOrgDomain.set(org); + } + /** * If the current user is a new user, record a sign-up event via Google Tag Manager. */ diff --git a/app/client/models/DocPageModel.ts b/app/client/models/DocPageModel.ts index 9a3474c3..74520afc 100644 --- a/app/client/models/DocPageModel.ts +++ b/app/client/models/DocPageModel.ts @@ -42,6 +42,7 @@ export interface DocInfo extends Document { isSnapshot: boolean; isTutorialTrunk: boolean; isTutorialFork: boolean; + isTemplate: boolean; idParts: UrlIdParts; openMode: OpenDocMode; } @@ -76,6 +77,7 @@ export interface DocPageModel { isSnapshot: Observable; isTutorialTrunk: Observable; isTutorialFork: Observable; + isTemplate: Observable; importSources: ImportSource[]; @@ -131,6 +133,8 @@ export class DocPageModelImpl extends Disposable implements DocPageModel { (use, doc) => doc ? doc.isTutorialTrunk : false); public readonly isTutorialFork = Computed.create(this, this.currentDoc, (use, doc) => doc ? doc.isTutorialFork : false); + public readonly isTemplate = Computed.create(this, this.currentDoc, + (use, doc) => doc ? doc.isTemplate : false); public readonly importSources: ImportSource[] = []; @@ -431,24 +435,33 @@ function addMenu(importSources: ImportSource[], gristDoc: GristDoc, isReadonly: function buildDocInfo(doc: Document, mode: OpenDocMode | undefined): DocInfo { const idParts = parseUrlId(doc.urlId || doc.id); const isFork = Boolean(idParts.forkId || idParts.snapshotId); + const isBareFork = isFork && idParts.trunkId === NEW_DOCUMENT_CODE; + const isSnapshot = Boolean(idParts.snapshotId); + const isTutorial = doc.type === 'tutorial'; + const isTutorialTrunk = isTutorial && !isFork && mode !== 'default'; + const isTutorialFork = isTutorial && isFork; let openMode = mode; if (!openMode) { - if (isFork) { - // Ignore the document 'openMode' setting if the doc is an unsaved fork. + if (isFork || isTutorialTrunk || isTutorialFork) { + // Tutorials (if no explicit /m/default mode is set) automatically get or + // create a fork on load, which then behaves as a document that is in default + // mode. Since the document's 'openMode' has no effect, don't bother trying + // to set it here, as it'll potentially be confusing for other code reading it. openMode = 'default'; + } else if (!isFork && doc.type === 'template') { + // Templates should always open in fork mode by default. + openMode = 'fork'; } else { // Try to use the document's 'openMode' if it's set. openMode = doc.options?.openMode ?? 'default'; } } - const isPreFork = (openMode === 'fork'); - const isBareFork = isFork && idParts.trunkId === NEW_DOCUMENT_CODE; - const isSnapshot = Boolean(idParts.snapshotId); - const isTutorialTrunk = !isFork && doc.type === 'tutorial' && mode !== 'default'; - const isTutorialFork = isFork && doc.type === 'tutorial'; + const isPreFork = openMode === 'fork'; + const isTemplate = doc.type === 'template' && (isFork || isPreFork); const isEditable = !isSnapshot && (canEdit(doc.access) || isPreFork); + return { ...doc, isFork, @@ -459,6 +472,7 @@ function buildDocInfo(doc: Document, mode: OpenDocMode | undefined): DocInfo { isSnapshot, isTutorialTrunk, isTutorialFork, + isTemplate, isReadonly: !isEditable, idParts, openMode, diff --git a/app/client/models/HomeModel.ts b/app/client/models/HomeModel.ts index 2ac3efdb..6c4f1633 100644 --- a/app/client/models/HomeModel.ts +++ b/app/client/models/HomeModel.ts @@ -11,6 +11,7 @@ import {IHomePage} from 'app/common/gristUrls'; import {isLongerThan} from 'app/common/gutil'; import {SortPref, UserOrgPrefs, ViewPref} from 'app/common/Prefs'; import * as roles from 'app/common/roles'; +import {getGristConfig} from 'app/common/urlUtils'; import {Document, Organization, Workspace} from 'app/common/UserAPI'; import {bundleChanges, Computed, Disposable, Observable, subscribe} from 'grainjs'; import moment from 'moment'; @@ -311,7 +312,9 @@ export class HomeModelImpl extends Disposable implements HomeModel, ViewSettings // now, but it is good to show names to highlight the possibility of adding more. const nonSupportWss = Array.isArray(wss) ? wss.filter(ws => !ws.isSupportWorkspace) : null; this.singleWorkspace.set( - !!nonSupportWss && nonSupportWss.length === 1 && _isSingleWorkspaceMode(this._app) + // The anon personal site always has 0 non-support workspaces. + nonSupportWss?.length === 0 || + nonSupportWss?.length === 1 && _isSingleWorkspaceMode(this._app) ); }); } @@ -357,6 +360,9 @@ export class HomeModelImpl extends Disposable implements HomeModel, ViewSettings * Only fetches featured (pinned) templates on the All Documents page. */ private async _maybeFetchTemplates(): Promise { + const {templateOrg} = getGristConfig(); + if (!templateOrg) { return null; } + const currentPage = this.currentPage.get(); const shouldFetchTemplates = ['all', 'templates'].includes(currentPage); if (!shouldFetchTemplates) { return null; } @@ -366,10 +372,10 @@ export class HomeModelImpl extends Disposable implements HomeModel, ViewSettings const onlyFeatured = currentPage === 'all'; templateWss = await this._app.api.getTemplates(onlyFeatured); } catch { - // If the org doesn't exist (404), return nothing and don't report error to user. - return null; + reportError('Failed to load templates'); } if (this.isDisposed()) { return null; } + for (const ws of templateWss) { for (const doc of ws.docs) { // Populate doc.workspace, which is used by DocMenu/PinnedDocs and diff --git a/app/client/ui/AccountPage.ts b/app/client/ui/AccountPage.ts index 452c6284..3e7b4809 100644 --- a/app/client/ui/AccountPage.ts +++ b/app/client/ui/AccountPage.ts @@ -47,7 +47,7 @@ export class AccountPage extends Disposable { panelWidth: Observable.create(this, 240), panelOpen, hideOpener: true, - header: dom.create(AppHeader, this._appModel.currentOrgName, this._appModel), + header: dom.create(AppHeader, this._appModel), content: leftPanelBasic(this._appModel, panelOpen), }, headerMain: this._buildHeaderMain(), diff --git a/app/client/ui/AccountWidget.ts b/app/client/ui/AccountWidget.ts index 70889a8e..955864d1 100644 --- a/app/client/ui/AccountWidget.ts +++ b/app/client/ui/AccountWidget.ts @@ -1,10 +1,10 @@ import {AppModel} from 'app/client/models/AppModel'; import {DocPageModel} from 'app/client/models/DocPageModel'; -import {getLoginOrSignupUrl, getLoginUrl, getLogoutUrl, urlState} from 'app/client/models/gristUrlState'; +import {getLoginOrSignupUrl, getLoginUrl, getLogoutUrl, getSignupUrl, urlState} from 'app/client/models/gristUrlState'; import {manageTeamUsers} from 'app/client/ui/OpenUserManager'; import {createUserImage} from 'app/client/ui/UserImage'; import * as viewport from 'app/client/ui/viewport'; -import {primaryButton} from 'app/client/ui2018/buttons'; +import {bigPrimaryButtonLink, primaryButtonLink} from 'app/client/ui2018/buttons'; import {mediaDeviceNotSmall, testId, theme, vars} from 'app/client/ui2018/cssVars'; import {icon} from 'app/client/ui2018/icons'; import {menu, menuDivider, menuItem, menuItemLink, menuSubHeader} from 'app/client/ui2018/menus'; @@ -20,8 +20,12 @@ import {getGristConfig} from 'app/common/urlUtils'; const t = makeT('AccountWidget'); /** - * Render the user-icon that opens the account menu. When no user is logged in, render a Sign-in - * button instead. + * Render the user-icon that opens the account menu. + * + * When no user is logged in, render "Sign In" and "Sign Up" buttons. + * + * When no user is logged in and a template document is open, render a "Use This Template" + * button. */ export class AccountWidget extends Disposable { constructor(private _appModel: AppModel, private _docPageModel?: DocPageModel) { @@ -30,20 +34,60 @@ export class AccountWidget extends Disposable { public buildDom() { return cssAccountWidget( - dom.domComputed(this._appModel.currentValidUser, (user) => - (user ? - cssUserIcon(createUserImage(user, 'medium', testId('user-icon')), - menu(() => this._makeAccountMenu(user), {placement: 'bottom-end'}), - ) : - cssSignInButton(t("Sign in"), icon('Collapse'), testId('user-signin'), - menu(() => this._makeAccountMenu(user), {placement: 'bottom-end'}), - ) - ) - ), + dom.domComputed(use => { + const isTemplate = Boolean(this._docPageModel && use(this._docPageModel.isTemplate)); + const user = this._appModel.currentValidUser; + if (!user && isTemplate) { + return this._buildUseThisTemplateButton(); + } else if (!user) { + return this._buildSignInAndSignUpButtons(); + } else { + return this._buildAccountMenuButton(user); + } + }), testId('dm-account'), ); } + private _buildAccountMenuButton(user: FullUser|null) { + return cssUserIcon( + createUserImage(user, 'medium', testId('user-icon')), + menu(() => this._makeAccountMenu(user), {placement: 'bottom-end'}), + ); + } + + private _buildSignInAndSignUpButtons() { + return [ + cssSigninButton(t('Sign In'), + cssSigninButton.cls('-secondary'), + dom.attr('href', use => { + // Keep the redirect param of the login URL fresh. + use(urlState().state); + return getLoginUrl(); + }), + testId('user-sign-in'), + ), + cssSigninButton(t('Sign Up'), + dom.attr('href', use => { + // Keep the redirect param of the signup URL fresh. + use(urlState().state); + return getSignupUrl(); + }), + testId('user-sign-up'), + ), + ]; + } + + 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(); + }), + testId('dm-account-use-this-template'), + ); + } /** * Renders the content of the account menu, with a list of available orgs, settings, and sign-out. @@ -187,6 +231,7 @@ export class AccountWidget extends Disposable { } const cssAccountWidget = styled('div', ` + display: flex; margin-right: 16px; white-space: nowrap; `); @@ -251,8 +296,22 @@ const cssSmallDeviceOnly = styled(menuItem, ` } `); -const cssSignInButton = styled(primaryButton, ` +const cssSigninButton = styled(bigPrimaryButtonLink, ` display: flex; + align-items: center; + font-weight: 700; + min-height: unset; + height: 36px; + padding: 8px 16px 8px 16px; + font-size: ${vars.mediumFontSize}; + + &-secondary, &-secondary:hover { + background-color: transparent; + border-color: transparent; + color: ${theme.text}; + } +`); + +const cssUseThisTemplateButton = styled(primaryButtonLink, ` margin: 8px; - gap: 4px; `); diff --git a/app/client/ui/AppHeader.ts b/app/client/ui/AppHeader.ts index c5a17ae5..fbcd9a39 100644 --- a/app/client/ui/AppHeader.ts +++ b/app/client/ui/AppHeader.ts @@ -4,14 +4,15 @@ import {cssLeftPane} from 'app/client/ui/PagePanels'; import {colors, testId, theme, vars} from 'app/client/ui2018/cssVars'; import * as version from 'app/common/version'; import {menu, menuItem, menuItemLink, menuSubHeader} from 'app/client/ui2018/menus'; -import {isTemplatesOrg, Organization} from 'app/common/UserAPI'; +import {commonUrls} from 'app/common/gristUrls'; +import {getOrgName, isTemplatesOrg, Organization} from 'app/common/UserAPI'; import {AppModel} from 'app/client/models/AppModel'; import {icon} from 'app/client/ui2018/icons'; import {DocPageModel} from 'app/client/models/DocPageModel'; import * as roles from 'app/common/roles'; import {manageTeamUsersApp} from 'app/client/ui/OpenUserManager'; import {maybeAddSiteSwitcherSection} from 'app/client/ui/SiteSwitcher'; -import {BindableValue, Disposable, dom, DomContents, styled} from 'grainjs'; +import {Computed, Disposable, dom, DomContents, styled} from 'grainjs'; import {makeT} from 'app/client/lib/localization'; import {getGristConfig} from 'app/common/urlUtils'; @@ -28,46 +29,95 @@ const productPills: {[name: string]: string|null} = { // Other plans are either personal, or grandfathered, or for testing. }; +interface AppLogoOrgNameAndLink { + name: string; + link: AppLogoLink; + org?: string; + href?: string; +} + +type AppLogoLink = AppLogoOrgDomain | AppLogoHref; + +interface AppLogoOrgDomain { + type: 'domain'; + domain: string; +} + +interface AppLogoHref { + type: 'href'; + href: string; +} + export class AppHeader extends Disposable { - constructor(private _orgName: BindableValue, private _appModel: AppModel, - private _docPageModel?: DocPageModel) { + private _currentOrg = this._appModel.currentOrg; + + /** + * The name and link of the site shown next to the logo. + * + * The last visited site is used, if known. Otherwise, the current site is used. + */ + private _appLogoOrg = Computed.create(this, (use) => { + const availableOrgs = use(this._appModel.topAppModel.orgs); + const currentOrgName = (this._appModel.currentOrgName || + (this._docPageModel && use(this._docPageModel.currentOrgName))) ?? ''; + const lastVisitedOrgDomain = use(this._appModel.lastVisitedOrgDomain); + return this._getAppLogoOrgNameAndLink({availableOrgs, currentOrgName, lastVisitedOrgDomain}); + }); + + private _appLogoOrgName = Computed.create(this, this._appLogoOrg, (_use, {name}) => name); + + private _appLogoOrgLink = Computed.create(this, this._appLogoOrg, (_use, {link}) => link); + + constructor(private _appModel: AppModel, private _docPageModel?: DocPageModel) { super(); } public buildDom() { const productFlavor = getTheme(this._appModel.topAppModel.productFlavor); - const currentOrg = this._appModel.currentOrg; - return cssAppHeader( cssAppHeader.cls('-widelogo', productFlavor.wideLogo || false), - // Show version when hovering over the application icon. - // Include gitcommit when known. Cast version.gitcommit since, depending - // on how Grist is compiled, tsc may believe it to be a constant and - // believe that testing it is unnecessary. - cssAppLogo( + dom.domComputed(this._appLogoOrgLink, orgLink => cssAppLogo( + // Show version when hovering over the application icon. + // Include gitcommit when known. Cast version.gitcommit since, depending + // on how Grist is compiled, tsc may believe it to be a constant and + // believe that testing it is unnecessary. {title: `Version ${version.version}` + ((version.gitcommit as string) !== 'unknown' ? ` (${version.gitcommit})` : '')}, - this._setHomePageUrl(), + this._setHomePageUrl(orgLink), testId('dm-logo') - ), - cssOrg( - cssOrgName(dom.text(this._orgName), testId('dm-orgname')), - productPill(currentOrg), - this._orgName && cssDropdownIcon('Dropdown'), + )), + this._buildOrgLinkOrMenu(), + ); + } + + private _buildOrgLinkOrMenu() { + const {currentValidUser, isPersonal, isTemplatesSite} = this._appModel; + if (!currentValidUser && (isPersonal || isTemplatesSite)) { + return cssOrgLink( + cssOrgName(dom.text(this._appLogoOrgName), testId('dm-orgname')), + {href: commonUrls.templates}, + testId('dm-org'), + ); + } else { + return cssOrg( + cssOrgName(dom.text(this._appLogoOrgName), testId('dm-orgname')), + productPill(this._currentOrg), + dom.maybe(this._appLogoOrgName, () => cssDropdownIcon('Dropdown')), menu(() => [ menuSubHeader( - this._appModel.isTeamSite ? t("Team Site") : t("Personal Site") - + (this._appModel.isLegacySite ? ` (${t("Legacy")})` : ''), + this._appModel.isPersonal + ? t("Personal Site") + (this._appModel.isLegacySite ? ` (${t("Legacy")})` : '') + : t("Team Site"), testId('orgmenu-title'), ), menuItemLink(urlState().setLinkUrl({}), t("Home Page"), testId('orgmenu-home-page')), // Show 'Organization Settings' when on a home page of a valid org. - (!this._docPageModel && currentOrg && !currentOrg.owner ? + (!this._docPageModel && this._currentOrg && !this._currentOrg.owner ? menuItem(() => manageTeamUsersApp(this._appModel), 'Manage Team', testId('orgmenu-manage-team'), - dom.cls('disabled', !roles.canEditAccess(currentOrg.access))) : + dom.cls('disabled', !roles.canEditAccess(this._currentOrg.access))) : // Don't show on doc pages, or for personal orgs. null), @@ -77,16 +127,15 @@ export class AppHeader extends Disposable { maybeAddSiteSwitcherSection(this._appModel), ], { placement: 'bottom-start' }), testId('dm-org'), - ), - ); + ); + } } - private _setHomePageUrl() { - const lastVisitedOrg = this._appModel.lastVisitedOrgDomain.get(); - if (lastVisitedOrg) { - return urlState().setLinkUrl({org: lastVisitedOrg}); + private _setHomePageUrl(link: AppLogoLink) { + if (link.type === 'href') { + return {href: link.href}; } else { - return {href: getWelcomeHomeUrl()}; + return urlState().setLinkUrl({org: link.domain}); } } @@ -123,6 +172,50 @@ export class AppHeader extends Disposable { return menuItemLink('Activation', urlState().setLinkUrl({activation: 'activation'})); } + + private _getAppLogoOrgNameAndLink(params: { + availableOrgs: Organization[], + currentOrgName: string, + lastVisitedOrgDomain: string|null, + }): AppLogoOrgNameAndLink { + const { + currentValidUser, + isPersonal, + isTemplatesSite, + } = this._appModel; + if (!currentValidUser && (isPersonal || isTemplatesSite)) { + // When signed out and not on a team site, link to the templates site. + return { + name: t('Grist Templates'), + link: { + type: 'href', + href: commonUrls.templates, + }, + }; + } + + const {availableOrgs, currentOrgName, lastVisitedOrgDomain} = params; + if (lastVisitedOrgDomain) { + const lastVisitedOrg = availableOrgs.find(({domain}) => domain === lastVisitedOrgDomain); + if (lastVisitedOrg) { + return { + name: getOrgName(lastVisitedOrg), + link: { + type: 'domain', + domain: lastVisitedOrgDomain, + }, + }; + } + } + + return { + name: currentOrgName ?? '', + link: { + type: 'href', + href: getWelcomeHomeUrl(), + }, + }; + } } export function productPill(org: Organization|null, options: {large?: boolean} = {}): DomContents { @@ -198,6 +291,31 @@ const cssOrg = styled('div', ` } `); +const cssOrgLink = styled('a', ` + display: none; + flex-grow: 1; + align-items: center; + max-width: calc(100% - 48px); + cursor: pointer; + height: 100%; + font-weight: 500; + color: ${theme.text}; + user-select: none; + + &, &:hover, &:focus { + text-decoration: none; + } + + &:hover { + color: ${theme.text}; + background-color: ${theme.hover}; + } + + .${cssLeftPane.className}-open & { + display: flex; + } +`); + const cssOrgName = styled('div', ` padding-left: 16px; padding-right: 8px; diff --git a/app/client/ui/AppUI.ts b/app/client/ui/AppUI.ts index a6f30e0c..25ce8a62 100644 --- a/app/client/ui/AppUI.ts +++ b/app/client/ui/AppUI.ts @@ -106,7 +106,7 @@ function pagePanelsHome(owner: IDisposableOwner, appModel: AppModel, app: App) { panelWidth: Observable.create(owner, 240), panelOpen: leftPanelOpen, hideOpener: true, - header: dom.create(AppHeader, appModel.currentOrgName, appModel), + header: dom.create(AppHeader, appModel), content: createHomeLeftPane(leftPanelOpen, pageModel), }, headerMain: createTopBarHome(appModel), @@ -153,7 +153,7 @@ function pagePanelsDoc(owner: IDisposableOwner, appModel: AppModel, appObj: App) leftPanel: { panelWidth: leftPanelWidth, panelOpen: leftPanelOpen, - header: dom.create(AppHeader, appModel.currentOrgName || pageModel.currentOrgName, appModel, pageModel), + header: dom.create(AppHeader, appModel, pageModel), content: pageModel.createLeftPane(leftPanelOpen), }, rightPanel: { diff --git a/app/client/ui/HomeLeftPane.ts b/app/client/ui/HomeLeftPane.ts index 398a907a..44de1c66 100644 --- a/app/client/ui/HomeLeftPane.ts +++ b/app/client/ui/HomeLeftPane.ts @@ -18,6 +18,7 @@ import {menu, menuIcon, menuItem, upgradableMenuItem, upgradeText} from 'app/cli import {confirmModal} from 'app/client/ui2018/modals'; import {commonUrls, isFeatureEnabled} from 'app/common/gristUrls'; import * as roles from 'app/common/roles'; +import {getGristConfig} from 'app/common/urlUtils'; import {Workspace} from 'app/common/UserAPI'; import {computed, dom, domComputed, DomElementArg, observable, Observable, styled} from 'grainjs'; import {createHelpTools, cssLeftPanel, cssScrollPane, @@ -28,6 +29,7 @@ const t = makeT('HomeLeftPane'); export function createHomeLeftPane(leftPanelOpen: Observable, home: HomeModel) { const creating = observable(false); const renaming = observable(null); + const isAnonymous = !home.app.currentValidUser; return cssContent( dom.autoDispose(creating), @@ -109,14 +111,14 @@ export function createHomeLeftPane(leftPanelOpen: Observable, home: Hom )), cssTools( cssPageEntry( - dom.show(isFeatureEnabled("templates")), + dom.show(isFeatureEnabled("templates") && Boolean(getGristConfig().templateOrg)), cssPageEntry.cls('-selected', (use) => use(home.currentPage) === "templates"), cssPageLink(cssPageIcon('Board'), cssLinkText(t("Examples & Templates")), urlState().setLinkUrl({homePage: "templates"}), testId('dm-templates-page'), ), ), - cssPageEntry( + isAnonymous ? null : cssPageEntry( cssPageEntry.cls('-selected', (use) => use(home.currentPage) === "trash"), cssPageLink(cssPageIcon('RemoveBig'), cssLinkText(t("Trash")), urlState().setLinkUrl({homePage: "trash"}), diff --git a/app/client/ui/SupportGristPage.ts b/app/client/ui/SupportGristPage.ts index a3b35536..25cb9d35 100644 --- a/app/client/ui/SupportGristPage.ts +++ b/app/client/ui/SupportGristPage.ts @@ -47,7 +47,7 @@ export class SupportGristPage extends Disposable { panelWidth: Observable.create(this, 240), panelOpen, hideOpener: true, - header: dom.create(AppHeader, this._appModel.currentOrgName, this._appModel), + header: dom.create(AppHeader, this._appModel), content: leftPanelBasic(this._appModel, panelOpen), }, headerMain: this._buildMainHeader(), diff --git a/app/client/ui/TopBar.ts b/app/client/ui/TopBar.ts index 3a222d98..59ee6481 100644 --- a/app/client/ui/TopBar.ts +++ b/app/client/ui/TopBar.ts @@ -24,6 +24,8 @@ import {Computed, dom, DomElementArg, makeTestId, MultiHolder, Observable, style const t = makeT('TopBar'); export function createTopBarHome(appModel: AppModel) { + const isAnonymous = !appModel.currentValidUser; + return [ cssFlexSpace(), appModel.supportGristNudge.showButton(), @@ -40,7 +42,7 @@ export function createTopBarHome(appModel: AppModel) { ), buildLanguageMenu(appModel), - buildNotifyMenuButton(appModel.notifier, appModel), + isAnonymous ? null : buildNotifyMenuButton(appModel.notifier, appModel), dom('div', dom.create(AccountWidget, appModel)), ]; } @@ -78,6 +80,8 @@ export function createTopBarDoc(owner: MultiHolder, appModel: AppModel, pageMode return !use(undoStack.isDisabled); }); + const isAnonymous = !pageModel.appModel.currentValidUser; + return [ // TODO Before gristDoc is loaded, we could show doc-name without the page. For now, we delay // showing of breadcrumbs until gristDoc is loaded. @@ -96,6 +100,8 @@ export function createTopBarDoc(owner: MultiHolder, appModel: AppModel, pageMode isFiddle: Computed.create(owner, (use) => use(pageModel.isPrefork)), isSnapshot: pageModel.isSnapshot, isPublic: Computed.create(owner, doc, (use, _doc) => Boolean(_doc && _doc.public)), + isTemplate: pageModel.isTemplate, + isAnonymous, }) ) ), @@ -121,23 +127,21 @@ export function createTopBarDoc(owner: MultiHolder, appModel: AppModel, pageMode const model = use(searchModelObs); return model && use(moduleObs)?.searchBar(model, makeTestId('test-tb-search-')); }), - - buildShareMenuButton(pageModel), - - dom.maybe(use => - ( - use(pageModel.gristDoc) - && !use(use(pageModel.gristDoc)!.isReadonly) - && use(COMMENTS()) + dom.maybe(use => !(use(pageModel.isTemplate) && isAnonymous), () => [ + buildShareMenuButton(pageModel), + dom.maybe(use => + ( + use(pageModel.gristDoc) + && !use(use(pageModel.gristDoc)!.isReadonly) + && use(COMMENTS()) + ), + () => buildShowDiscussionButton(pageModel)), + dom.update( + buildNotifyMenuButton(appModel.notifier, appModel), + cssHideForNarrowScreen.cls(''), ), - () => buildShowDiscussionButton(pageModel)), - - dom.update( - buildNotifyMenuButton(appModel.notifier, appModel), - cssHideForNarrowScreen.cls(''), - ), - - dom('div', dom.create(AccountWidget, appModel, pageModel)) + ]), + dom('div', dom.create(AccountWidget, appModel, pageModel)), ]; } diff --git a/app/client/ui/WelcomePage.ts b/app/client/ui/WelcomePage.ts index d67498fb..87dada3f 100644 --- a/app/client/ui/WelcomePage.ts +++ b/app/client/ui/WelcomePage.ts @@ -50,7 +50,7 @@ export class WelcomePage extends Disposable { panelWidth: Observable.create(this, 240), panelOpen: Observable.create(this, false), hideOpener: true, - header: dom.create(AppHeader, '', this._appModel), + header: dom.create(AppHeader, this._appModel), content: null, }, headerMain: [cssFlexSpace(), dom.create(AccountWidget, this._appModel)], diff --git a/app/client/ui/errorPages.ts b/app/client/ui/errorPages.ts index 0f989c88..06f09400 100644 --- a/app/client/ui/errorPages.ts +++ b/app/client/ui/errorPages.ts @@ -110,7 +110,7 @@ function pagePanelsError(appModel: AppModel, header: string, content: DomElement panelWidth: observable(240), panelOpen, hideOpener: true, - header: dom.create(AppHeader, appModel.currentOrgName, appModel), + header: dom.create(AppHeader, appModel), content: leftPanelBasic(appModel, panelOpen), }, headerMain: createTopBarHome(appModel), diff --git a/app/client/ui2018/breadcrumbs.ts b/app/client/ui2018/breadcrumbs.ts index ed82f6ad..ec98bd0a 100644 --- a/app/client/ui2018/breadcrumbs.ts +++ b/app/client/ui2018/breadcrumbs.ts @@ -100,10 +100,13 @@ export function docBreadcrumbs( isRecoveryMode: Observable, isSnapshot?: Observable, isPublic?: Observable, + isTemplate?: Observable, + isAnonymous?: boolean, } ): Element { + const shouldShowWorkspace = !(options.isTemplate && options.isAnonymous); return cssBreadcrumbs( - dom.domComputed<[boolean, PartialWorkspace|null]>( + !shouldShowWorkspace ? null : dom.domComputed<[boolean, PartialWorkspace|null]>( (use) => [use(options.isBareFork), use(workspace)], ([isBareFork, ws]) => { if (isBareFork || !ws) { return null; } diff --git a/app/common/UserAPI.ts b/app/common/UserAPI.ts index 3204e1b6..ca5cb41a 100644 --- a/app/common/UserAPI.ts +++ b/app/common/UserAPI.ts @@ -20,7 +20,7 @@ import { WebhookSummaryCollection, WebhookUpdate } from 'app/common/Triggers'; -import {addCurrentOrgToPath} from 'app/common/urlUtils'; +import {addCurrentOrgToPath, getGristConfig} from 'app/common/urlUtils'; import omitBy from 'lodash/omitBy'; @@ -92,11 +92,14 @@ export function getOrgName(org: Organization): string { } /** - * Returns whether the given org is the templates org, which contains the public templates. + * Returns whether the given org is the templates org, which contains the public + * templates and tutorials. */ -export function isTemplatesOrg(org: Organization): boolean { - // TODO: It would be nice to have a more robust way to detect the templates org. - return org.domain === 'templates' || org.domain === 'templates-s'; +export function isTemplatesOrg(org: {domain: Organization['domain']}|null): boolean { + if (!org) { return false; } + + const {templateOrg} = getGristConfig(); + return org.domain === templateOrg; } export type WorkspaceProperties = CommonProperties; @@ -117,7 +120,7 @@ export interface Workspace extends WorkspaceProperties { isSupportWorkspace?: boolean; } -export type DocumentType = 'tutorial'; +export type DocumentType = 'tutorial'|'template'; // Non-core options for a document. // "Non-core" means bundled into a single options column in the database. diff --git a/app/common/gristUrls.ts b/app/common/gristUrls.ts index 4357db89..2af7dae7 100644 --- a/app/common/gristUrls.ts +++ b/app/common/gristUrls.ts @@ -80,6 +80,7 @@ export const commonUrls = { plans: "https://www.getgrist.com/pricing", sproutsProgram: "https://www.getgrist.com/sprouts-program", contact: "https://www.getgrist.com/contact", + templates: 'https://www.getgrist.com/templates', community: 'https://community.getgrist.com', functions: 'https://support.getgrist.com/functions', formulaSheet: 'https://support.getgrist.com/formula-cheat-sheet', @@ -647,6 +648,9 @@ export interface GristLoadConfig { // The Grist deployment type (e.g. core, enterprise). deploymentType?: GristDeploymentType; + + // The org containing public templates and tutorials. + templateOrg?: string|null; } export const Features = StringUnion( diff --git a/app/gen-server/ApiServer.ts b/app/gen-server/ApiServer.ts index 99f30b7c..542f2bf3 100644 --- a/app/gen-server/ApiServer.ts +++ b/app/gen-server/ApiServer.ts @@ -14,17 +14,13 @@ import {RequestWithOrg} from 'app/server/lib/extractOrg'; import log from 'app/server/lib/log'; import {addPermit, clearSessionCacheIfNeeded, getDocScope, getScope, integerParam, isParameterOn, optStringParam, sendOkReply, sendReply, stringParam} from 'app/server/lib/requestUtils'; +import {getTemplateOrg} from 'app/server/lib/sendAppPage'; import {IWidgetRepository} from 'app/server/lib/WidgetRepository'; import {User} from './entity/User'; import {HomeDBManager, QueryResult, Scope} from './lib/HomeDBManager'; import {getCookieDomain} from 'app/server/lib/gristSessions'; -// Special public organization that contains examples and templates. -export const TEMPLATES_ORG_DOMAIN = process.env.GRIST_ID_PREFIX ? - `templates-${process.env.GRIST_ID_PREFIX}` : - 'templates'; - // exposed for testing purposes export const Deps = { apiKeyGenerator: () => crypto.randomBytes(20).toString('hex') @@ -247,10 +243,15 @@ export class ApiServer { // GET /api/templates/ // Get all templates (or only featured templates if `onlyFeatured` is set). this._app.get('/api/templates/', expressWrap(async (req, res) => { + const templateOrg = getTemplateOrg(); + if (!templateOrg) { + throw new ApiError('Template org is not configured', 500); + } + const onlyFeatured = isParameterOn(req.query.onlyFeatured); const query = await this._dbManager.getOrgWorkspaces( {...getScope(req), showOnlyPinned: onlyFeatured}, - TEMPLATES_ORG_DOMAIN + templateOrg ); return sendReply(req, res, query); })); diff --git a/app/server/lib/ActiveDoc.ts b/app/server/lib/ActiveDoc.ts index 83eb128c..9d30976e 100644 --- a/app/server/lib/ActiveDoc.ts +++ b/app/server/lib/ActiveDoc.ts @@ -79,7 +79,6 @@ import {Document as APIDocument, DocReplacementOptions, DocState, DocStateCompar import {convertFromColumn} from 'app/common/ValueConverter'; import {guessColInfo} from 'app/common/ValueGuesser'; import {parseUserAction} from 'app/common/ValueParser'; -import {TEMPLATES_ORG_DOMAIN} from 'app/gen-server/ApiServer'; import {Document} from 'app/gen-server/entity/Document'; import {ParseFileResult, ParseOptions} from 'app/plugin/FileParserAPI'; import {AccessTokenOptions, AccessTokenResult, GristDocAPI} from 'app/plugin/GristAPI'; @@ -98,6 +97,7 @@ import log from 'app/server/lib/log'; import {LogMethods} from "app/server/lib/LogMethods"; import {NullSandbox, UnavailableSandboxMethodError} from 'app/server/lib/NullSandbox'; import {DocRequests} from 'app/server/lib/Requests'; +import {getTemplateOrg} from 'app/server/lib/sendAppPage'; import {shortDesc} from 'app/server/lib/shortDesc'; import {TableMetadataLoader} from 'app/server/lib/TableMetadataLoader'; import {DocTriggers} from "app/server/lib/Triggers"; @@ -1402,8 +1402,9 @@ export class ActiveDoc extends EventEmitter { await dbManager.forkDoc(userId, doc, forkIds.forkId); - // TODO: Need a more precise way to identify a template. (This org now also has tutorials.) - const isTemplate = TEMPLATES_ORG_DOMAIN === doc.workspace.org.domain && doc.type !== 'tutorial'; + // TODO: Remove the right side once all template docs have their type set to "template". + const isTemplate = doc.type === 'template' || + (doc.workspace.org.domain === getTemplateOrg() && doc.type !== 'tutorial'); this.logTelemetryEvent(docSession, 'documentForked', { limited: { forkIdDigest: forkIds.forkId, diff --git a/app/server/lib/AppEndpoint.ts b/app/server/lib/AppEndpoint.ts index 2e06bf8a..a29ed2b8 100644 --- a/app/server/lib/AppEndpoint.ts +++ b/app/server/lib/AppEndpoint.ts @@ -12,7 +12,6 @@ import {removeTrailingSlash} from 'app/common/gutil'; import {LocalPlugin} from "app/common/plugin"; import {TELEMETRY_TEMPLATE_SIGNUP_COOKIE_NAME} from 'app/common/Telemetry'; import {Document as APIDocument} from 'app/common/UserAPI'; -import {TEMPLATES_ORG_DOMAIN} from 'app/gen-server/ApiServer'; import {Document} from "app/gen-server/entity/Document"; import {HomeDBManager} from 'app/gen-server/lib/HomeDBManager'; import {assertAccess, getTransitiveHeaders, getUserId, isAnonymousUser, @@ -24,7 +23,7 @@ import {getCookieDomain} from 'app/server/lib/gristSessions'; import {getAssignmentId} from 'app/server/lib/idUtils'; import log from 'app/server/lib/log'; import {adaptServerUrl, addOrgToPathIfNeeded, pruneAPIResult, trustOrigin} from 'app/server/lib/requestUtils'; -import {ISendAppPageOptions} from 'app/server/lib/sendAppPage'; +import {getTemplateOrg, ISendAppPageOptions} from 'app/server/lib/sendAppPage'; export interface AttachOptions { app: express.Application; // Express app to which to add endpoints @@ -304,8 +303,9 @@ export function attachAppEndpoint(options: AttachOptions): void { const isPublic = ((doc as unknown) as APIDocument).public ?? false; const isSnapshot = Boolean(parseUrlId(urlId).snapshotId); - // TODO: Need a more precise way to identify a template. (This org now also has tutorials.) - const isTemplate = TEMPLATES_ORG_DOMAIN === doc.workspace.org.domain && doc.type !== 'tutorial'; + // TODO: Remove the right side once all template docs have their type set to "template". + const isTemplate = doc.type === 'template' || + (doc.workspace.org.domain === getTemplateOrg() && doc.type !== 'tutorial'); if (isPublic || isTemplate) { gristServer.getTelemetry().logEvent('documentOpened', { limited: { diff --git a/app/server/lib/sendAppPage.ts b/app/server/lib/sendAppPage.ts index 46d50031..19336982 100644 --- a/app/server/lib/sendAppPage.ts +++ b/app/server/lib/sendAppPage.ts @@ -3,6 +3,7 @@ import {isAffirmative} from 'app/common/gutil'; import {getTagManagerSnippet} from 'app/common/tagManager'; import {Document} from 'app/common/UserAPI'; import {SUPPORT_EMAIL} from 'app/gen-server/lib/HomeDBManager'; +import {appSettings} from 'app/server/lib/AppSettings'; import {isAnonymousUser, isSingleUserMode, RequestWithLogin} from 'app/server/lib/Authorizer'; import {RequestWithOrg} from 'app/server/lib/extractOrg'; import {GristServer} from 'app/server/lib/GristServer'; @@ -78,6 +79,7 @@ export function makeGristConfig(options: MakeGristConfigOptons): GristLoadConfig userLocale: (req as RequestWithLogin | undefined)?.user?.options?.locale, telemetry: server?.getTelemetry().getTelemetryConfig(), deploymentType: server?.getDeploymentType(), + templateOrg: getTemplateOrg(), ...extra, }; } @@ -152,6 +154,18 @@ export function makeSendAppPage(opts: { }; } +export function getTemplateOrg() { + let org = appSettings.section('templates').flag('org').readString({ + envVar: 'GRIST_TEMPLATE_ORG', + }); + if (!org) { return null; } + + if (process.env.GRIST_ID_PREFIX) { + org += `-${process.env.GRIST_ID_PREFIX}`; + } + return org; +} + function shouldSupportAnon() { // Enable UI for anonymous access if a flag is explicitly set in the environment return process.env.GRIST_SUPPORT_ANON === "true"; diff --git a/test/nbrowser/DuplicateDocument.ts b/test/nbrowser/DuplicateDocument.ts index 362815a4..72bd1c82 100644 --- a/test/nbrowser/DuplicateDocument.ts +++ b/test/nbrowser/DuplicateDocument.ts @@ -139,6 +139,12 @@ describe("DuplicateDocument", function() { await gu.session().teamSite2.createHomeApi().updateOrgPermissions('current', {users: { [session2.email]: 'owners', }}); + + // Reset tracking of the last visited site. We seem to need this now to get consistent + // behavior across Jenkins and local test runs. (May have something to do with newer + // versions of chromedriver and headless Chrome.) + await driver.executeScript('window.sessionStorage.clear();'); + await session2.login(); await session2.loadDoc(`/doc/${urlId}`); diff --git a/test/nbrowser/Features.ts b/test/nbrowser/Features.ts index 9f394c8c..a2682716 100644 --- a/test/nbrowser/Features.ts +++ b/test/nbrowser/Features.ts @@ -5,7 +5,7 @@ import { EnvironmentSnapshot } from 'test/server/testUtils'; describe('Features', function () { this.timeout(20000); - setupTestSuite(); + setupTestSuite({samples: true}); let session: gu.Session; let oldEnv: EnvironmentSnapshot; @@ -21,6 +21,7 @@ describe('Features', function () { }); it('can be enabled with the GRIST_UI_FEATURES env variable', async function () { + process.env.GRIST_TEMPLATE_ORG = 'templates'; process.env.GRIST_UI_FEATURES = 'helpCenter,templates'; await server.restart(); await session.loadDocMenu('/'); diff --git a/test/nbrowser/Fork.ts b/test/nbrowser/Fork.ts index 7245cc64..1a5d1ed4 100644 --- a/test/nbrowser/Fork.ts +++ b/test/nbrowser/Fork.ts @@ -89,12 +89,12 @@ describe("Fork", function() { for (const mode of ['anonymous', 'logged in']) { for (const content of ['empty', 'imported']) { it(`can create an ${content} unsaved document when ${mode}`, async function() { - let name: string; + let visitedSites: string[]; if (mode === 'anonymous') { - name = '@Guest'; + visitedSites = ['Grist Templates']; await personal.anon.login(); } else { - name = `@${personal.name}`; + visitedSites = ['Test Grist', `@${personal.name}`]; await personal.login(); } const anonApi = personal.anon.createHomeApi(); @@ -106,8 +106,10 @@ describe("Fork", function() { await gu.dismissWelcomeTourIfNeeded(); // check that the tag is there assert.equal(await driver.find('.test-unsaved-tag').isPresent(), true); - // check that the org name area is showing the user (not @Support). - assert.equal(await driver.find('.test-dm-org').getText(), name); + // check that the org name area is showing one of the last visited sites. this is + // an imprecise check; doing an assert.equal instead is possible, but would require + // changing this test significantly. + assert.include(visitedSites, await driver.find('.test-dm-org').getText()); if (content === 'imported') { assert.equal(await gu.getCell({rowNum: 1, col: 0}).getText(), '999'); } else { @@ -331,13 +333,13 @@ describe("Fork", function() { // Check others without view access to trunk cannot see fork await team.user('user2').login(); await driver.get(forkUrl); - assert.equal(await driver.findWait('.test-dm-logo', 2000).isDisplayed(), true); - assert.match(await driver.find('.test-error-header').getText(), /Access denied/); + assert.match(await driver.findWait('.test-error-header', 2000).getText(), /Access denied/); + assert.equal(await driver.find('.test-dm-logo').isDisplayed(), true); await server.removeLogin(); await driver.get(forkUrl); - assert.equal(await driver.findWait('.test-dm-logo', 2000).isDisplayed(), true); - assert.match(await driver.find('.test-error-header').getText(), /Access denied/); + assert.match(await driver.findWait('.test-error-header', 2000).getText(), /Access denied/); + assert.equal(await driver.find('.test-dm-logo').isDisplayed(), true); }); it('fails to create forks with inconsistent user id', async function() { @@ -364,8 +366,8 @@ describe("Fork", function() { // new doc user2 has no access granted via the doc, or // workspace, or org). await altSession.loadDoc(`/doc/new~${forkId}~${userId}`, false); - assert.equal(await driver.findWait('.test-dm-logo', 2000).isDisplayed(), true); - assert.match(await driver.find('.test-error-header').getText(), /Access denied/); + assert.match(await driver.findWait('.test-error-header', 2000).getText(), /Access denied/); + assert.equal(await driver.find('.test-dm-logo').isDisplayed(), true); // Same, but as an anonymous user. const anonSession = await altSession.anon.login(); @@ -375,8 +377,8 @@ describe("Fork", function() { // A new doc cannot be created either (because of access mismatch). await altSession.loadDoc(`/doc/new~${forkId}~${userId}`, false); - assert.equal(await driver.findWait('.test-dm-logo', 2000).isDisplayed(), true); - assert.match(await driver.find('.test-error-header').getText(), /Access denied/); + assert.match(await driver.findWait('.test-error-header', 2000).getText(), /Access denied/); + assert.equal(await driver.find('.test-dm-logo').isDisplayed(), true); // Now as a user who *is* allowed to create the fork. // But doc forks cannot be casually created this way anymore, so it still doesn't work. diff --git a/test/nbrowser/HomeIntro.ts b/test/nbrowser/HomeIntro.ts index 21733a99..eb62c642 100644 --- a/test/nbrowser/HomeIntro.ts +++ b/test/nbrowser/HomeIntro.ts @@ -10,6 +10,7 @@ import {server, setupTestSuite} from 'test/nbrowser/testUtils'; describe('HomeIntro', function() { this.timeout(40000); setupTestSuite({samples: true}); + gu.withEnvironmentSnapshot({'GRIST_TEMPLATE_ORG': 'templates'}); describe("Anonymous on merged-org", function() { it('should show welcome for anonymous user', async function() { diff --git a/test/nbrowser/homeUtil.ts b/test/nbrowser/homeUtil.ts index 82da0369..e90826ad 100644 --- a/test/nbrowser/homeUtil.ts +++ b/test/nbrowser/homeUtil.ts @@ -116,9 +116,8 @@ export class HomeUtil { // When running against an external server, we log in through the Grist login page. await this.driver.get(this.server.getUrl(org, "")); if (!await this.isOnLoginPage()) { - // Explicitly click sign-in link if necessary. - await this.driver.findWait('.test-user-signin', 4000).click(); - await this.driver.findContentWait('.grist-floating-menu a', 'Sign in', 500).click(); + // Explicitly click Sign In button if necessary. + await this.driver.findWait('.test-user-sign-in', 4000).click(); } // Fill the login form (either test or Grist). @@ -382,8 +381,7 @@ export class HomeUtil { await this.deleteCurrentUser(); await this.removeLogin(org); await this.driver.get(this.server.getUrl(org, "")); - await this.driver.findWait('.test-user-signin', 4000).click(); - await this.driver.findContentWait('.grist-floating-menu a', 'Sign in', 500).click(); + await this.driver.findWait('.test-user-sign-in', 4000).click(); await this.checkLoginPage(); // Fill the login form (either test or Grist). if (await this.isOnTestLoginPage()) {