(core) Site Switcher and Other Sites

Summary:
A new section, Other Sites, will now be shown on the All Documents page when:

 - A user is on a personal site and has access to other team sites.
 - A user is on a public site with view access only.

In addition, a site switcher is now available by clicking
the site name in the top-left section of the UI next to the
Grist logo. It works much like the switcher in the Account
menu.

Test Plan: Browser tests.

Reviewers: paulfitz

Reviewed By: paulfitz

Differential Revision: https://phab.getgrist.com/D2979
This commit is contained in:
George Gevoian 2021-08-18 10:49:34 -07:00
parent c561dad22d
commit d83d734b75
10 changed files with 218 additions and 74 deletions

View File

@ -8,7 +8,7 @@ import {GristLoadConfig} from 'app/common/gristUrls';
import {FullUser} from 'app/common/LoginSessionAPI';
import {LocalPlugin} from 'app/common/plugin';
import {getOrgName, Organization, OrgError, UserAPI, UserAPIImpl} from 'app/common/UserAPI';
import {Computed, Disposable, Observable, subscribe} from 'grainjs';
import {bundleChanges, Computed, Disposable, Observable, subscribe} from 'grainjs';
export {reportError} from 'app/client/models/errors';
@ -29,6 +29,9 @@ export interface TopAppModel {
// different parts of the code aren't using different users/orgs while the switch is pending.
appObs: Observable<AppModel|null>;
orgs: Observable<Organization[]>;
users: Observable<FullUser[]>;
// Reinitialize the app. This is called when org or user changes.
initialize(): void;
@ -68,6 +71,8 @@ export class TopAppModelImpl extends Disposable implements TopAppModel {
public readonly currentSubdomain = Computed.create(this, urlState().state, (use, s) => s.org);
public readonly notifier = Notifier.create(this);
public readonly appObs = Observable.create<AppModel|null>(this, null);
public readonly orgs = Observable.create<Organization[]>(this, []);
public readonly users = Observable.create<FullUser[]>(this, []);
public readonly plugins: LocalPlugin[] = [];
private readonly _gristConfig?: GristLoadConfig;
@ -85,6 +90,8 @@ export class TopAppModelImpl extends Disposable implements TopAppModel {
// and the FullUser to use for it (the user may change when switching orgs).
this.autoDispose(subscribe(this.currentSubdomain, (use) => this.initialize()));
this.plugins = this._gristConfig?.plugins || [];
this._fetchUsersAndOrgs().catch(reportError);
}
public initialize(): void {
@ -146,6 +153,15 @@ export class TopAppModelImpl extends Disposable implements TopAppModel {
AppModelImpl.create(this.appObs, this, null, null, {error: err.message, status: err.status || 500});
}
}
private async _fetchUsersAndOrgs() {
const data = await this.api.getSessionAll();
if (this.isDisposed()) { return; }
bundleChanges(() => {
this.users.set(data.users);
this.orgs.set(data.orgs);
});
}
}
export class AppModelImpl extends Disposable implements AppModel {

View File

@ -11,7 +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 {Document, Workspace} from 'app/common/UserAPI';
import {Document, Organization, Workspace} from 'app/common/UserAPI';
import {bundleChanges, Computed, Disposable, Observable, subscribe} from 'grainjs';
import * as moment from 'moment';
import flatten = require('lodash/flatten');
@ -63,6 +63,10 @@ export interface HomeModel {
// List of featured templates from templateWorkspaces.
featuredTemplates: Observable<Document[]>;
// List of other sites (orgs) user can access. Only populated on All Documents, and only when
// the current org is a personal org, or the current org is view access only.
otherSites: Observable<Organization[]>;
currentSort: Observable<SortPref>;
currentView: Observable<ViewPref>;
importSources: Observable<ImportSourceElement[]>;
@ -118,6 +122,21 @@ export class HomeModelImpl extends Disposable implements HomeModel, ViewSettings
return sortBy(featuredTemplates, (t) => t.name.toLowerCase());
});
public readonly otherSites = Computed.create(this, this.currentPage, this.app.topAppModel.orgs,
(_use, page, orgs) => {
if (page !== 'all') { return []; }
const currentOrg = this._app.currentOrg;
if (!currentOrg) { return []; }
const isPersonalOrg = currentOrg.owner;
if (!isPersonalOrg && (currentOrg.access !== 'viewers' || !currentOrg.public)) {
return [];
}
return orgs.filter(org => org.id !== currentOrg.id);
});
public readonly currentSort: Observable<SortPref>;
public readonly currentView: Observable<ViewPref>;
@ -255,16 +274,10 @@ export class HomeModelImpl extends Disposable implements HomeModel, ViewSettings
this.loading.set(true);
const currentPage = this.currentPage.get();
const promises = [
this._fetchWorkspaces(org.id, false).catch(reportError), // workspaces
currentPage === 'trash' ? this._fetchWorkspaces(org.id, true).catch(reportError) : null, // trash
null // templates
];
const shouldFetchTemplates = ['all', 'templates'].includes(currentPage);
if (shouldFetchTemplates) {
const onlyFeatured = currentPage === 'all';
promises[2] = this._fetchTemplates(onlyFeatured);
}
this._fetchWorkspaces(org.id, false).catch(reportError),
currentPage === 'trash' ? this._fetchWorkspaces(org.id, true).catch(reportError) : null,
this._maybeFetchTemplates(),
] as const;
const promise = Promise.all(promises);
if (await isLongerThan(promise, DELAY_BEFORE_SPINNER_MS)) {
@ -327,9 +340,19 @@ export class HomeModelImpl extends Disposable implements HomeModel, ViewSettings
ws.name.toLowerCase()]);
}
private async _fetchTemplates(onlyFeatured: boolean) {
/**
* Fetches templates if on the Templates or All Documents page.
*
* Only fetches featured (pinned) templates on the All Documents page.
*/
private async _maybeFetchTemplates(): Promise<Workspace[] | null> {
const currentPage = this.currentPage.get();
const shouldFetchTemplates = ['all', 'templates'].includes(currentPage);
if (!shouldFetchTemplates) { return null; }
let templateWss: Workspace[] = [];
try {
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.

View File

@ -1,5 +1,5 @@
import {loadGristDoc, loadUserManager} from 'app/client/lib/imports';
import {AppModel, reportError} from 'app/client/models/AppModel';
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 {showProfileModal} from 'app/client/ui/ProfileDialog';
@ -13,22 +13,17 @@ import {commonUrls} from 'app/common/gristUrls';
import {FullUser} from 'app/common/LoginSessionAPI';
import * as roles from 'app/common/roles';
import {getOrgName, Organization, SUPPORT_EMAIL} from 'app/common/UserAPI';
import {bundleChanges, Disposable, dom, DomElementArg, Observable, styled} from 'grainjs';
import {Disposable, dom, DomElementArg, styled} from 'grainjs';
import {cssMenuItem} from 'popweasel';
import {cssOrgCheckmark, cssOrgSelected} from 'app/client/ui/AppHeader';
/**
* Render the user-icon that opens the account menu. When no user is logged in, render a Sign-in
* button instead.
*/
export class AccountWidget extends Disposable {
private _users = Observable.create<FullUser[]>(this, []);
private _orgs = Observable.create<Organization[]>(this, []);
constructor(private _appModel: AppModel, private _docPageModel?: DocPageModel) {
super();
// We initialize users and orgs asynchronously when we create the menu, so it will *probably* be
// available by the time the user opens it. Even if not, we do not delay the opening of the menu.
this._fetchUsersAndOrgs().catch(reportError);
}
public buildDom() {
@ -47,16 +42,6 @@ export class AccountWidget extends Disposable {
);
}
private async _fetchUsersAndOrgs() {
if (!this._appModel.topAppModel.isSingleOrg) {
const data = await this._appModel.api.getSessionAll();
if (this.isDisposed()) { return; }
bundleChanges(() => {
this._users.set(data.users);
this._orgs.set(data.orgs);
});
}
}
/**
* Renders the content of the account menu, with a list of available orgs, settings, and sign-out.
@ -104,6 +89,9 @@ export class AccountWidget extends Disposable {
];
}
const users = this._appModel.topAppModel.users;
const orgs = this._appModel.topAppModel.orgs;
return [
cssUserInfo(
createUserImage(user, 'large'),
@ -141,8 +129,8 @@ export class AccountWidget extends Disposable {
// org-listing UI below.
this._appModel.topAppModel.isSingleOrg ? [] : [
menuDivider(),
menuSubHeader(dom.text((use) => use(this._users).length > 1 ? 'Switch Accounts' : 'Accounts')),
dom.forEach(this._users, (_user) => {
menuSubHeader(dom.text((use) => use(users).length > 1 ? 'Switch Accounts' : 'Accounts')),
dom.forEach(users, (_user) => {
if (_user.id === user.id) { return null; }
return menuItem(() => this._switchAccount(_user),
cssSmallIconWrap(createUserImage(_user, 'small')),
@ -154,11 +142,11 @@ export class AccountWidget extends Disposable {
menuItemLink({href: getLogoutUrl()}, "Sign Out", testId('dm-log-out')),
dom.maybe((use) => use(this._orgs).length > 0, () => [
dom.maybe((use) => use(orgs).length > 0, () => [
menuDivider(),
menuSubHeader('Switch Sites'),
]),
dom.forEach(this._orgs, (org) =>
dom.forEach(orgs, (org) =>
menuItemLink(urlState().setLinkUrl({org: org.domain || undefined}),
cssOrgSelected.cls('', this._appModel.currentOrg ? org.id === this._appModel.currentOrg.id : false),
getOrgName(org),
@ -231,21 +219,6 @@ const cssOtherEmail = styled('div', `
}
`);
const cssOrgSelected = styled('div', `
background-color: ${colors.dark};
color: ${colors.light};
`);
const cssOrgCheckmark = styled(icon, `
flex: none;
margin-left: 16px;
--icon-color: ${colors.light};
display: none;
.${cssOrgSelected.className} > & {
display: block;
}
`);
const cssCheckmark = styled(icon, `
flex: none;
margin-left: 16px;

View File

@ -1,23 +1,75 @@
import {urlState} from 'app/client/models/gristUrlState';
import {getTheme, ProductFlavor} from 'app/client/ui/CustomThemes';
import {getTheme} from 'app/client/ui/CustomThemes';
import {cssLeftPane} from 'app/client/ui/PagePanels';
import {colors, testId, vars} from 'app/client/ui2018/cssVars';
import * as version from 'app/common/version';
import {BindableValue, dom, styled} from "grainjs";
import {BindableValue, Disposable, dom, styled} from "grainjs";
import {menu, menuDivider, menuItemLink, menuSubHeader} from 'app/client/ui2018/menus';
import {getOrgName} from 'app/common/UserAPI';
import {AppModel} from 'app/client/models/AppModel';
import {icon} from 'app/client/ui2018/icons';
export function appHeader(orgName: BindableValue<string>, productFlavor: ProductFlavor) {
const theme = getTheme(productFlavor);
return cssAppHeader(
urlState().setLinkUrl({}),
cssAppHeader.cls('-widelogo', theme.wideLogo || false),
// Show version when hovering over the application icon.
cssAppLogo({title: `Ver ${version.version} (${version.gitcommit})`}),
cssOrgName(dom.text(orgName)),
testId('dm-org'),
);
export class AppHeader extends Disposable {
constructor(private _orgName: BindableValue<string>, private _appModel: AppModel) {
super();
}
public buildDom() {
const theme = getTheme(this._appModel.topAppModel.productFlavor);
return cssAppHeader(
cssAppHeader.cls('-widelogo', theme.wideLogo || false),
// Show version when hovering over the application icon.
cssAppLogo(
{title: `Ver ${version.version} (${version.gitcommit})`},
urlState().setLinkUrl({}),
testId('dm-logo')
),
cssOrg(
cssOrgName(dom.text(this._orgName)),
this._orgName && cssDropdownIcon('Dropdown'),
menu(() => this._makeOrgMenu(), {placement: 'bottom-start'}),
testId('dm-org'),
),
);
}
private _makeOrgMenu() {
const orgs = this._appModel.topAppModel.orgs;
return [
menuItemLink(urlState().setLinkUrl({}), 'Go to Home Page', testId('orgmenu-home-page')),
menuDivider(),
menuSubHeader('Switch Sites'),
dom.forEach(orgs, (org) =>
menuItemLink(urlState().setLinkUrl({org: org.domain || undefined}),
cssOrgSelected.cls('', this._appModel.currentOrg ? org.id === this._appModel.currentOrg.id : false),
getOrgName(org),
cssOrgCheckmark('Tick', testId('orgmenu-org-tick')),
testId('orgmenu-org'),
)
),
];
}
}
const cssAppHeader = styled('a', `
export const cssOrgSelected = styled('div', `
background-color: ${colors.dark};
color: ${colors.light};
`);
export const cssOrgCheckmark = styled(icon, `
flex: none;
margin-left: 16px;
--icon-color: ${colors.light};
display: none;
.${cssOrgSelected.className} > & {
display: block;
}
`);
const cssAppHeader = styled('div', `
display: flex;
width: 100%;
height: 100%;
@ -29,7 +81,7 @@ const cssAppHeader = styled('a', `
}
`);
const cssAppLogo = styled('div', `
const cssAppLogo = styled('a', `
flex: none;
height: 48px;
width: 48px;
@ -49,8 +101,23 @@ const cssAppLogo = styled('div', `
}
`);
const cssDropdownIcon = styled(icon, `
flex-shrink: 0;
margin-right: 8px;
`);
const cssOrg = styled('div', `
display: flex;
flex-grow: 1;
align-items: center;
max-width: calc(100% - 48px);
cursor: pointer;
height: 100%;
`);
const cssOrgName = styled('div', `
padding: 0px 16px;
padding-left: 16px;
padding-right: 8px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;

View File

@ -5,7 +5,7 @@ import {AppModel, TopAppModel} from 'app/client/models/AppModel';
import {DocPageModelImpl} from 'app/client/models/DocPageModel';
import {HomeModelImpl} from 'app/client/models/HomeModel';
import {App} from 'app/client/ui/App';
import {appHeader} from 'app/client/ui/AppHeader';
import {AppHeader} from 'app/client/ui/AppHeader';
import {createBottomBarDoc} from 'app/client/ui/BottomBar';
import {createDocMenu} from 'app/client/ui/DocMenu';
import {createForbiddenPage, createNotFoundPage, createOtherErrorPage} from 'app/client/ui/errorPages';
@ -96,7 +96,7 @@ function pagePanelsHome(owner: IDisposableOwner, appModel: AppModel, app: App) {
panelWidth: Observable.create(owner, 240),
panelOpen: leftPanelOpen,
hideOpener: true,
header: appHeader(appModel.currentOrgName, appModel.topAppModel.productFlavor),
header: dom.create(AppHeader, appModel.currentOrgName, appModel),
content: createHomeLeftPane(leftPanelOpen, pageModel),
},
headerMain: createTopBarHome(appModel),
@ -136,7 +136,7 @@ function pagePanelsDoc(owner: IDisposableOwner, appModel: AppModel, appObj: App)
leftPanel: {
panelWidth: leftPanelWidth,
panelOpen: leftPanelOpen,
header: appHeader(appModel.currentOrgName || pageModel.currentOrgName, appModel.topAppModel.productFlavor),
header: dom.create(AppHeader, appModel.currentOrgName || pageModel.currentOrgName, appModel),
content: pageModel.createLeftPane(leftPanelOpen),
},
rightPanel: {

View File

@ -2,7 +2,7 @@ import {beaconOpenMessage} from 'app/client/lib/helpScout';
import {AppModel, reportError} from 'app/client/models/AppModel';
import {BillingModel, BillingModelImpl, ISubscriptionModel} from 'app/client/models/BillingModel';
import {getLoginUrl, getMainOrgUrl, urlState} from 'app/client/models/gristUrlState';
import {appHeader} from 'app/client/ui/AppHeader';
import {AppHeader} from 'app/client/ui/AppHeader';
import {BillingForm, IFormData} from 'app/client/ui/BillingForm';
import * as css from 'app/client/ui/BillingPageCss';
import {BillingPlanManagers} from 'app/client/ui/BillingPlanManagers';
@ -64,7 +64,7 @@ export class BillingPage extends Disposable {
panelWidth: Observable.create(this, 240),
panelOpen,
hideOpener: true,
header: appHeader(this._appModel.currentOrgName, this._appModel.topAppModel.productFlavor),
header: dom.create(AppHeader, this._appModel.currentOrgName, this._appModel),
content: leftPanelBasic(this._appModel, panelOpen),
},
headerMain: this._createTopBarBilling(),

View File

@ -84,6 +84,7 @@ function createLoadedDocMenu(home: HomeModel) {
]),
dom.maybe(home.available, () => [
buildOtherSites(home),
(showIntro && page === 'all' ?
null :
css.docListHeader(
@ -232,6 +233,48 @@ function buildAllTemplates(home: HomeModel, templateWorkspaces: Observable<Works
});
}
/**
* Builds the Other Sites section if there are any to show. Otherwise, builds nothing.
*/
function buildOtherSites(home: HomeModel) {
return dom.domComputed(home.otherSites, sites => {
if (sites.length === 0) { return null; }
const hideOtherSitesObs = Observable.create(null, false);
return css.otherSitesBlock(
dom.autoDispose(hideOtherSitesObs),
css.otherSitesHeader(
'Other Sites',
dom.domComputed(hideOtherSitesObs, (collapsed) =>
collapsed ? css.otherSitesHeaderIcon('Expand') : css.otherSitesHeaderIcon('Collapse')
),
dom.on('click', () => hideOtherSitesObs.set(!hideOtherSitesObs.get())),
testId('other-sites-header'),
),
dom.maybe((use) => !use(hideOtherSitesObs), () => {
const onPersonalSite = Boolean(home.app.currentOrg?.owner);
const siteName = onPersonalSite ? 'your personal site' : `the ${home.app.currentOrgName} site`;
return [
dom('div',
`You are on ${siteName}. You also have access to the following sites:`,
testId('other-sites-message')
),
css.otherSitesButtons(
dom.forEach(sites, s =>
css.siteButton(
s.name,
urlState().setLinkUrl({org: s.domain ?? undefined}),
testId('other-sites-button')
)
),
testId('other-sites-buttons')
)
];
})
);
});
}
/**
* Build the widget for selecting sort and view mode options.
* If hideSort is true, will hide the sort dropdown: it has no effect on the list of examples, so

View File

@ -2,6 +2,7 @@ import {transientInput} from 'app/client/ui/transientInput';
import {colors, mediaSmall, vars} from 'app/client/ui2018/cssVars';
import {icon} from 'app/client/ui2018/icons';
import {styled} from 'grainjs';
import {bigBasicButton} from 'app/client/ui2018/buttons';
// The "&:after" clause forces some padding below all docs.
export const docList = styled('div', `
@ -40,6 +41,8 @@ export const featuredTemplatesHeader = styled(docListHeader, `
align-items: center;
`);
export const otherSitesHeader = templatesHeader;
export const docBlock = styled('div', `
max-width: 550px;
min-width: 300px;
@ -54,6 +57,23 @@ export const templatesDocBlock = styled(docBlock, `
margin-top: 32px;
`);
export const otherSitesBlock = styled('div', `
margin-bottom: 32px;
`);
export const otherSitesButtons = styled('div', `
display: flex;
flex-wrap: wrap;
padding-bottom: 16px;
margin-top: 16px;
margin-bottom: 28px;
gap: 16px;
`);
export const siteButton = styled(bigBasicButton, `
flex: 0 0 auto;
`);
export const docHeaderIconDark = styled(icon, `
margin-right: 8px;
margin-top: -3px;
@ -74,6 +94,8 @@ export const templatesHeaderIcon = styled(docHeaderIcon, `
height: 24px;
`);
export const otherSitesHeaderIcon = templatesHeaderIcon;
const docBlockHeader = `
display: flex;
align-items: center;

View File

@ -4,7 +4,7 @@ import { submitForm } from "app/client/lib/uploads";
import { AppModel, reportError } from "app/client/models/AppModel";
import { getLoginUrl, getSignupUrl, urlState } from "app/client/models/gristUrlState";
import { AccountWidget } from "app/client/ui/AccountWidget";
import { appHeader } from 'app/client/ui/AppHeader';
import { AppHeader } from 'app/client/ui/AppHeader';
import * as BillingPageCss from "app/client/ui/BillingPageCss";
import * as forms from "app/client/ui/forms";
import { pagePanels } from "app/client/ui/PagePanels";
@ -73,7 +73,7 @@ export class WelcomePage extends Disposable {
panelWidth: Observable.create(this, 240),
panelOpen: Observable.create(this, false),
hideOpener: true,
header: appHeader('', this._appModel.topAppModel.productFlavor),
header: dom.create(AppHeader, '', this._appModel),
content: null,
},
headerMain: [cssFlexSpace(), dom.create(AccountWidget, this._appModel)],

View File

@ -1,6 +1,6 @@
import {AppModel} from 'app/client/models/AppModel';
import {getLoginUrl, getMainOrgUrl, urlState} from 'app/client/models/gristUrlState';
import {appHeader} from 'app/client/ui/AppHeader';
import {AppHeader} from 'app/client/ui/AppHeader';
import {leftPanelBasic} from 'app/client/ui/LeftPanelCommon';
import {pagePanels} from 'app/client/ui/PagePanels';
import {createTopBarHome} from 'app/client/ui/TopBar';
@ -105,7 +105,7 @@ function pagePanelsError(appModel: AppModel, header: string, content: DomElement
panelWidth: observable(240),
panelOpen,
hideOpener: true,
header: appHeader(appModel.currentOrgName, appModel.topAppModel.productFlavor),
header: dom.create(AppHeader, appModel.currentOrgName, appModel),
content: leftPanelBasic(appModel, panelOpen),
},
headerMain: createTopBarHome(appModel),