mirror of
https://github.com/gristlabs/grist-core.git
synced 2024-10-27 20:44:07 +00:00
(core) Update design of empty docs home page, and add a "Manage Team" button.
Summary: - Remove the empty-folder icon - Add an "Invite team members" button for owners on empty team sites - Add a "Browse Templates" button for all other cases on empty sites - Update intro text for team, including a link to Sprouts - Update intro text for personal/anon. - Include a Free/Pro tag for team sites (for now, only "Free") - Add a "Manage Team" button for owners on home page of all team sites. - Polished the UI of UserManager: add a transition for the background, and delay the appearance of the spinner for fast loads. Test Plan: Fixed up the HomeIntro tests; added test case for Manage Team button Reviewers: georgegevoian Reviewed By: georgegevoian Differential Revision: https://phab.getgrist.com/D3459
This commit is contained in:
parent
af4738b94a
commit
acddd25cfd
@ -60,6 +60,8 @@ export interface AppModel {
|
||||
|
||||
currentOrg: Organization|null; // null if no access to currentSubdomain
|
||||
currentOrgName: string; // Our best guess for human-friendly name.
|
||||
isPersonal: boolean; // Is it a personal site?
|
||||
isTeamSite: boolean; // Is it a team site?
|
||||
orgError?: OrgError; // If currentOrg is null, the error that caused it.
|
||||
|
||||
currentFeatures: Features; // features of the current org's product.
|
||||
@ -180,6 +182,9 @@ export class AppModelImpl extends Disposable implements AppModel {
|
||||
// Figure out the org name, or blank if details are unavailable.
|
||||
public readonly currentOrgName = getOrgNameOrGuest(this.currentOrg, this.currentUser);
|
||||
|
||||
public readonly isPersonal = Boolean(this.currentOrg?.owner);
|
||||
public readonly isTeamSite = Boolean(this.currentOrg) && !this.isPersonal;
|
||||
|
||||
public readonly currentFeatures = (this.currentOrg && this.currentOrg.billingAccount) ?
|
||||
this.currentOrg.billingAccount.product.features : {};
|
||||
|
||||
|
@ -1,7 +1,8 @@
|
||||
import {loadGristDoc, loadUserManager} from 'app/client/lib/imports';
|
||||
import {loadGristDoc} from 'app/client/lib/imports';
|
||||
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 {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';
|
||||
@ -11,7 +12,7 @@ import {menu, menuDivider, menuItem, menuItemLink, menuSubHeader} from 'app/clie
|
||||
import {commonUrls, shouldHideUiElement} from 'app/common/gristUrls';
|
||||
import {FullUser} from 'app/common/LoginSessionAPI';
|
||||
import * as roles from 'app/common/roles';
|
||||
import {Organization, SUPPORT_EMAIL} from 'app/common/UserAPI';
|
||||
import {SUPPORT_EMAIL} from 'app/common/UserAPI';
|
||||
import {Disposable, dom, DomElementArg, styled} from 'grainjs';
|
||||
import {cssMenuItem} from 'popweasel';
|
||||
import {maybeAddSiteSwitcherSection} from 'app/client/ui/SiteSwitcher';
|
||||
@ -47,19 +48,6 @@ export class AccountWidget extends Disposable {
|
||||
* Note that `user` should NOT be anonymous (none of the items are really relevant).
|
||||
*/
|
||||
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),
|
||||
activeUser: user,
|
||||
resourceType: 'organization',
|
||||
resourceId: org.id,
|
||||
resource: org,
|
||||
});
|
||||
};
|
||||
|
||||
const currentOrg = this._appModel.currentOrg;
|
||||
const gristDoc = this._docPageModel ? this._docPageModel.gristDoc.get() : null;
|
||||
const isBillingManager = Boolean(currentOrg && currentOrg.billingAccount &&
|
||||
@ -104,8 +92,8 @@ export class AccountWidget extends Disposable {
|
||||
documentSettingsItem,
|
||||
|
||||
// Show 'Organization Settings' when on a home page of a valid org.
|
||||
(!this._docPageModel && currentOrg && !currentOrg.owner ?
|
||||
menuItem(() => manageUsers(currentOrg),
|
||||
(!this._docPageModel && currentOrg && this._appModel.isTeamSite ?
|
||||
menuItem(() => manageTeamUsers(currentOrg, user, this._appModel.api),
|
||||
roles.canEditAccess(currentOrg.access) ? 'Manage Team' : 'Access Details',
|
||||
testId('dm-org-access')) :
|
||||
// Don't show on doc pages, or for personal orgs.
|
||||
@ -113,7 +101,7 @@ export class AccountWidget extends Disposable {
|
||||
|
||||
shouldHideUiElement("billing") ? null :
|
||||
// Show link to billing pages.
|
||||
currentOrg && !currentOrg.owner ?
|
||||
this._appModel.isTeamSite ?
|
||||
// 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 ?
|
||||
|
@ -6,14 +6,25 @@ import {shouldHideUiElement} from 'app/common/gristUrls';
|
||||
import * as version from 'app/common/version';
|
||||
import {BindableValue, Disposable, dom, styled} from "grainjs";
|
||||
import {menu, menuItem, menuItemLink, menuSubHeader} from 'app/client/ui2018/menus';
|
||||
import {Organization, SUPPORT_EMAIL} from 'app/common/UserAPI';
|
||||
import {isTemplatesOrg, 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 {manageTeamUsersApp} from 'app/client/ui/OpenUserManager';
|
||||
import {maybeAddSiteSwitcherSection} from 'app/client/ui/SiteSwitcher';
|
||||
import {DomContents} from 'grainjs';
|
||||
|
||||
// Maps a name of a Product (from app/gen-server/entity/Product.ts) to a tag (pill) to show next
|
||||
// to the org name.
|
||||
const productPills: {[name: string]: string|null} = {
|
||||
// TODO We don't label paid team plans with a tag yet, but we should label as "Pro" once we
|
||||
// update our pricing pages to refer to paid team plans as Pro plans.
|
||||
"professional": null, // Deprecated but used in development.
|
||||
"team": null, // Used for the paid team plans.
|
||||
"teamFree": "Free", // The new free team plan.
|
||||
// Other plans are either personal, or grandfathered, or for testing.
|
||||
};
|
||||
|
||||
export class AppHeader extends Disposable {
|
||||
constructor(private _orgName: BindableValue<string>, private _appModel: AppModel,
|
||||
@ -26,22 +37,9 @@ export class AppHeader extends Disposable {
|
||||
|
||||
const user = this._appModel.currentValidUser;
|
||||
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),
|
||||
activeUser: user,
|
||||
resourceType: 'organization',
|
||||
resourceId: org.id,
|
||||
resource: org
|
||||
});
|
||||
};
|
||||
|
||||
return cssAppHeader(
|
||||
cssAppHeader.cls('-widelogo', theme.wideLogo || false),
|
||||
// Show version when hovering over the application icon.
|
||||
@ -51,15 +49,17 @@ export class AppHeader extends Disposable {
|
||||
testId('dm-logo')
|
||||
),
|
||||
cssOrg(
|
||||
cssOrgName(dom.text(this._orgName)),
|
||||
cssOrgName(dom.text(this._orgName), testId('dm-orgname')),
|
||||
productPill(currentOrg),
|
||||
this._orgName && cssDropdownIcon('Dropdown'),
|
||||
menu(() => [
|
||||
menuSubHeader(`${isTeamSite ? 'Team' : 'Personal'} Site`, testId('orgmenu-title')),
|
||||
menuSubHeader(`${this._appModel.isTeamSite ? 'Team' : 'Personal'} Site`, testId('orgmenu-title')),
|
||||
menuItemLink(urlState().setLinkUrl({}), 'Home Page', testId('orgmenu-home-page')),
|
||||
|
||||
// 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'),
|
||||
menuItem(() => manageTeamUsersApp(this._appModel),
|
||||
'Manage Team', testId('orgmenu-manage-team'),
|
||||
dom.cls('disabled', !roles.canEditAccess(currentOrg.access))) :
|
||||
// Don't show on doc pages, or for personal orgs.
|
||||
null),
|
||||
@ -82,6 +82,22 @@ export class AppHeader extends Disposable {
|
||||
}
|
||||
}
|
||||
|
||||
export function productPill(org: Organization|null, options: {large?: boolean} = {}): DomContents {
|
||||
if (!org || isTemplatesOrg(org)) {
|
||||
return null;
|
||||
}
|
||||
const product = org?.billingAccount?.product.name;
|
||||
const pillTag = product && productPills[product];
|
||||
if (!pillTag) {
|
||||
return null;
|
||||
}
|
||||
return cssProductPill(cssProductPill.cls('-' + pillTag),
|
||||
options.large ? cssProductPill.cls('-large') : null,
|
||||
pillTag,
|
||||
testId('appheader-product-pill'));
|
||||
}
|
||||
|
||||
|
||||
const cssAppHeader = styled('div', `
|
||||
display: flex;
|
||||
width: 100%;
|
||||
@ -126,6 +142,11 @@ const cssOrg = styled('div', `
|
||||
max-width: calc(100% - 48px);
|
||||
cursor: pointer;
|
||||
height: 100%;
|
||||
font-weight: 500;
|
||||
|
||||
&:hover {
|
||||
background-color: ${colors.mediumGrey};
|
||||
}
|
||||
`);
|
||||
|
||||
const cssOrgName = styled('div', `
|
||||
@ -138,3 +159,25 @@ const cssOrgName = styled('div', `
|
||||
display: none;
|
||||
}
|
||||
`);
|
||||
|
||||
const cssProductPill = styled('div', `
|
||||
border-radius: 4px;
|
||||
font-size: ${vars.smallFontSize};
|
||||
padding: 2px 4px;
|
||||
display: inline;
|
||||
vertical-align: middle;
|
||||
|
||||
&-Free {
|
||||
background-color: ${colors.orange};
|
||||
color: white;
|
||||
}
|
||||
&-Pro {
|
||||
background-color: ${colors.lightGreen};
|
||||
color: white;
|
||||
}
|
||||
&-large {
|
||||
padding: 4px 8px;
|
||||
margin-left: 16px;
|
||||
font-size: ${vars.mediumFontSize};
|
||||
}
|
||||
`);
|
||||
|
@ -67,7 +67,9 @@ function createLoadedDocMenu(home: HomeModel) {
|
||||
|
||||
return [
|
||||
// Hide the sort option only when showing intro.
|
||||
buildPrefs(viewSettings, {hideSort: showIntro}),
|
||||
((showIntro && page === 'all') ? null :
|
||||
buildPrefs(viewSettings, {hideSort: showIntro})
|
||||
),
|
||||
|
||||
// Build the pinned docs dom. Builds nothing if the selectedOrg is unloaded or
|
||||
dom.maybe((use) => use(home.currentWSPinnedDocs).length > 0, () => [
|
||||
|
@ -33,7 +33,7 @@ export const docList = styled('div', `
|
||||
`);
|
||||
|
||||
export const docListHeader = styled('div', `
|
||||
height: 32px;
|
||||
min-height: 32px;
|
||||
line-height: 32px;
|
||||
margin-bottom: 24px;
|
||||
color: ${colors.dark};
|
||||
|
@ -1,63 +1,80 @@
|
||||
import {getLoginOrSignupUrl} from 'app/client/models/gristUrlState';
|
||||
import {getLoginOrSignupUrl, urlState} from 'app/client/models/gristUrlState';
|
||||
import {HomeModel} from 'app/client/models/HomeModel';
|
||||
import {productPill} from 'app/client/ui/AppHeader';
|
||||
import * as css from 'app/client/ui/DocMenuCss';
|
||||
import {createDocAndOpen, importDocAndOpen} from 'app/client/ui/HomeLeftPane';
|
||||
import {bigBasicButton} from 'app/client/ui2018/buttons';
|
||||
import {mediaXSmall, testId} from 'app/client/ui2018/cssVars';
|
||||
import {manageTeamUsersApp} from 'app/client/ui/OpenUserManager';
|
||||
import {bigBasicButton, cssButton} from 'app/client/ui2018/buttons';
|
||||
import {testId, vars} from 'app/client/ui2018/cssVars';
|
||||
import {icon} from 'app/client/ui2018/icons';
|
||||
import {cssLink} from 'app/client/ui2018/links';
|
||||
import {commonUrls} from 'app/common/gristUrls';
|
||||
import {dom, DomContents, DomCreateFunc, styled} from 'grainjs';
|
||||
import {FullUser} from 'app/common/LoginSessionAPI';
|
||||
import * as roles from 'app/common/roles';
|
||||
import {dom, DomContents, styled} from 'grainjs';
|
||||
|
||||
export function buildHomeIntro(homeModel: HomeModel): DomContents {
|
||||
const user = homeModel.app.currentValidUser;
|
||||
if (user) {
|
||||
return [
|
||||
css.docListHeader(`Welcome to Grist, ${user.name}!`, testId('welcome-title')),
|
||||
cssIntroSplit(
|
||||
cssIntroLeft(
|
||||
cssIntroImage({src: 'https://www.getgrist.com/themes/grist/assets/images/empty-folder.png'}),
|
||||
testId('intro-image'),
|
||||
),
|
||||
cssIntroRight(
|
||||
cssParagraph(
|
||||
'Watch video on ',
|
||||
cssLink({href: 'https://support.getgrist.com/creating-doc/', target: '_blank'}, 'creating a document'),
|
||||
'.', dom('br'),
|
||||
'Learn more in our ', cssLink({href: commonUrls.help, target: '_blank'}, 'Help Center'), '.',
|
||||
testId('welcome-text')
|
||||
),
|
||||
makeCreateButtons(homeModel),
|
||||
),
|
||||
),
|
||||
];
|
||||
return homeModel.app.isTeamSite ? makeTeamSiteIntro(homeModel) : makePersonalIntro(homeModel, user);
|
||||
} else {
|
||||
return [
|
||||
cssIntroSplit(
|
||||
cssIntroLeft(
|
||||
cssLink({href: 'https://support.getgrist.com/creating-doc/', target: '_blank'},
|
||||
cssIntroImage({src: 'https://www.getgrist.com/themes/grist/assets/images/video-create-doc.png'}),
|
||||
),
|
||||
testId('intro-image'),
|
||||
),
|
||||
cssIntroRight(
|
||||
css.docListHeader('Welcome to Grist!', testId('welcome-title')),
|
||||
cssParagraph(
|
||||
'You can explore and experiment without logging in. ',
|
||||
'To save your work, however, you’ll need to ',
|
||||
cssLink({href: getLoginOrSignupUrl()}, 'sign up'), '.', dom('br'),
|
||||
'Learn more in our ', cssLink({href: commonUrls.help, target: '_blank'}, 'Help Center'), '.',
|
||||
testId('welcome-text')
|
||||
),
|
||||
makeCreateButtons(homeModel),
|
||||
),
|
||||
),
|
||||
];
|
||||
return makeAnonIntro(homeModel);
|
||||
}
|
||||
}
|
||||
|
||||
function makeTeamSiteIntro(homeModel: HomeModel) {
|
||||
const sproutsProgram = cssLink({href: commonUrls.sproutsProgram, target: '_blank'}, 'Sprouts Program');
|
||||
return [
|
||||
css.docListHeader(`Welcome to ${homeModel.app.currentOrgName}`,
|
||||
productPill(homeModel.app.currentOrg, {large: true}),
|
||||
testId('welcome-title')),
|
||||
cssIntroLine('Get started by inviting your team and creating your first Grist document.'),
|
||||
cssIntroLine('Learn more in our ', helpCenterLink(), ', or find an expert via our ', sproutsProgram, '.',
|
||||
testId('welcome-text')),
|
||||
makeCreateButtons(homeModel),
|
||||
];
|
||||
}
|
||||
|
||||
function makePersonalIntro(homeModel: HomeModel, user: FullUser) {
|
||||
return [
|
||||
css.docListHeader(`Welcome to Grist, ${user.name}!`, testId('welcome-title')),
|
||||
cssIntroLine('Get started by creating your first Grist document.'),
|
||||
cssIntroLine('Visit our ', helpCenterLink(), ' to learn more.',
|
||||
testId('welcome-text')),
|
||||
makeCreateButtons(homeModel),
|
||||
];
|
||||
}
|
||||
|
||||
function makeAnonIntro(homeModel: HomeModel) {
|
||||
const signUp = cssLink({href: getLoginOrSignupUrl()}, 'Sign up');
|
||||
return [
|
||||
css.docListHeader(`Welcome to Grist!`, testId('welcome-title')),
|
||||
cssIntroLine('Get started by exploring templates, or creating your first Grist document.'),
|
||||
cssIntroLine(signUp, ' to save your work. Visit our ', helpCenterLink(), ' to learn more.',
|
||||
testId('welcome-text')),
|
||||
makeCreateButtons(homeModel),
|
||||
];
|
||||
}
|
||||
|
||||
function helpCenterLink() {
|
||||
return cssLink({href: commonUrls.help, target: '_blank'}, cssInlineIcon('Help'), 'Help Center');
|
||||
}
|
||||
|
||||
|
||||
function makeCreateButtons(homeModel: HomeModel) {
|
||||
const canManageTeam = homeModel.app.isTeamSite &&
|
||||
roles.canEditAccess(homeModel.app.currentOrg?.access || null);
|
||||
return cssBtnGroup(
|
||||
(canManageTeam ?
|
||||
cssBtn(cssBtnIcon('Help'), 'Invite Team Members', testId('intro-invite'),
|
||||
cssButton.cls('-primary'),
|
||||
dom.on('click', () => manageTeamUsersApp(homeModel.app)),
|
||||
) :
|
||||
cssBtn(cssBtnIcon('FieldTable'), 'Browse Templates', testId('intro-templates'),
|
||||
cssButton.cls('-primary'),
|
||||
urlState().setLinkUrl({homePage: 'templates'}),
|
||||
)
|
||||
),
|
||||
cssBtn(cssBtnIcon('Import'), 'Import Document', testId('intro-import-doc'),
|
||||
dom.on('click', () => importDocAndOpen(homeModel)),
|
||||
),
|
||||
@ -67,44 +84,24 @@ function makeCreateButtons(homeModel: HomeModel) {
|
||||
);
|
||||
}
|
||||
|
||||
const cssIntroSplit = styled(css.docBlock, `
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
@media ${mediaXSmall} {
|
||||
& {
|
||||
display: block;
|
||||
}
|
||||
}
|
||||
`);
|
||||
|
||||
const cssIntroLeft = styled('div', `
|
||||
flex: 0.4 1 0px;
|
||||
overflow: hidden;
|
||||
max-height: 150px;
|
||||
text-align: center;
|
||||
margin: 32px 0;
|
||||
`);
|
||||
|
||||
const cssIntroRight = styled('div', `
|
||||
flex: 0.6 1 0px;
|
||||
overflow: auto;
|
||||
margin-left: 8px;
|
||||
`);
|
||||
|
||||
const cssParagraph = styled(css.docBlock, `
|
||||
line-height: 1.6;
|
||||
`);
|
||||
|
||||
const cssIntroLine = styled(cssParagraph, `
|
||||
font-size: ${vars.introFontSize};
|
||||
margin-bottom: 8px;
|
||||
`);
|
||||
|
||||
const cssBtnGroup = styled('div', `
|
||||
display: inline-flex;
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
margin-top: -16px;
|
||||
`);
|
||||
|
||||
const cssBtn = styled(bigBasicButton, `
|
||||
display: block;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin-right: 16px;
|
||||
margin-top: 16px;
|
||||
text-align: left;
|
||||
@ -114,11 +111,6 @@ const cssBtnIcon = styled(icon, `
|
||||
margin-right: 8px;
|
||||
`);
|
||||
|
||||
// Helper to create an image scaled down to half of its intrinsic size.
|
||||
// Based on https://stackoverflow.com/a/25026615/328565
|
||||
const cssIntroImage: DomCreateFunc<HTMLDivElement> =
|
||||
(...args) => _cssImageWrap1(_cssImageWrap2(_cssImageScaled(...args)));
|
||||
|
||||
const _cssImageWrap1 = styled('div', `width: 200%; margin-left: -50%;`);
|
||||
const _cssImageWrap2 = styled('div', `display: inline-block;`);
|
||||
const _cssImageScaled = styled('img', `width: 50%;`);
|
||||
const cssInlineIcon = styled(icon, `
|
||||
margin: -2px 4px 2px 4px;
|
||||
`);
|
||||
|
@ -14,7 +14,7 @@ import {select} from 'app/client/ui2018/menus';
|
||||
import {confirmModal, cssModalBody, cssModalButtons, cssModalWidth, modal, saveModal} from 'app/client/ui2018/modals';
|
||||
import {FullUser} from 'app/common/LoginSessionAPI';
|
||||
import * as roles from 'app/common/roles';
|
||||
import {Document, Organization, Workspace} from 'app/common/UserAPI';
|
||||
import {Document, isTemplatesOrg, Organization, Workspace} from 'app/common/UserAPI';
|
||||
import {Computed, Disposable, dom, input, Observable, styled, subscribe} from 'grainjs';
|
||||
import sortBy = require('lodash/sortBy');
|
||||
|
||||
@ -99,10 +99,9 @@ export async function makeCopy(doc: Document, app: AppModel, modalTitle: string)
|
||||
}
|
||||
let orgs = allowOtherOrgs(doc, app) ? await app.api.getOrgs(true) : null;
|
||||
if (orgs) {
|
||||
// TODO: Need a more robust way to detect and exclude the templates org.
|
||||
// Don't show the templates org since it's selected by default, and
|
||||
// is not writable to.
|
||||
orgs = orgs.filter(o => o.domain !== 'templates' && o.domain !== 'templates-s');
|
||||
orgs = orgs.filter(o => !isTemplatesOrg(o));
|
||||
}
|
||||
|
||||
// Show a dialog with a form to select destination.
|
||||
|
21
app/client/ui/OpenUserManager.ts
Normal file
21
app/client/ui/OpenUserManager.ts
Normal file
@ -0,0 +1,21 @@
|
||||
import {loadUserManager} from 'app/client/lib/imports';
|
||||
import {AppModel} from 'app/client/models/AppModel';
|
||||
import {FullUser, Organization, UserAPI} from 'app/common/UserAPI';
|
||||
|
||||
// Opens the user-manager for the given org.
|
||||
export async function manageTeamUsers(org: Organization, user: FullUser|null, api: UserAPI) {
|
||||
(await loadUserManager()).showUserManagerModal(api, {
|
||||
permissionData: api.getOrgAccess(org.id),
|
||||
activeUser: user,
|
||||
resourceType: 'organization',
|
||||
resourceId: org.id,
|
||||
resource: org,
|
||||
});
|
||||
}
|
||||
|
||||
// Opens the user-manager for the current org in the given AppModel.
|
||||
export async function manageTeamUsersApp(app: AppModel) {
|
||||
if (app.currentOrg) {
|
||||
return manageTeamUsers(app.currentOrg, app.currentValidUser, app.api);
|
||||
}
|
||||
}
|
@ -5,17 +5,33 @@ import {DocPageModel} from 'app/client/models/DocPageModel';
|
||||
import {workspaceName} from 'app/client/models/WorkspaceInfo';
|
||||
import {AccountWidget} from 'app/client/ui/AccountWidget';
|
||||
import {buildNotifyMenuButton} from 'app/client/ui/NotifyUI';
|
||||
import {manageTeamUsersApp} from 'app/client/ui/OpenUserManager';
|
||||
import {buildShareMenuButton} from 'app/client/ui/ShareMenu';
|
||||
import {cssHoverCircle, cssTopBarBtn} from 'app/client/ui/TopBarCss';
|
||||
import {docBreadcrumbs} from 'app/client/ui2018/breadcrumbs';
|
||||
import {basicButton} from 'app/client/ui2018/buttons';
|
||||
import {colors, cssHideForNarrowScreen, testId} from 'app/client/ui2018/cssVars';
|
||||
import {IconName} from 'app/client/ui2018/IconList';
|
||||
import {waitGrainObs} from 'app/common/gutil';
|
||||
import * as roles from 'app/common/roles';
|
||||
import {Computed, dom, DomElementArg, makeTestId, MultiHolder, Observable, styled} from 'grainjs';
|
||||
|
||||
export function createTopBarHome(appModel: AppModel) {
|
||||
return [
|
||||
cssFlexSpace(),
|
||||
|
||||
(appModel.isTeamSite && roles.canEditAccess(appModel.currentOrg?.access || null) ?
|
||||
[
|
||||
basicButton(
|
||||
'Manage Team',
|
||||
dom.on('click', () => manageTeamUsersApp(appModel)),
|
||||
testId('topbar-manage-team')
|
||||
),
|
||||
cssSpacer()
|
||||
] :
|
||||
null
|
||||
),
|
||||
|
||||
buildNotifyMenuButton(appModel.notifier, appModel),
|
||||
dom('div', dom.create(AccountWidget, appModel)),
|
||||
];
|
||||
|
@ -6,6 +6,7 @@
|
||||
* It can be instantiated by calling showUserManagerModal with the UserAPI and IUserManagerOptions.
|
||||
*/
|
||||
import {commonUrls} from 'app/common/gristUrls';
|
||||
import {isLongerThan} from 'app/common/gutil';
|
||||
import {FullUser} from 'app/common/LoginSessionAPI';
|
||||
import * as roles from 'app/common/roles';
|
||||
import {Organization, PermissionData, UserAPI} from 'app/common/UserAPI';
|
||||
@ -71,11 +72,11 @@ async function getModel(options: IUserManagerOptions): Promise<UserManagerModelI
|
||||
* the UserManager menu with save and cancel buttons.
|
||||
*/
|
||||
export function showUserManagerModal(userApi: UserAPI, options: IUserManagerOptions) {
|
||||
const modelObs: Observable<UserManagerModel|null> = observable(null);
|
||||
const modelObs: Observable<UserManagerModel|null|"slow"> = observable(null);
|
||||
|
||||
async function onConfirm(ctl: IModalControl) {
|
||||
const model = modelObs.get();
|
||||
if (!model) {
|
||||
if (!model || model === "slow") {
|
||||
ctl.close();
|
||||
return;
|
||||
}
|
||||
@ -111,15 +112,17 @@ export function showUserManagerModal(userApi: UserAPI, options: IUserManagerOpti
|
||||
}
|
||||
|
||||
// Get the model and assign it to the observable. Report errors to the app.
|
||||
getModel(options)
|
||||
const waitPromise = getModel(options)
|
||||
.then(model => modelObs.set(model))
|
||||
.catch(reportError);
|
||||
|
||||
isLongerThan(waitPromise, 400).then((slow) => slow && modelObs.set("slow")).catch(() => {});
|
||||
|
||||
return buildUserManagerModal(modelObs, onConfirm, options);
|
||||
}
|
||||
|
||||
function buildUserManagerModal(
|
||||
modelObs: Observable<UserManagerModel|null>,
|
||||
modelObs: Observable<UserManagerModel|null|"slow">,
|
||||
onConfirm: (ctl: IModalControl) => Promise<void>,
|
||||
options: IUserManagerOptions
|
||||
) {
|
||||
@ -127,19 +130,20 @@ function buildUserManagerModal(
|
||||
// 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,
|
||||
dom.maybe(modelObs, model => cssTitle(
|
||||
renderTitle(options.resourceType, options.resource, model.isPersonal),
|
||||
(options.resourceType === 'document' && (!model.isPersonal || model.isPublicMember)
|
||||
? makeCopyBtn(options.linkToCopy, cssCopyBtn.cls('-header'))
|
||||
: null
|
||||
),
|
||||
testId('um-header'),
|
||||
)),
|
||||
dom.domComputed(modelObs, model => {
|
||||
if (!model) { return cssSpinner(loadingSpinner()); }
|
||||
if (!model) { return null; }
|
||||
if (model === "slow") { return cssSpinner(loadingSpinner()); }
|
||||
|
||||
const cssBody = model.isPersonal ? cssAccessDetailsBody : cssUserManagerBody;
|
||||
return [
|
||||
cssTitle(
|
||||
renderTitle(options.resourceType, options.resource, model.isPersonal),
|
||||
(options.resourceType === 'document' && (!model.isPersonal || model.isPublicMember)
|
||||
? makeCopyBtn(options.linkToCopy, cssCopyBtn.cls('-header'))
|
||||
: null
|
||||
),
|
||||
testId('um-header'),
|
||||
),
|
||||
cssModalBody(
|
||||
cssBody(
|
||||
new UserManager(
|
||||
|
@ -39,7 +39,8 @@ export const cssButton = styled('button', `
|
||||
|
||||
&-large {
|
||||
font-weight: 500;
|
||||
padding: 12px 24px;
|
||||
padding: 10px 24px;
|
||||
min-height: 40px;
|
||||
}
|
||||
|
||||
&-primary {
|
||||
|
@ -43,6 +43,7 @@ export const colors = {
|
||||
|
||||
lighterBlue: new CustomProp('color-lighter-blue', '#87b2f9'),
|
||||
lightBlue: new CustomProp('color-light-blue', '#3B82F6'),
|
||||
orange: new CustomProp('color-orange', '#F9AE41'),
|
||||
|
||||
cursor: new CustomProp('color-cursor', '#16B378'), // cursor is lightGreen
|
||||
selection: new CustomProp('color-selection', 'rgba(22,179,120,0.15)'),
|
||||
@ -74,6 +75,7 @@ export const vars = {
|
||||
xsmallFontSize: new CustomProp('x-small-font-size', '10px'),
|
||||
smallFontSize: new CustomProp('small-font-size', '11px'),
|
||||
mediumFontSize: new CustomProp('medium-font-size', '13px'),
|
||||
introFontSize: new CustomProp('intro-font-size', '14px'), // feels friendlier
|
||||
largeFontSize: new CustomProp('large-font-size', '16px'),
|
||||
xlargeFontSize: new CustomProp('x-large-font-size', '18px'),
|
||||
xxlargeFontSize: new CustomProp('xx-large-font-size', '20px'),
|
||||
|
@ -5,7 +5,8 @@ import {bigBasicButton, bigPrimaryButton, cssButton} from 'app/client/ui2018/but
|
||||
import {colors, mediaSmall, testId, vars} from 'app/client/ui2018/cssVars';
|
||||
import {loadingSpinner} from 'app/client/ui2018/loaders';
|
||||
import {waitGrainObs} from 'app/common/gutil';
|
||||
import {Computed, Disposable, dom, DomContents, DomElementArg, input, MultiHolder, Observable, styled} from 'grainjs';
|
||||
import {Computed, Disposable, dom, DomContents, DomElementArg, input, keyframes,
|
||||
MultiHolder, Observable, styled} from 'grainjs';
|
||||
|
||||
// IModalControl is passed into the function creating the body of the modal.
|
||||
export interface IModalControl {
|
||||
@ -466,6 +467,10 @@ export const cssModalButtons = styled('div', `
|
||||
}
|
||||
`);
|
||||
|
||||
const cssFadeIn = keyframes(`
|
||||
from {background-color: transparent}
|
||||
`);
|
||||
|
||||
const cssModalBacker = styled('div', `
|
||||
position: fixed;
|
||||
display: flex;
|
||||
@ -478,6 +483,8 @@ const cssModalBacker = styled('div', `
|
||||
z-index: 999;
|
||||
background-color: ${colors.backdrop};
|
||||
overflow-y: auto;
|
||||
animation-name: ${cssFadeIn};
|
||||
animation-duration: 0.4s;
|
||||
`);
|
||||
|
||||
const cssSpinner = styled('div', `
|
||||
|
@ -84,6 +84,14 @@ export function getOrgName(org: Organization): string {
|
||||
return org.owner ? `@` + org.owner.name : org.name;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns whether the given org is the templates org, which contains the public templates.
|
||||
*/
|
||||
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 type WorkspaceProperties = CommonProperties;
|
||||
export const workspacePropertyKeys = ['createdAt', 'name', 'updatedAt'];
|
||||
|
||||
|
@ -60,6 +60,7 @@ export const commonUrls = {
|
||||
help: "https://support.getgrist.com",
|
||||
plans: "https://www.getgrist.com/pricing",
|
||||
createTeamSite: "https://www.getgrist.com/create-team-site",
|
||||
sproutsProgram: "https://www.getgrist.com/sprouts-program",
|
||||
|
||||
efcrConnect: 'https://efc-r.com/connect',
|
||||
efcrHelp: 'https://www.nioxus.info/eFCR-Help',
|
||||
|
@ -21,10 +21,10 @@ describe('HomeIntro', function() {
|
||||
|
||||
// Check message specific to anon
|
||||
assert.equal(await driver.find('.test-welcome-title').getText(), 'Welcome to Grist!');
|
||||
assert.match(await driver.find('.test-welcome-text').getText(), /without logging in.*need to sign up/);
|
||||
assert.match(await driver.find('.test-welcome-text').getText(), /Sign up.*Visit our Help Center/);
|
||||
|
||||
// Check the sign-up link.
|
||||
const signUp = await driver.findContent('.test-welcome-text a', 'sign up');
|
||||
const signUp = await driver.findContent('.test-welcome-text a', 'Sign up');
|
||||
assert.include(await signUp.getAttribute('href'), '/signin');
|
||||
|
||||
// Check that the link takes us to a Grist login page.
|
||||
@ -34,21 +34,7 @@ describe('HomeIntro', function() {
|
||||
await gu.waitForDocMenuToLoad();
|
||||
});
|
||||
|
||||
// Check intro screen.
|
||||
it('should should intro screen for anon, with video thumbnail', async function() {
|
||||
// Check image for first video.
|
||||
assert.equal(await driver.find('.test-intro-image img').isPresent(), true);
|
||||
await checkImageLoaded(driver.find('.test-intro-image img'));
|
||||
|
||||
// Check links to first video in image and title.
|
||||
assert.include(await driver.find('.test-intro-image img').findClosest('a').getAttribute('href'),
|
||||
'support.getgrist.com');
|
||||
|
||||
// Check link to Help Center
|
||||
assert.include(await driver.findContent('.test-welcome-text a', /Help Center/).getAttribute('href'),
|
||||
'support.getgrist.com');
|
||||
});
|
||||
|
||||
it('should should intro screen for anon', () => testIntroScreen({team: false}));
|
||||
it('should not show Other Sites section', testOtherSitesSection);
|
||||
it('should allow create/import from intro screen', testCreateImport.bind(null, false));
|
||||
it('should allow collapsing examples and remember the state', testExamplesCollapsing);
|
||||
@ -70,12 +56,12 @@ describe('HomeIntro', function() {
|
||||
|
||||
// Check message specific to logged-in user
|
||||
assert.match(await driver.find('.test-welcome-title').getText(), new RegExp(`Welcome.* ${session.name}`));
|
||||
assert.match(await driver.find('.test-welcome-text').getText(), /Watch video/);
|
||||
assert.notMatch(await driver.find('.test-welcome-text').getText(), /sign up/);
|
||||
assert.match(await driver.find('.test-welcome-text').getText(), /Visit our Help Center/);
|
||||
assert.notMatch(await driver.find('.test-welcome-text').getText(), /sign up/i);
|
||||
});
|
||||
|
||||
it('should not show Other Sites section', testOtherSitesSection);
|
||||
it('should show intro screen for empty org', testIntroScreenLoggedIn);
|
||||
it('should show intro screen for empty org', () => testIntroScreen({team: false}));
|
||||
it('should allow create/import from intro screen', testCreateImport.bind(null, true));
|
||||
it('should allow collapsing examples and remember the state', testExamplesCollapsing);
|
||||
it('should show examples workspace with the intro', testExamplesSection);
|
||||
@ -93,14 +79,15 @@ describe('HomeIntro', function() {
|
||||
// Open doc-menu
|
||||
await session.loadDocMenu('/', 'skipWelcomeQuestions');
|
||||
|
||||
// Check message specific to logged-in user
|
||||
assert.match(await driver.find('.test-welcome-title').getText(), new RegExp(`Welcome.* ${session.name}`));
|
||||
assert.match(await driver.find('.test-welcome-text').getText(), /Watch video/);
|
||||
// Check message specific to logged-in user and an empty team site.
|
||||
assert.match(await driver.find('.test-welcome-title').getText(), new RegExp(`Welcome.* ${session.orgName}`));
|
||||
assert.match(await driver.find('.test-welcome-text').getText(), /Learn more.*find an expert/);
|
||||
assert.notMatch(await driver.find('.test-welcome-text').getText(), /sign up/);
|
||||
});
|
||||
|
||||
it('should not show Other Sites section', testOtherSitesSection);
|
||||
it('should show intro screen for empty org', testIntroScreenLoggedIn);
|
||||
it('should show intro screen for empty org', () => testIntroScreen({team: true}));
|
||||
it('should allow create/import from intro screen', testCreateImport.bind(null, true));
|
||||
it('should show examples workspace with the intro', testExamplesSection);
|
||||
it('should allow copying examples', testCopyingExamples.bind(null, gu.session().teamSite.orgName));
|
||||
it('should render selected Examples workspace specially', testSelectedExamplesPage);
|
||||
@ -111,18 +98,23 @@ describe('HomeIntro', function() {
|
||||
assert.isFalse(await driver.find('.test-dm-other-sites-header').isPresent());
|
||||
}
|
||||
|
||||
async function testIntroScreenLoggedIn() {
|
||||
// Check image for first video.
|
||||
assert.equal(await driver.find('.test-intro-image img').isPresent(), true);
|
||||
await checkImageLoaded(driver.find('.test-intro-image img'));
|
||||
|
||||
// Check link to first video in welcome text
|
||||
assert.include(await driver.findContent('.test-welcome-text a', /creating a document/).getAttribute('href'),
|
||||
'support.getgrist.com');
|
||||
async function testIntroScreen(options: {team: boolean}) {
|
||||
// TODO There is no longer a thumbnail + video link on an empty site, but it's a good place to
|
||||
// check for the presence and functionality of the planned links that open an intro video.
|
||||
|
||||
// Check link to Help Center
|
||||
assert.include(await driver.findContent('.test-welcome-text a', /Help Center/).getAttribute('href'),
|
||||
'support.getgrist.com');
|
||||
|
||||
if (options.team) {
|
||||
assert.equal(await driver.find('.test-intro-invite').getText(), 'Invite Team Members');
|
||||
assert.equal(await driver.find('.test-topbar-manage-team').getText(), 'Manage Team');
|
||||
} else {
|
||||
assert.equal(await driver.find('.test-intro-invite').isPresent(), false);
|
||||
assert.equal(await driver.find('.test-topbar-manage-team').isPresent(), false);
|
||||
assert.equal(await driver.find('.test-intro-templates').getText(), 'Browse Templates');
|
||||
assert.include(await driver.find('.test-intro-templates').getAttribute('href'), '/p/templates');
|
||||
}
|
||||
}
|
||||
|
||||
async function testCreateImport(isLoggedIn: boolean) {
|
||||
|
Loading…
Reference in New Issue
Block a user