(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:
Dmitry S 2022-06-03 10:58:07 -04:00
parent af4738b94a
commit acddd25cfd
16 changed files with 248 additions and 167 deletions

View File

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

View File

@ -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 ?

View File

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

View File

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

View File

@ -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};

View File

@ -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, youll 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;
`);

View File

@ -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.

View 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);
}
}

View File

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

View File

@ -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(
dom.domComputed(modelObs, model => {
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'),
)),
dom.domComputed(modelObs, model => {
if (!model) { return cssSpinner(loadingSpinner()); }
const cssBody = model.isPersonal ? cssAccessDetailsBody : cssUserManagerBody;
return [
),
cssModalBody(
cssBody(
new UserManager(

View File

@ -39,7 +39,8 @@ export const cssButton = styled('button', `
&-large {
font-weight: 500;
padding: 12px 24px;
padding: 10px 24px;
min-height: 40px;
}
&-primary {

View File

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

View File

@ -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', `

View File

@ -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'];

View File

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

View File

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