(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
This commit is contained in:
George Gevoian
2023-07-26 15:31:02 -07:00
parent bc54a6646e
commit a77170c4bd
25 changed files with 380 additions and 127 deletions

View File

@@ -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(),

View File

@@ -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;
margin: 8px;
gap: 4px;
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;
`);

View File

@@ -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<string>, 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<AppLogoOrgNameAndLink>(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;

View File

@@ -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: {

View File

@@ -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<boolean>, home: HomeModel) {
const creating = observable<boolean>(false);
const renaming = observable<Workspace|null>(null);
const isAnonymous = !home.app.currentValidUser;
return cssContent(
dom.autoDispose(creating),
@@ -109,14 +111,14 @@ export function createHomeLeftPane(leftPanelOpen: Observable<boolean>, 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"}),

View File

@@ -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(),

View File

@@ -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)),
];
}

View File

@@ -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)],

View File

@@ -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),