From acddd25cfd2b388a6bcefb38ef458359504659d7 Mon Sep 17 00:00:00 2001 From: Dmitry S Date: Fri, 3 Jun 2022 10:58:07 -0400 Subject: [PATCH] (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 --- app/client/models/AppModel.ts | 5 ++ app/client/ui/AccountWidget.ts | 24 ++--- app/client/ui/AppHeader.ts | 79 ++++++++++++---- app/client/ui/DocMenu.ts | 4 +- app/client/ui/DocMenuCss.ts | 2 +- app/client/ui/HomeIntro.ts | 150 +++++++++++++++---------------- app/client/ui/MakeCopyMenu.ts | 5 +- app/client/ui/OpenUserManager.ts | 21 +++++ app/client/ui/TopBar.ts | 16 ++++ app/client/ui/UserManager.ts | 30 ++++--- app/client/ui2018/buttons.ts | 3 +- app/client/ui2018/cssVars.ts | 2 + app/client/ui2018/modals.ts | 9 +- app/common/UserAPI.ts | 8 ++ app/common/gristUrls.ts | 1 + test/nbrowser/HomeIntro.ts | 56 +++++------- 16 files changed, 248 insertions(+), 167 deletions(-) create mode 100644 app/client/ui/OpenUserManager.ts diff --git a/app/client/models/AppModel.ts b/app/client/models/AppModel.ts index e3512bea..4c3b74a2 100644 --- a/app/client/models/AppModel.ts +++ b/app/client/models/AppModel.ts @@ -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 : {}; diff --git a/app/client/ui/AccountWidget.ts b/app/client/ui/AccountWidget.ts index a42f9524..6c7133b6 100644 --- a/app/client/ui/AccountWidget.ts +++ b/app/client/ui/AccountWidget.ts @@ -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 ? diff --git a/app/client/ui/AppHeader.ts b/app/client/ui/AppHeader.ts index 494e8e80..3a639b3e 100644 --- a/app/client/ui/AppHeader.ts +++ b/app/client/ui/AppHeader.ts @@ -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, 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}; + } +`); diff --git a/app/client/ui/DocMenu.ts b/app/client/ui/DocMenu.ts index d86c8770..df94931e 100644 --- a/app/client/ui/DocMenu.ts +++ b/app/client/ui/DocMenu.ts @@ -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, () => [ diff --git a/app/client/ui/DocMenuCss.ts b/app/client/ui/DocMenuCss.ts index ffed90f0..5f4436ba 100644 --- a/app/client/ui/DocMenuCss.ts +++ b/app/client/ui/DocMenuCss.ts @@ -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}; diff --git a/app/client/ui/HomeIntro.ts b/app/client/ui/HomeIntro.ts index b347e34e..d6051074 100644 --- a/app/client/ui/HomeIntro.ts +++ b/app/client/ui/HomeIntro.ts @@ -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 = - (...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; +`); diff --git a/app/client/ui/MakeCopyMenu.ts b/app/client/ui/MakeCopyMenu.ts index f8b15ee3..4f324abc 100644 --- a/app/client/ui/MakeCopyMenu.ts +++ b/app/client/ui/MakeCopyMenu.ts @@ -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. diff --git a/app/client/ui/OpenUserManager.ts b/app/client/ui/OpenUserManager.ts new file mode 100644 index 00000000..3768b0ba --- /dev/null +++ b/app/client/ui/OpenUserManager.ts @@ -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); + } +} diff --git a/app/client/ui/TopBar.ts b/app/client/ui/TopBar.ts index a310ce81..6358dc8a 100644 --- a/app/client/ui/TopBar.ts +++ b/app/client/ui/TopBar.ts @@ -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)), ]; diff --git a/app/client/ui/UserManager.ts b/app/client/ui/UserManager.ts index 66588003..fa2a4631 100644 --- a/app/client/ui/UserManager.ts +++ b/app/client/ui/UserManager.ts @@ -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 = observable(null); + const modelObs: Observable = 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, + modelObs: Observable, onConfirm: (ctl: IModalControl) => Promise, 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( diff --git a/app/client/ui2018/buttons.ts b/app/client/ui2018/buttons.ts index 84a88108..b38c7591 100644 --- a/app/client/ui2018/buttons.ts +++ b/app/client/ui2018/buttons.ts @@ -39,7 +39,8 @@ export const cssButton = styled('button', ` &-large { font-weight: 500; - padding: 12px 24px; + padding: 10px 24px; + min-height: 40px; } &-primary { diff --git a/app/client/ui2018/cssVars.ts b/app/client/ui2018/cssVars.ts index b5eb67c4..6ee87cc0 100644 --- a/app/client/ui2018/cssVars.ts +++ b/app/client/ui2018/cssVars.ts @@ -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'), diff --git a/app/client/ui2018/modals.ts b/app/client/ui2018/modals.ts index 42f20f5f..faeacc1f 100644 --- a/app/client/ui2018/modals.ts +++ b/app/client/ui2018/modals.ts @@ -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', ` diff --git a/app/common/UserAPI.ts b/app/common/UserAPI.ts index 06e10f13..3ceed634 100644 --- a/app/common/UserAPI.ts +++ b/app/common/UserAPI.ts @@ -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']; diff --git a/app/common/gristUrls.ts b/app/common/gristUrls.ts index 0164ff86..8e771027 100644 --- a/app/common/gristUrls.ts +++ b/app/common/gristUrls.ts @@ -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', diff --git a/test/nbrowser/HomeIntro.ts b/test/nbrowser/HomeIntro.ts index d0e1e3a8..36e73612 100644 --- a/test/nbrowser/HomeIntro.ts +++ b/test/nbrowser/HomeIntro.ts @@ -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) {