diff --git a/app/client/models/UserManagerModel.ts b/app/client/models/UserManagerModel.ts index dfe1f90a..62f16dc2 100644 --- a/app/client/models/UserManagerModel.ts +++ b/app/client/models/UserManagerModel.ts @@ -5,7 +5,8 @@ import {ShareAnnotations, ShareAnnotator} from 'app/common/ShareAnnotator'; import {normalizeEmail} from 'app/common/emails'; import {GristLoadConfig} from 'app/common/gristUrls'; import * as roles from 'app/common/roles'; -import {ANONYMOUS_USER_EMAIL, EVERYONE_EMAIL, PermissionData, PermissionDelta, UserAPI} from 'app/common/UserAPI'; +import {ANONYMOUS_USER_EMAIL, EVERYONE_EMAIL, Organization, PermissionData, PermissionDelta, + UserAPI, Workspace} from 'app/common/UserAPI'; import {getRealAccess} from 'app/common/UserAPI'; import {computed, Computed, Disposable, obsArray, ObsArray, observable, Observable} from 'grainjs'; import some = require('lodash/some'); @@ -42,6 +43,8 @@ export interface UserManagerModel { export type ResourceType = 'organization'|'workspace'|'document'; +export type Resource = Organization|Workspace|Document; + export interface IEditableMember { id: number; // Newly invited members do not have ids and are represented by -1 name: string; @@ -116,8 +119,6 @@ export class UserManagerModelImpl extends Disposable implements UserManagerModel public isOrg: boolean = this.resourceType === 'organization'; - private _shareAnnotator?: ShareAnnotator; - // Checks if any members were added/removed/changed, if the max inherited role changed or if the // anonymous access setting changed to enable the confirm button to write changes to the server. public readonly isAnythingChanged: Computed = this.autoDispose(computed((use) => { @@ -128,6 +129,8 @@ export class UserManagerModelImpl extends Disposable implements UserManagerModel (this.publicMember ? isMemberChangedFn(this.publicMember) : false); })); + private _shareAnnotator?: ShareAnnotator; + constructor( public initData: PermissionData, public resourceType: ResourceType, diff --git a/app/client/ui/AccountWidget.ts b/app/client/ui/AccountWidget.ts index 3cf35788..9da4602f 100644 --- a/app/client/ui/AccountWidget.ts +++ b/app/client/ui/AccountWidget.ts @@ -12,10 +12,10 @@ import {menu, menuDivider, menuItem, menuItemLink, menuSubHeader} from 'app/clie 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 {Organization, SUPPORT_EMAIL} from 'app/common/UserAPI'; import {Disposable, dom, DomElementArg, styled} from 'grainjs'; import {cssMenuItem} from 'popweasel'; -import {cssOrgCheckmark, cssOrgSelected} from 'app/client/ui/AppHeader'; +import {buildSiteSwitcher} from 'app/client/ui/SiteSwitcher'; /** * Render the user-icon that opens the account menu. When no user is logged in, render a Sign-in @@ -49,13 +49,15 @@ export class AccountWidget extends Disposable { */ private _makeAccountMenu(user: FullUser|null): DomElementArg[] { // Opens the user-manager for the org. + // TODO: Factor out manageUsers, and related UI code, since AppHeader also uses it. const manageUsers = async (org: Organization) => { const api = this._appModel.api; (await loadUserManager()).showUserManagerModal(api, { permissionData: api.getOrgAccess(org.id), activeEmail: user ? user.email : null, resourceType: 'organization', - resourceId: org.id + resourceId: org.id, + resource: org, }); }; @@ -105,7 +107,7 @@ export class AccountWidget extends Disposable { // Show 'Organization Settings' when on a home page of a valid org. (!this._docPageModel && currentOrg && !currentOrg.owner ? - menuItem(() => manageUsers(currentOrg), 'Manage Users', testId('dm-org-access'), + menuItem(() => manageUsers(currentOrg), 'Manage Team', testId('dm-org-access'), dom.cls('disabled', !roles.canEditAccess(currentOrg.access))) : // Don't show on doc pages, or for personal orgs. null), @@ -144,16 +146,8 @@ export class AccountWidget extends Disposable { dom.maybe((use) => use(orgs).length > 0, () => [ menuDivider(), - menuSubHeader('Switch Sites'), + buildSiteSwitcher(this._appModel), ]), - 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('usermenu-org-tick')), - testId('usermenu-org'), - ) - ), ]; } diff --git a/app/client/ui/AppHeader.ts b/app/client/ui/AppHeader.ts index f959e33f..13c897bc 100644 --- a/app/client/ui/AppHeader.ts +++ b/app/client/ui/AppHeader.ts @@ -4,20 +4,44 @@ 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, Disposable, dom, styled} from "grainjs"; -import {menu, menuDivider, menuItemLink, menuSubHeader} from 'app/client/ui2018/menus'; -import {getOrgName} from 'app/common/UserAPI'; +import {menu, menuDivider, menuItem, menuItemLink, menuSubHeader} from 'app/client/ui2018/menus'; +import {Organization, SUPPORT_EMAIL} 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 {loadUserManager} from 'app/client/lib/imports'; +import {buildSiteSwitcher} from 'app/client/ui/SiteSwitcher'; export class AppHeader extends Disposable { - constructor(private _orgName: BindableValue, private _appModel: AppModel) { + constructor(private _orgName: BindableValue, private _appModel: AppModel, + private _docPageModel?: DocPageModel) { super(); } public buildDom() { const theme = getTheme(this._appModel.topAppModel.productFlavor); + const user = this._appModel.currentValidUser; + const orgs = this._appModel.topAppModel.orgs; + const currentOrg = this._appModel.currentOrg; + const isTeamSite = Boolean(currentOrg && !currentOrg.owner); + const isBillingManager = Boolean(currentOrg && currentOrg.billingAccount && + (currentOrg.billingAccount.isManager || user?.email === SUPPORT_EMAIL)); + + // Opens the user-manager for the org. + const manageUsers = async (org: Organization) => { + const api = this._appModel.api; + (await loadUserManager()).showUserManagerModal(api, { + permissionData: api.getOrgAccess(org.id), + activeEmail: user ? user.email : null, + resourceType: 'organization', + resourceId: org.id, + resource: org, + }); + }; + return cssAppHeader( cssAppHeader.cls('-widelogo', theme.wideLogo || false), // Show version when hovering over the application icon. @@ -29,46 +53,38 @@ export class AppHeader extends Disposable { cssOrg( cssOrgName(dom.text(this._orgName)), this._orgName && cssDropdownIcon('Dropdown'), - menu(() => this._makeOrgMenu(), {placement: 'bottom-start'}), - testId('dm-org'), - ), - ); - } + menu(() => [ + menuSubHeader(`${isTeamSite ? 'Team' : 'Personal'} Site`, testId('orgmenu-title')), + menuItemLink(urlState().setLinkUrl({}), 'Home Page', testId('orgmenu-home-page')), - private _makeOrgMenu() { - const orgs = this._appModel.topAppModel.orgs; + // Show 'Organization Settings' when on a home page of a valid org. + (!this._docPageModel && currentOrg && !currentOrg.owner ? + menuItem(() => manageUsers(currentOrg), 'Manage Team', testId('orgmenu-manage-team'), + dom.cls('disabled', !roles.canEditAccess(currentOrg.access))) : + // Don't show on doc pages, or for personal orgs. + null), + + // Show link to billing pages. + currentOrg && !currentOrg.owner ? + // For links, disabling with just a class is hard; easier to just not make it a link. + // TODO weasel menus should support disabling menuItemLink. + (isBillingManager ? + menuItemLink(urlState().setLinkUrl({billing: 'billing'}), 'Billing Account') : + menuItem(() => null, 'Billing Account', dom.cls('disabled', true), testId('orgmenu-billing')) + ) : + null, - 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'), - ) + dom.maybe((use) => use(orgs).length > 0, () => [ + menuDivider(), + buildSiteSwitcher(this._appModel), + ]), + ], { placement: 'bottom-start' }), + testId('dm-org'), ), - ]; + ); } } -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%; diff --git a/app/client/ui/AppUI.ts b/app/client/ui/AppUI.ts index f9dc7a56..dc03d873 100644 --- a/app/client/ui/AppUI.ts +++ b/app/client/ui/AppUI.ts @@ -136,7 +136,7 @@ function pagePanelsDoc(owner: IDisposableOwner, appModel: AppModel, appObj: App) leftPanel: { panelWidth: leftPanelWidth, panelOpen: leftPanelOpen, - header: dom.create(AppHeader, appModel.currentOrgName || pageModel.currentOrgName, appModel), + header: dom.create(AppHeader, appModel.currentOrgName || pageModel.currentOrgName, appModel, pageModel), content: pageModel.createLeftPane(leftPanelOpen), }, rightPanel: { diff --git a/app/client/ui/SiteSwitcher.ts b/app/client/ui/SiteSwitcher.ts new file mode 100644 index 00000000..634f5471 --- /dev/null +++ b/app/client/ui/SiteSwitcher.ts @@ -0,0 +1,53 @@ +import {commonUrls} from 'app/common/gristUrls'; +import {getOrgName} from 'app/common/UserAPI'; +import {dom, makeTestId, styled} from 'grainjs'; +import {AppModel} from 'app/client/models/AppModel'; +import {urlState} from 'app/client/models/gristUrlState'; +import {menuIcon, menuItemLink, menuSubHeader} from 'app/client/ui2018/menus'; +import {icon} from 'app/client/ui2018/icons'; +import {colors} from 'app/client/ui2018/cssVars'; + +const testId = makeTestId('test-site-switcher-'); + +/** + * Builds a menu sub-section that displays a list of orgs/sites that the current + * valid user has access to, with buttons to navigate to them. + * + * Used by AppHeader and AccountWidget. + */ +export function buildSiteSwitcher(appModel: AppModel) { + const orgs = appModel.topAppModel.orgs; + + return [ + menuSubHeader('Switch Sites'), + dom.forEach(orgs, (org) => + menuItemLink(urlState().setLinkUrl({ org: org.domain || undefined }), + cssOrgSelected.cls('', appModel.currentOrg ? org.id === appModel.currentOrg.id : false), + getOrgName(org), + cssOrgCheckmark('Tick', testId('org-tick')), + testId('org'), + ) + ), + menuItemLink( + { href: commonUrls.createTeamSite }, + menuIcon('Plus'), + 'Create new team site', + testId('create-new-site'), + ), + ]; +} + +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; + } +`); diff --git a/app/client/ui/UserManager.ts b/app/client/ui/UserManager.ts index 1afab767..2e61064c 100644 --- a/app/client/ui/UserManager.ts +++ b/app/client/ui/UserManager.ts @@ -8,8 +8,8 @@ import {commonUrls} from 'app/common/gristUrls'; import {FullUser} from 'app/common/LoginSessionAPI'; import * as roles from 'app/common/roles'; -import {PermissionData, UserAPI} from 'app/common/UserAPI'; -import {computed, Computed, Disposable, observable, Observable} from 'grainjs'; +import {Organization, PermissionData, UserAPI} from 'app/common/UserAPI'; +import {computed, Computed, Disposable, keyframes, observable, Observable} from 'grainjs'; import {dom, DomElementArg, styled} from 'grainjs'; import pick = require('lodash/pick'); import {cssMenuItem} from 'popweasel'; @@ -20,7 +20,8 @@ import {AppModel} from 'app/client/models/AppModel'; import {DocPageModel} from 'app/client/models/DocPageModel'; import {reportError} from 'app/client/models/errors'; import {urlState} from 'app/client/models/gristUrlState'; -import {IEditableMember, IMemberSelectOption, IOrgMemberSelectOption} from 'app/client/models/UserManagerModel'; +import {IEditableMember, IMemberSelectOption, IOrgMemberSelectOption, + Resource} from 'app/client/models/UserManagerModel'; import {UserManagerModel, UserManagerModelImpl} from 'app/client/models/UserManagerModel'; import {getResourceParent, ResourceType} from 'app/client/models/UserManagerModel'; import {shadowScroll} from 'app/client/ui/shadowScroll'; @@ -41,6 +42,7 @@ export interface IUserManagerOptions { activeEmail: string|null; resourceType: ResourceType; resourceId: string|number; + resource?: Resource; docPageModel?: DocPageModel; appModel?: AppModel; // If present, we offer access to a nested team-level dialog. linkToCopy?: string; @@ -49,6 +51,7 @@ export interface IUserManagerOptions { prompt?: { // If set, user manager should open with this email filled in and ready to go. email: string; }; + showAnimation?: boolean; // If true, animates opening of the modal. Defaults to false. } // Returns an instance of UserManagerModel given IUserManagerOptions. Makes the async call for the @@ -91,10 +94,10 @@ export function showUserManagerModal(userApi: UserAPI, options: IUserManagerOpti modal(ctl => [ // We set the padding to 0 since the body scroll shadows extend to the edge of the modal. { style: 'padding: 0;' }, - + options.showAnimation ? dom.cls(cssAnimatedModal.className) : null, cssModalTitle( { style: 'margin: 40px 64px 0 64px;' }, - `Invite people to ${renderType(options.resourceType)}`, + renderTitle(options.resourceType, options.resource), (options.resourceType === 'document' ? makeCopyBtn(options.linkToCopy, cssCopyBtn.cls('-header')) : null), testId('um-header') ), @@ -433,7 +436,9 @@ export class MemberEmail extends Disposable { modifiers: { offset: { enabled: true, offset: -40 } }, - stretchToSelector: `.${cssEmailInputContainer.className}` + stretchToSelector: `.${cssEmailInputContainer.className}`, + attach: null, + boundaries: 'document' as any, // TODO: Update weasel.js types to allow 'document'. }) ), cssEmailInputContainer.cls('-green', enableAdd), @@ -507,8 +512,10 @@ async function manageTeam(appModel: AppModel, activeEmail: user ? user.email : null, resourceType: 'organization', resourceId: currentOrg.id, + resource: currentOrg, onSave, prompt, + showAnimation: true, }); } } @@ -602,7 +609,40 @@ const cssAccessLink = styled(cssLink, ` margin-left: auto; `); -// Render the name "organization" as "team site" in UI -function renderType(resourceType: ResourceType): string { - return resourceType === 'organization' ? 'team site' : resourceType; +const cssOrgName = styled('div', ` + font-size: ${vars.largeFontSize}; +`); + +const cssOrgDomain = styled('span', ` + color: ${colors.lightGreen}; +`); + +const cssFadeInFromTop = keyframes(` + from {top: -250px; opacity: 0} + to {top: 0; opacity: 1} +`); + +const cssAnimatedModal = styled('div', ` + animation-name: ${cssFadeInFromTop}; + animation-duration: 0.4s; + position: relative; +`); + +// Render the UserManager title for `resourceType` (e.g. org as "team site"). +function renderTitle(resourceType: ResourceType, resource?: Resource) { + switch (resourceType) { + case 'organization': { + return [ + 'Manage members of team site', + !resource ? null : cssOrgName( + `${(resource as Organization).name} (`, + cssOrgDomain(`${(resource as Organization).domain}.getgrist.com`), + ')', + ) + ]; + } + default: { + return `Invite people to ${resourceType}`; + } + } } diff --git a/app/common/gristUrls.ts b/app/common/gristUrls.ts index b05b0ce0..b0883c93 100644 --- a/app/common/gristUrls.ts +++ b/app/common/gristUrls.ts @@ -42,6 +42,7 @@ export const MIN_URLID_PREFIX_LENGTH = 12; export const commonUrls = { help: "https://support.getgrist.com", plans: "https://www.getgrist.com/pricing", + createTeamSite: "https://www.getgrist.com/create-team-site", efcrConnect: 'https://efc-r.com/connect', efcrHelp: 'https://www.nioxus.info/eFCR-Help', diff --git a/test/nbrowser/gristUtils.ts b/test/nbrowser/gristUtils.ts index c3d02265..99c7e4c3 100644 --- a/test/nbrowser/gristUtils.ts +++ b/test/nbrowser/gristUtils.ts @@ -1682,7 +1682,7 @@ async function openAccountMenu() { // Since the AccountWidget loads orgs and the user data asynchronously, the menu // can expand itself causing the click to land on a wrong button. await waitForServer(); - await driver.findWait('.test-usermenu-org', 1000); + await driver.findWait('.test-site-switcher-org', 1000); await driver.sleep(250); // There's still some jitter (scroll-bar? other user accounts?) }