(core) Add new home page cards

Summary:
New cards on the home page link to useful resources like the welcome
video, tutorial, webinars, and the Help Center. They are shown by
default to new and exisiting users, and may be hidden via a toggle.

Test Plan: Browser tests.

Reviewers: jarek

Reviewed By: jarek

Differential Revision: https://phab.getgrist.com/D4340
This commit is contained in:
George Gevoian
2024-09-12 13:10:55 -04:00
parent 839bf63b9f
commit da6c39aa50
34 changed files with 1055 additions and 999 deletions

View File

@@ -5,6 +5,7 @@ import {sanitizeHTML} from 'app/client/ui/sanitizeHTML';
import {dropdownWithSearch} from 'app/client/ui/searchDropdown';
import {isXSmallScreenObs} from 'app/client/ui2018/cssVars';
import {confirmModal} from 'app/client/ui2018/modals';
import {toggleSwitch} from 'app/client/ui2018/toggleSwitch';
import {CellValue} from 'app/plugin/GristData';
import {Disposable, dom, DomContents, makeTestId, MutableObsArray, obsArray, Observable} from 'grainjs';
import {marked} from 'marked';
@@ -528,32 +529,21 @@ class BoolRenderer extends BaseFieldRenderer {
}
private _renderSwitchInput() {
return css.toggleSwitch(
dom('input',
dom.prop('checked', this.checked),
dom.prop('value', use => use(this.checked) ? '1' : '0'),
dom.on('change', (_e, elem) => this.checked.set(elem.checked)),
{
type: this.inputType,
name: this.name(),
required: this.field.options.formRequired,
},
return toggleSwitch(this. checked, {
label: this.field.question,
inputArgs: [
{name: this.name(), required: this.field.options.formRequired},
preventSubmitOnEnter(),
),
css.gristSwitch(
css.gristSwitchSlider(),
css.gristSwitchCircle(),
),
css.toggleLabel(
],
labelArgs: [
css.label.cls('-required', Boolean(this.field.options.formRequired)),
this.field.question,
),
);
],
});
}
private _renderCheckboxInput() {
return css.toggle(
dom('input',
css.checkboxInput(
dom.prop('checked', this.checked),
dom.prop('value', use => use(this.checked) ? '1' : '0'),
dom.on('change', (_e, elem) => this.checked.set(elem.checked)),
@@ -613,7 +603,7 @@ class ChoiceListRenderer extends BaseFieldRenderer {
{name: this.name(), required},
dom.forEach(this.checkboxes, (checkbox) =>
css.checkbox(
dom('input',
css.checkboxInput(
dom.prop('checked', checkbox.checked),
dom.on('change', (_e, elem) => checkbox.checked.set(elem.value)),
{
@@ -674,7 +664,7 @@ class RefListRenderer extends BaseFieldRenderer {
{name: this.name(), required},
dom.forEach(this.checkboxes, (checkbox) =>
css.checkbox(
dom('input',
css.checkboxInput(
dom.prop('checked', checkbox.checked),
dom.on('change', (_e, elem) => checkbox.checked.set(elem.value)),
{

View File

@@ -102,73 +102,12 @@ export const submitButton = styled('div', `
}
`);
// TODO: break up into multiple variables, one for each field type.
export const field = styled('div', `
display: flex;
flex-direction: column;
height: 100%;
justify-content: space-between;
& input[type="checkbox"] {
-webkit-appearance: none;
-moz-appearance: none;
margin: 0;
padding: 0;
flex-shrink: 0;
display: inline-block;
width: 16px;
height: 16px;
--radius: 3px;
position: relative;
margin-right: 8px;
vertical-align: baseline;
}
& input[type="checkbox"]:focus {
outline-color: ${vars.primaryBgHover};
}
& input[type="checkbox"]:checked:enabled,
& input[type="checkbox"]:indeterminate:enabled {
--color: ${vars.primaryBg};
}
& input[type="checkbox"]:disabled {
--color: ${colors.darkGrey};
cursor: not-allowed;
}
& input[type="checkbox"]::before,
& input[type="checkbox"]::after {
content: '';
position: absolute;
top: 0;
left: 0;
height: 16px;
width: 16px;
box-sizing: border-box;
border: 1px solid var(--color, ${colors.darkGrey});
border-radius: var(--radius);
}
& input[type="checkbox"]:checked::before,
& input[type="checkbox"]:disabled::before,
& input[type="checkbox"]:indeterminate::before {
background-color: var(--color);
}
& input[type="checkbox"]:not(:checked):indeterminate::after {
-webkit-mask-image: var(--icon-Minus);
}
& input[type="checkbox"]:not(:disabled)::after {
background-color: ${colors.light};
}
& input[type="checkbox"]:checked::after,
& input[type="checkbox"]:indeterminate::after {
content: '';
position: absolute;
height: 16px;
width: 16px;
-webkit-mask-image: var(--icon-Tick);
-webkit-mask-size: contain;
-webkit-mask-position: center;
-webkit-mask-repeat: no-repeat;
background-color: ${colors.light};
}
& > .${label.className} {
color: ${colors.dark};
font-size: 13px;
@@ -217,6 +156,63 @@ export const textarea = styled('textarea', `
resize: none;
`);
export const checkboxInput = styled('input', `
-webkit-appearance: none;
-moz-appearance: none;
margin: 0;
padding: 0;
flex-shrink: 0;
display: inline-block;
width: 16px;
height: 16px;
--radius: 3px;
position: relative;
margin-right: 8px;
vertical-align: baseline;
&:focus {
outline-color: ${vars.primaryBgHover};
}
&:checked:enabled, &:indeterminate:enabled {
--color: ${vars.primaryBg};
}
&:disabled {
--color: ${colors.darkGrey};
cursor: not-allowed;
}
&::before, &::after {
content: '';
position: absolute;
top: 0;
left: 0;
height: 16px;
width: 16px;
box-sizing: border-box;
border: 1px solid var(--color, ${colors.darkGrey});
border-radius: var(--radius);
}
&:checked::before, &:disabled::before, &:indeterminate::before {
background-color: var(--color);
}
&:not(:checked):indeterminate::after {
-webkit-mask-image: var(--icon-Minus);
}
&:not(:disabled)::after {
background-color: ${colors.light};
}
&:checked::after, &:indeterminate::after {
content: '';
position: absolute;
height: 16px;
width: 16px;
-webkit-mask-image: var(--icon-Tick);
-webkit-mask-size: contain;
-webkit-mask-position: center;
-webkit-mask-repeat: no-repeat;
background-color: ${colors.light};
}
`);
export const spinner = styled(numericSpinner, `
& input {
height: 29px;
@@ -240,32 +236,6 @@ export const toggle = styled('label', `
}
`);
export const toggleSwitch = styled(toggle, `
cursor: pointer;
& input[type='checkbox'] {
margin: 0;
position: absolute;
top: 1px;
left: 4px;
}
& input[type='checkbox'],
& input[type='checkbox']::before,
& input[type='checkbox']::after {
height: 1px;
width: 1px;
}
& input[type='checkbox']:focus {
outline: none;
}
& input[type='checkbox']:focus {
outline: none;
}
& > span {
margin-left: 8px;
}
`);
export const toggleLabel = styled('span', `
font-size: 13px;
font-weight: 700;
@@ -273,56 +243,6 @@ export const toggleLabel = styled('span', `
overflow-wrap: anywhere;
`);
export const gristSwitchSlider = styled('div', `
position: absolute;
cursor: pointer;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-color: #ccc;
border-radius: 17px;
-webkit-transition: background-color .4s;
transition: background-color .4s;
`);
export const gristSwitchCircle = styled('div', `
position: absolute;
cursor: pointer;
content: "";
height: 13px;
width: 13px;
left: 2px;
bottom: 2px;
background-color: white;
border-radius: 17px;
-webkit-transition: transform .4s;
transition: transform .4s;
`);
export const gristSwitch = styled('div', `
position: relative;
width: 30px;
height: 17px;
display: inline-block;
flex: none;
input:focus + & > .${gristSwitchSlider.className} {
outline: 2px solid ${vars.primaryBgHover};
outline-offset: 1px;
}
input:checked + & > .${gristSwitchSlider.className} {
background-color: ${vars.primaryBg};
}
input:checked + & > .${gristSwitchCircle.className} {
-webkit-transform: translateX(13px);
-ms-transform: translateX(13px);
transform: translateX(13px);
}
`);
export const checkboxList = styled('div', `
display: inline-flex;
flex-direction: column;

View File

@@ -9,7 +9,6 @@ import {urlState} from 'app/client/models/gristUrlState';
import {Notifier} from 'app/client/models/NotifyModel';
import {getFlavor, ProductFlavor} from 'app/client/ui/CustomThemes';
import {buildNewSiteModal, buildUpgradeModal} from 'app/client/ui/ProductUpgrades';
import {SupportGristNudge} from 'app/client/ui/SupportGristNudge';
import {gristThemePrefs} from 'app/client/ui2018/theme';
import {AsyncCreate} from 'app/common/AsyncCreate';
import {PlanSelection} from 'app/common/BillingAPI';
@@ -130,8 +129,6 @@ export interface AppModel {
behavioralPromptsManager: BehavioralPromptsManager;
supportGristNudge: SupportGristNudge;
refreshOrgUsage(): Promise<void>;
showUpgradeModal(): Promise<void>;
showNewSiteModal(): Promise<void>;
@@ -351,8 +348,6 @@ export class AppModelImpl extends Disposable implements AppModel {
public readonly behavioralPromptsManager: BehavioralPromptsManager =
BehavioralPromptsManager.create(this, this);
public readonly supportGristNudge: SupportGristNudge = SupportGristNudge.create(this, this);
constructor(
public readonly topAppModel: TopAppModel,
public readonly currentUser: ExtendedUser|null,

View File

@@ -6,6 +6,7 @@ import {localStorageObs} from 'app/client/lib/localStorageObs';
import {AppModel, reportError} from 'app/client/models/AppModel';
import {reportMessage, UserError} from 'app/client/models/errors';
import {urlState} from 'app/client/models/gristUrlState';
import {getUserPrefObs} from 'app/client/models/UserPrefs';
import {ownerName} from 'app/client/models/WorkspaceInfo';
import {IHomePage, isFeatureEnabled} from 'app/common/gristUrls';
import {isLongerThan} from 'app/common/gutil';
@@ -31,7 +32,7 @@ export interface HomeModel {
workspaces: Observable<Workspace[]>;
loading: Observable<boolean|"slow">; // Set to "slow" when loading for a while.
available: Observable<boolean>; // set if workspaces loaded correctly.
showIntro: Observable<boolean>; // set if no docs and we should show intro.
empty: Observable<boolean>; // set if no docs.
singleWorkspace: Observable<boolean>; // set if workspace name should be hidden.
trashWorkspaces: Observable<Workspace[]>; // only set when viewing trash
templateWorkspaces: Observable<Workspace[]>; // Only set when viewing templates or all documents.
@@ -49,6 +50,8 @@ export interface HomeModel {
// the current org is a personal org, or the current org is view access only.
otherSites: Observable<Organization[]>;
onlyShowDocuments: Observable<boolean>;
currentSort: Observable<SortPref>;
currentView: Observable<ViewPref>;
importSources: Observable<ImportSourceElement[]>;
@@ -123,21 +126,26 @@ export class HomeModelImpl extends Disposable implements HomeModel, ViewSettings
return orgs.filter(org => org.id !== currentOrg.id);
});
public readonly onlyShowDocuments = getUserPrefObs(this.app.userPrefsObs, 'onlyShowDocuments', {
defaultValue: false,
}) as Observable<boolean>;
public readonly currentSort: Observable<SortPref>;
public readonly currentView: Observable<ViewPref>;
// The workspace for new docs, or "unsaved" to only allow unsaved-doc creation, or null if the
// user isn't allowed to create a doc.
public readonly newDocWorkspace = Computed.create(this, this.currentPage, this.currentWS, (use, page, ws) => {
// Anonymous user can create docs, but in unsaved mode.
if (!this.app.currentValidUser) { return "unsaved"; }
if (!this.app.currentValidUser) {
// Anonymous user can create docs, but in unsaved mode and only when enabled.
return getGristConfig().enableAnonPlayground ? 'unsaved' : null;
}
if (page === 'trash') { return null; }
const destWS = (['all', 'templates'].includes(page)) ? (use(this.workspaces)[0] || null) : ws;
return destWS && roles.canEdit(destWS.access) ? destWS : null;
});
// Whether to show intro: no docs (other than examples).
public readonly showIntro = Computed.create(this, this.workspaces, (use, wss) => (
public readonly empty = Computed.create(this, this.workspaces, (use, wss) => (
wss.every((ws) => ws.isSupportWorkspace || ws.docs.length === 0)));
public readonly shouldShowAddNewTip = Observable.create(this,
@@ -343,22 +351,16 @@ export class HomeModelImpl extends Disposable implements HomeModel, ViewSettings
}
/**
* Fetches templates if on the Templates or All Documents page.
*
* Only fetches featured (pinned) templates on the All Documents page.
* Fetches templates if on the Templates page.
*/
private async _maybeFetchTemplates(): Promise<Workspace[] | null> {
const {templateOrg} = getGristConfig();
if (!templateOrg) { return null; }
const currentPage = this.currentPage.get();
const shouldFetchTemplates = ['all', 'templates'].includes(currentPage);
if (!shouldFetchTemplates) { return null; }
if (!getGristConfig().templateOrg || this.currentPage.get() !== 'templates') {
return null;
}
let templateWss: Workspace[] = [];
try {
const onlyFeatured = currentPage === 'all';
templateWss = await this._app.api.getTemplates(onlyFeatured);
templateWss = await this._app.api.getTemplates();
} catch {
reportError('Failed to load templates');
}
@@ -381,8 +383,7 @@ export class HomeModelImpl extends Disposable implements HomeModel, ViewSettings
if (
!isFeatureEnabled('tutorials') ||
!templateOrg ||
!onboardingTutorialDocId ||
this._app.dismissedPopups.get().includes('onboardingCards')
!onboardingTutorialDocId
) {
return;
}

View File

@@ -14,8 +14,10 @@ function shouldShowAddNewTip(home: HomeModel): boolean {
home.app.isOwnerOrEditor() &&
// And the tip hasn't been shown before.
home.shouldShowAddNewTip.get() &&
// And the intro isn't being shown.
!home.showIntro.get() &&
// And the site isn't empty.
!home.empty.get() &&
// And home page cards aren't being shown.
!(home.currentPage.get() === 'all' && !home.onlyShowDocuments.get()) &&
// And the workspace loaded correctly.
home.available.get() &&
// And the current page isn't /p/trash; the Add New button is limited there.

View File

@@ -1,4 +1,5 @@
import {autoFocus} from 'app/client/lib/domUtils';
import {makeT} from 'app/client/lib/localization';
import {ValidationGroup, Validator} from 'app/client/lib/Validator';
import {AppModel, getHomeUrl} from 'app/client/models/AppModel';
import {reportError, UserError} from 'app/client/models/errors';
@@ -11,11 +12,7 @@ import {TEAM_PLAN} from 'app/common/Features';
import {checkSubdomainValidity} from 'app/common/orgNameUtils';
import {UserAPIImpl} from 'app/common/UserAPI';
import {PlanSelection} from 'app/common/BillingAPI';
import {
Disposable, dom, DomArg, DomContents, DomElementArg, IDisposableOwner, input, makeTestId,
Observable, styled
} from 'grainjs';
import { makeT } from '../lib/localization';
import {Disposable, dom, DomContents, DomElementArg, input, makeTestId, Observable, styled} from 'grainjs';
const t = makeT('CreateTeamModal');
const testId = makeTestId('test-create-team-');
@@ -87,16 +84,12 @@ export function buildUpgradeModal(owner: Disposable, options: {
throw new UserError(t(`Billing is not supported in grist-core`));
}
export interface IUpgradeButton {
showUpgradeCard(...args: DomArg<HTMLElement>[]): DomContents;
showUpgradeButton(...args: DomArg<HTMLElement>[]): DomContents;
}
export class UpgradeButton extends Disposable {
constructor(_appModel: AppModel) {
super();
}
export function buildUpgradeButton(owner: IDisposableOwner, app: AppModel): IUpgradeButton {
return {
showUpgradeCard: () => null,
showUpgradeButton: () => null,
};
public buildDom() { return null; }
}
export function buildConfirm({

View File

@@ -4,6 +4,7 @@
* Orgs, workspaces and docs are fetched asynchronously on build via the passed in API.
*/
import {loadUserManager} from 'app/client/lib/imports';
import {makeT} from 'app/client/lib/localization';
import {getTimeFromNow} from 'app/client/lib/timeUtils';
import {reportError} from 'app/client/models/AppModel';
import {docUrl, urlState} from 'app/client/models/gristUrlState';
@@ -12,17 +13,18 @@ import {getWorkspaceInfo, workspaceName} from 'app/client/models/WorkspaceInfo';
import {attachAddNewTip} from 'app/client/ui/AddNewTip';
import * as css from 'app/client/ui/DocMenuCss';
import {buildHomeIntro, buildWorkspaceIntro} from 'app/client/ui/HomeIntro';
import {buildUpgradeButton} from 'app/client/ui/ProductUpgrades';
import {newDocMethods} from 'app/client/ui/NewDocMethods';
import {buildPinnedDoc, createPinnedDocs} from 'app/client/ui/PinnedDocs';
import {shadowScroll} from 'app/client/ui/shadowScroll';
import {makeShareDocUrl} from 'app/client/ui/ShareMenu';
import {buildTemplateDocs} from 'app/client/ui/TemplateDocs';
import {transition} from 'app/client/ui/transitions';
import {shouldShowWelcomeCoachingCall, showWelcomeCoachingCall} from 'app/client/ui/WelcomeCoachingCall';
import {createVideoTourTextButton} from 'app/client/ui/OpenVideoTour';
import {bigPrimaryButton} from 'app/client/ui2018/buttons';
import {buttonSelect, cssButtonSelect} from 'app/client/ui2018/buttonSelect';
import {isNarrowScreenObs, theme} from 'app/client/ui2018/cssVars';
import {buildOnboardingCards} from 'app/client/ui/OnboardingCards';
import {theme} from 'app/client/ui2018/cssVars';
import {icon} from 'app/client/ui2018/icons';
import {cssLink} from 'app/client/ui2018/links';
import {loadingSpinner} from 'app/client/ui2018/loaders';
import {menu, menuItem, menuText, select} from 'app/client/ui2018/menus';
import {confirmModal, saveModal} from 'app/client/ui2018/modals';
@@ -30,12 +32,17 @@ import {IHomePage} from 'app/common/gristUrls';
import {SortPref, ViewPref} from 'app/common/Prefs';
import * as roles from 'app/common/roles';
import {Document, Workspace} from 'app/common/UserAPI';
import {computed, Computed, dom, DomArg, DomContents, DomElementArg, IDisposableOwner,
makeTestId, observable, Observable} from 'grainjs';
import {buildTemplateDocs} from 'app/client/ui/TemplateDocs';
import {makeT} from 'app/client/lib/localization';
import {localStorageBoolObs} from 'app/client/lib/localStorageObs';
import {bigBasicButton} from 'app/client/ui2018/buttons';
import {
computed,
Computed,
dom,
DomArg,
DomElementArg,
IDisposableOwner,
makeTestId,
observable,
Observable,
} from 'grainjs';
import sortBy = require('lodash/sortBy');
const t = makeT(`DocMenu`);
@@ -70,10 +77,7 @@ function attachWelcomePopups(home: HomeModel): (el: Element) => void {
function createLoadedDocMenu(owner: IDisposableOwner, home: HomeModel) {
const flashDocId = observable<string|null>(null);
const upgradeButton = buildUpgradeButton(owner, home.app);
return css.docList( /* vbox */
/* first line */
dom.create(buildOnboardingCards, {homeModel: home}),
/* hbox */
css.docListContent(
/* left column - grow 1 */
@@ -85,24 +89,16 @@ function createLoadedDocMenu(owner: IDisposableOwner, home: HomeModel) {
dom('span', t("(The organization needs a paid plan)")),
]),
// currentWS and showIntro observables change together. We capture both in one domComputed call.
dom.domComputed<[IHomePage, Workspace|undefined, boolean]>(
(use) => [use(home.currentPage), use(home.currentWS), use(home.showIntro)],
([page, workspace, showIntro]) => {
dom.domComputed<[IHomePage, Workspace|undefined]>(
(use) => [use(home.currentPage), use(home.currentWS)],
([page, workspace]) => {
const viewSettings: ViewSettings =
page === 'trash' ? makeLocalViewSettings(home, 'trash') :
page === 'templates' ? makeLocalViewSettings(home, 'templates') :
workspace ? makeLocalViewSettings(home, workspace.id) :
home;
return [
buildPrefs(
viewSettings,
// Hide the sort and view options when showing the intro.
{hideSort: showIntro, hideView: showIntro && page === 'all'},
['all', 'workspace'].includes(page)
? upgradeButton.showUpgradeButton(css.upgradeButton.cls(''))
: null,
),
page !== 'all' ? null : buildHomeIntro(home),
// Build the pinned docs dom. Builds nothing if the selectedOrg is unloaded.
// TODO: this is shown on all pages, but there is a hack in currentWSPinnedDocs that
@@ -124,9 +120,8 @@ function createLoadedDocMenu(owner: IDisposableOwner, home: HomeModel) {
dom.maybe(home.available, () => [
buildOtherSites(home),
(showIntro && page === 'all' ?
null :
css.docListHeader(
page === 'all' && home.app.isPersonal && !home.app.currentValidUser ? null : css.docListHeaderWrap(
css.listHeader(
(
page === 'all' ? t("All Documents") :
page === 'templates' ?
@@ -137,24 +132,20 @@ function createLoadedDocMenu(owner: IDisposableOwner, home: HomeModel) {
workspace && [css.docHeaderIcon('Folder'), workspaceName(home.app, workspace)]
),
testId('doc-header'),
)
),
buildPrefs(viewSettings),
),
(
(page === 'all') ?
dom('div',
showIntro ? buildHomeIntro(home) : null,
buildAllDocsBlock(home, home.workspaces, showIntro, flashDocId, viewSettings),
shouldShowTemplates(home, showIntro) ? buildAllDocsTemplates(home, viewSettings) : null,
) :
(page === 'trash') ?
page === 'all' ? buildAllDocumentsDocsBlock({home, flashDocId, viewSettings}) :
page === 'trash' ?
dom('div',
css.docBlock(t("Documents stay in Trash for 30 days, after which they get deleted permanently.")),
dom.maybe((use) => use(home.trashWorkspaces).length === 0, () =>
css.docBlock(t("Trash is empty."))
),
buildAllDocsBlock(home, home.trashWorkspaces, false, flashDocId, viewSettings),
buildAllDocsBlock(home, home.trashWorkspaces, flashDocId, viewSettings),
) :
(page === 'templates') ?
page === 'templates' ?
dom('div',
buildAllTemplates(home, home.templateWorkspaces, viewSettings)
) :
@@ -172,30 +163,21 @@ function createLoadedDocMenu(owner: IDisposableOwner, home: HomeModel) {
}),
testId('doclist')
),
dom.maybe(use => !use(isNarrowScreenObs()) && ['all', 'workspace'].includes(use(home.currentPage)),
() => {
// TODO: These don't currently clash (grist-core stubs the upgradeButton), but a way to
// manage card popups will be needed if more are added later.
return [
upgradeButton.showUpgradeCard(css.upgradeCard.cls('')),
home.app.supportGristNudge.buildNudgeCard(),
];
}),
),
);
}
function buildAllDocsBlock(
home: HomeModel, workspaces: Observable<Workspace[]>,
showIntro: boolean, flashDocId: Observable<string|null>, viewSettings: ViewSettings,
home: HomeModel,
workspaces: Observable<Workspace[]>,
flashDocId: Observable<string|null>,
viewSettings: ViewSettings
) {
return dom.forEach(workspaces, (ws) => {
// Don't show the support workspace -- examples/templates are now retrieved from a special org.
// TODO: Remove once support workspaces are removed from the backend.
if (ws.isSupportWorkspace) { return null; }
// Show docs in regular workspaces. For empty orgs, we show the intro and skip
// the empty workspace headers. Workspaces are still listed in the left panel.
if (showIntro) { return null; }
return css.docBlock(
css.docBlockHeaderLink(
css.wsLeft(
@@ -224,42 +206,50 @@ function buildAllDocsBlock(
});
}
/**
* Builds the collapsible examples and templates section at the bottom of
* the All Documents page.
*
* If there are no featured templates, builds nothing.
*/
function buildAllDocsTemplates(home: HomeModel, viewSettings: ViewSettings) {
return dom.domComputed(home.featuredTemplates, templates => {
if (templates.length === 0) { return null; }
function buildAllDocumentsDocsBlock(options: {
home: HomeModel,
flashDocId: Observable<string|null>,
viewSettings: ViewSettings
}) {
const {home, flashDocId, viewSettings} = options;
if (home.app.isPersonal && !home.app.currentValidUser) { return null; }
const hideTemplatesObs = localStorageBoolObs('hide-examples');
return css.allDocsTemplates(css.templatesDocBlock(
dom.autoDispose(hideTemplatesObs),
css.templatesHeaderWrap(
css.templatesHeader(
t("Examples & Templates"),
dom.domComputed(hideTemplatesObs, (collapsed) =>
collapsed ? css.templatesHeaderIcon('Expand') : css.templatesHeaderIcon('Collapse')
),
dom.on('click', () => hideTemplatesObs.set(!hideTemplatesObs.get())),
testId('all-docs-templates-header'),
),
createVideoTourTextButton(),
return dom('div',
dom.maybe(use => use(home.empty) && !home.app.isOwnerOrEditor(), () => css.docBlock(
css.introLine(
t("You have read-only access to this site. Currently there are no documents."),
dom('br'),
t("Any documents created in this site will appear here."),
testId('readonly-no-docs-message'),
),
dom.maybe((use) => !use(hideTemplatesObs), () => [
buildTemplateDocs(home, templates, viewSettings),
bigBasicButton(
t("Discover More Templates"),
urlState().setLinkUrl({homePage: 'templates'}),
testId('all-docs-templates-discover-more'),
)
]),
css.docBlock.cls((use) => '-' + use(home.currentView)),
testId('all-docs-templates'),
));
});
css.introLine(
t(
"Interested in using Grist outside of your team? Visit your free "
+ "{{personalSiteLink}}.",
{
personalSiteLink: dom.maybe(use =>
use(home.app.topAppModel.orgs).find(o => o.owner), org => cssLink(
urlState().setLinkUrl({org: org.domain ?? undefined}),
t("personal site"),
testId('readonly-personal-site-link')
)),
}
),
testId('readonly-personal-site-message'),
),
)),
dom.maybe(use => use(home.empty) && home.app.isOwnerOrEditor(), () => css.createFirstDocument(
css.createFirstDocumentImage({src: 'img/create-document.svg'}),
bigPrimaryButton(
t('Create my first document'),
dom.on('click', () => newDocMethods.createDocAndOpen(home)),
dom.boolAttr('disabled', use => !use(home.newDocWorkspace)),
),
)),
dom.maybe(use => !use(home.empty), () =>
buildAllDocsBlock(home, home.workspaces, flashDocId, viewSettings),
),
);
}
/**
@@ -333,20 +323,11 @@ function buildOtherSites(home: HomeModel) {
/**
* Build the widget for selecting sort and view mode options.
*
* Options hideSort and hideView control which options are shown; they should have no effect
* on the list of examples, so best to hide when those are the only docs shown.
*/
function buildPrefs(
viewSettings: ViewSettings,
options: {
hideSort: boolean,
hideView: boolean,
},
...args: DomArg<HTMLElement>[]): DomContents {
function buildPrefs(viewSettings: ViewSettings, ...args: DomArg<HTMLElement>[]) {
return css.prefSelectors(
// The Sort selector.
options.hideSort ? null : dom.update(
dom.update(
select<SortPref>(viewSettings.currentSort, [
{value: 'name', label: t("By Name")},
{value: 'date', label: t("By Date Modified")},
@@ -357,7 +338,7 @@ function buildPrefs(
),
// The View selector.
options.hideView ? null : buttonSelect<ViewPref>(viewSettings.currentView, [
buttonSelect<ViewPref>(viewSettings.currentView, [
{value: 'icons', icon: 'TypeTable'},
{value: 'list', icon: 'TypeCardList'},
],
@@ -617,13 +598,3 @@ function scrollIntoViewIfNeeded(target: Element) {
target.scrollIntoView(true);
}
}
/**
* Returns true if templates should be shown in All Documents.
*/
function shouldShowTemplates(home: HomeModel, showIntro: boolean): boolean {
const org = home.app.currentOrg;
const isPersonalOrg = Boolean(org && org.owner);
// Show templates for all personal orgs, and for non-personal orgs when showing intro.
return isPersonalOrg || showIntro;
}

View File

@@ -36,14 +36,24 @@ export const docList = styled('div', `
export const docListContent = styled('div', `
display: flex;
width: 100%;
max-width: 1340px;
margin: 0 auto;
`);
export const docMenu = styled('div', `
flex-grow: 1;
max-width: 100%;
width: 100%;
`);
const listHeader = styled('div', `
export const docListHeaderWrap = styled('div', `
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 16px;
margin-bottom: 24px;
`);
export const listHeader = styled('div', `
min-height: 32px;
line-height: 32px;
color: ${theme.text};
@@ -358,3 +368,28 @@ export const upgradeButton = styled('div', `
export const upgradeCard = styled('div', `
margin-left: 64px;
`);
export const createFirstDocument = styled('div', `
margin: 32px 0px;
display: flex;
flex-direction: column;
row-gap: 16px;
align-items: center;
justify-content: center;
`);
export const createFirstDocumentImage = styled('img', `
display: flex;
align-items: center;
justify-content: center;
`);
export const paragraph = styled(docBlock, `
color: ${theme.text};
line-height: 1.6;
`);
export const introLine = styled(paragraph, `
font-size: ${vars.introFontSize};
margin-bottom: 8px;
`);

View File

@@ -1,24 +1,22 @@
import {makeT} from 'app/client/lib/localization';
import {getLoginOrSignupUrl, getLoginUrl, getSignupUrl, 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 {buildHomeIntroCards} from 'app/client/ui/HomeIntroCards';
import {newDocMethods} from 'app/client/ui/NewDocMethods';
import {manageTeamUsersApp} from 'app/client/ui/OpenUserManager';
import {bigBasicButton, cssButton} from 'app/client/ui2018/buttons';
import {bigBasicButton} from 'app/client/ui2018/buttons';
import {testId, theme, vars} from 'app/client/ui2018/cssVars';
import {icon} from 'app/client/ui2018/icons';
import {cssLink} from 'app/client/ui2018/links';
import {commonUrls, isFeatureEnabled} from 'app/common/gristUrls';
import {menu, menuCssClass} from 'app/client/ui2018/menus';
import {toggleSwitch} from 'app/client/ui2018/toggleSwitch';
import {FullUser} from 'app/common/LoginSessionAPI';
import * as roles from 'app/common/roles';
import {getGristConfig} from 'app/common/urlUtils';
import {Computed, dom, DomContents, styled} from 'grainjs';
import {dom, DomContents, styled} from 'grainjs';
import {defaultMenuOptions} from 'popweasel';
const t = makeT('HomeIntro');
export function buildHomeIntro(homeModel: HomeModel): DomContents {
const isViewer = homeModel.app.currentOrg?.access === roles.VIEWER;
const user = homeModel.app.currentValidUser;
const isAnonym = !user;
const isPersonal = !homeModel.app.isTeamSite;
@@ -26,185 +24,104 @@ export function buildHomeIntro(homeModel: HomeModel): DomContents {
return makeAnonIntro(homeModel);
} else if (isPersonal) {
return makePersonalIntro(homeModel, user);
} else { // isTeamSite
if (isViewer) {
return makeViewerTeamSiteIntro(homeModel);
} else {
return makeTeamSiteIntro(homeModel);
}
} else {
return makeTeamSiteIntro(homeModel);
}
}
export function buildWorkspaceIntro(homeModel: HomeModel): DomContents {
const isViewer = homeModel.currentWS.get()?.access === roles.VIEWER;
const isAnonym = !homeModel.app.currentValidUser;
const emptyLine = cssIntroLine(testId('empty-workspace-info'), t("This workspace is empty."));
const emptyLine = css.introLine(testId('empty-workspace-info'), t("This workspace is empty."));
if (isAnonym || isViewer) {
return emptyLine;
} else {
return [
emptyLine,
buildButtons(homeModel, {
invite: false,
templates: false,
import: true,
empty: true
})
cssBtnGroup(
cssBtn(cssBtnIcon('Import'), t("Import Document"), testId('intro-import-doc'),
dom.on('click', () => newDocMethods.importDocAndOpen(homeModel)),
),
cssBtn(cssBtnIcon('Page'), t("Create Empty Document"), testId('intro-create-doc'),
dom.on('click', () => newDocMethods.createDocAndOpen(homeModel)),
),
),
];
}
}
function makeViewerTeamSiteIntro(homeModel: HomeModel) {
const personalOrg = Computed.create(null, use => use(homeModel.app.topAppModel.orgs).find(o => o.owner));
const docLink = (dom.maybe(personalOrg, org => {
return cssLink(
urlState().setLinkUrl({org: org.domain ?? undefined}),
t("personal site"),
testId('welcome-personal-url'));
}));
return [
css.docListHeader(
dom.autoDispose(personalOrg),
t("Welcome to {{- orgName}}", {orgName: homeModel.app.currentOrgName}),
productPill(homeModel.app.currentOrg, {large: true}),
testId('welcome-title')
),
cssIntroLine(
testId('welcome-info'),
t("You have read-only access to this site. Currently there are no documents."),
dom('br'),
t("Any documents created in this site will appear here."),
),
cssIntroLine(
t("Interested in using Grist outside of your team? Visit your free "), docLink, '.',
testId('welcome-text')
)
];
}
function makeTeamSiteIntro(homeModel: HomeModel) {
return [
css.docListHeader(
t("Welcome to {{- orgName}}", {orgName: homeModel.app.currentOrgName}),
productPill(homeModel.app.currentOrg, {large: true}),
testId('welcome-title')
css.docListHeaderWrap(
cssHeader(
t("Welcome to {{- orgName}}", {orgName: homeModel.app.currentOrgName}),
productPill(homeModel.app.currentOrg, {large: true}),
testId('welcome-title')
),
buildPreferencesMenu(homeModel),
),
cssIntroLine(t("Get started by inviting your team and creating your first Grist document.")),
(!isFeatureEnabled('helpCenter') ? null :
cssIntroLine(
t(
'Learn more in our {{helpCenterLink}}.',
{helpCenterLink: helpCenterLink()}
),
testId('welcome-text')
)
),
makeCreateButtons(homeModel)
dom.create(buildHomeIntroCards, {homeModel}),
];
}
function makePersonalIntro(homeModel: HomeModel, user: FullUser) {
return [
css.docListHeader(t("Welcome to Grist, {{- name}}!", {name: user.name}), testId('welcome-title')),
cssIntroLine(t("Get started by creating your first Grist document.")),
(!isFeatureEnabled('helpCenter') ? null :
cssIntroLine(t("Visit our {{link}} to learn more.", { link: helpCenterLink() }),
testId('welcome-text'))
css.docListHeaderWrap(
cssHeader(
t("Welcome to Grist, {{- name}}!", {name: user.name}),
testId('welcome-title'),
),
buildPreferencesMenu(homeModel),
),
makeCreateButtons(homeModel),
dom.create(buildHomeIntroCards, {homeModel}),
];
}
function makeAnonIntroWithoutPlayground(homeModel: HomeModel) {
return [
(!isFeatureEnabled('helpCenter') ? null : cssIntroLine(t("Visit our {{link}} to learn more about Grist.", {
link: helpCenterLink()
}), testId('welcome-text-no-playground'))),
cssIntroLine(t("To use Grist, please either sign up or sign in.")),
cssBtnGroup(
cssBtn(t("Sign up"), cssButton.cls('-primary'), testId('intro-sign-up'),
dom.on('click', () => location.href = getSignupUrl())
),
cssBtn(t("Sign in"), testId('intro-sign-in'),
dom.on('click', () => location.href = getLoginUrl())
)
)
];
}
function makeAnonIntro(homeModel: HomeModel) {
const welcomeToGrist = css.docListHeader(t("Welcome to Grist!"), testId('welcome-title'));
const welcomeToGrist = css.docListHeaderWrap(
cssHeader(
t("Welcome to Grist!"),
testId('welcome-title'),
),
);
if (!getGristConfig().enableAnonPlayground) {
return [
welcomeToGrist,
...makeAnonIntroWithoutPlayground(homeModel)
];
}
const signUp = cssLink({href: getLoginOrSignupUrl()}, t("Sign up"));
return [
return cssIntro(
welcomeToGrist,
cssIntroLine(t("Get started by exploring templates, or creating your first Grist document.")),
cssIntroLine(t("{{signUp}} to save your work. ", {signUp}),
(!isFeatureEnabled('helpCenter') ? null : t("Visit our {{link}} to learn more.", { link: helpCenterLink() })),
testId('welcome-text')),
makeCreateButtons(homeModel),
];
}
function helpCenterLink() {
return cssLink({href: commonUrls.help, target: '_blank'}, cssInlineIcon('Help'), t("Help Center"));
}
function buildButtons(homeModel: HomeModel, options: {
invite: boolean,
templates: boolean,
import: boolean,
empty: boolean,
}) {
return cssBtnGroup(
!options.invite ? null :
cssBtn(cssBtnIcon('Help'), t("Invite Team Members"), testId('intro-invite'),
cssButton.cls('-primary'),
dom.on('click', () => manageTeamUsersApp({app: homeModel.app})),
),
!options.templates ? null :
cssBtn(cssBtnIcon('FieldTable'), t("Browse Templates"), testId('intro-templates'),
cssButton.cls('-primary'),
dom.show(isFeatureEnabled("templates")),
urlState().setLinkUrl({homePage: 'templates'}),
),
!options.import ? null :
cssBtn(cssBtnIcon('Import'), t("Import Document"), testId('intro-import-doc'),
dom.on('click', () => newDocMethods.importDocAndOpen(homeModel)),
),
!options.empty ? null :
cssBtn(cssBtnIcon('Page'), t("Create Empty Document"), testId('intro-create-doc'),
dom.on('click', () => newDocMethods.createDocAndOpen(homeModel)),
),
dom.create(buildHomeIntroCards, {homeModel}),
);
}
function makeCreateButtons(homeModel: HomeModel) {
const canManageTeam = homeModel.app.isTeamSite &&
roles.canEditAccess(homeModel.app.currentOrg?.access || null);
return buildButtons(homeModel, {
invite: canManageTeam,
templates: !canManageTeam,
import: true,
empty: true
});
function buildPreferencesMenu(homeModel: HomeModel) {
const {onlyShowDocuments} = homeModel;
return cssDotsMenu(
cssDots(icon('Dots')),
menu(
() => [
toggleSwitch(onlyShowDocuments, {
label: t('Only show documents'),
args: [
testId('welcome-menu-only-show-documents'),
],
}),
],
{
...defaultMenuOptions,
menuCssClass: `${menuCssClass} ${cssPreferencesMenu.className}`,
placement: 'bottom-end',
}
),
testId('welcome-menu'),
);
}
const cssParagraph = styled(css.docBlock, `
color: ${theme.text};
line-height: 1.6;
const cssIntro = styled('div', `
margin-bottom: 24px;
`);
const cssIntroLine = styled(cssParagraph, `
font-size: ${vars.introFontSize};
margin-bottom: 8px;
const cssHeader = styled(css.listHeader, `
font-size: 24px;
line-height: 36px;
`);
const cssBtnGroup = styled('div', `
@@ -225,6 +142,21 @@ const cssBtnIcon = styled(icon, `
margin-right: 8px;
`);
const cssInlineIcon = styled(icon, `
margin: -2px 4px 2px 4px;
const cssPreferencesMenu = styled('div', `
padding: 10px 16px;
`);
const cssDotsMenu = styled('div', `
display: flex;
cursor: pointer;
border-radius: ${vars.controlBorderRadius};
&:hover, &.weasel-popup-open {
background-color: ${theme.hover};
}
`);
const cssDots = styled('div', `
--icon-color: ${theme.lightText};
padding: 8px;
`);

View File

@@ -0,0 +1,386 @@
import {makeT} from 'app/client/lib/localization';
import {urlState} from 'app/client/models/gristUrlState';
import {HomeModel} from 'app/client/models/HomeModel';
import {newDocMethods} from 'app/client/ui/NewDocMethods';
import {openVideoTour} from 'app/client/ui/OpenVideoTour';
import {basicButtonLink, bigPrimaryButton, primaryButtonLink} from 'app/client/ui2018/buttons';
import {colors, theme, vars} from 'app/client/ui2018/cssVars';
import {icon} from 'app/client/ui2018/icons';
import {commonUrls, isFeatureEnabled} from 'app/common/gristUrls';
import {getGristConfig} from 'app/common/urlUtils';
import {Computed, dom, IDisposableOwner, makeTestId, styled, subscribeElem} from 'grainjs';
interface BuildHomeIntroCardsOptions {
homeModel: HomeModel;
}
const t = makeT('HomeIntroCards');
const testId = makeTestId('test-intro-');
export function buildHomeIntroCards(
owner: IDisposableOwner,
{homeModel}: BuildHomeIntroCardsOptions
) {
const {onboardingTutorialDocId, templateOrg} = getGristConfig();
const percentComplete = Computed.create(owner, (use) => {
if (!homeModel.app.currentValidUser) { return 0; }
const tutorial = use(homeModel.onboardingTutorial);
if (!tutorial) { return undefined; }
return tutorial.forks?.[0]?.options?.tutorial?.percentComplete ?? 0;
});
let videoPlayButtonElement: HTMLElement;
return dom.maybe(use => !use(homeModel.onlyShowDocuments), () => cssHomeIntroCards(
cssVideoTour(
cssVideoTourThumbnail(
cssVideoTourThumbnailSpacer(),
videoPlayButtonElement = cssVideoTourPlayButton(
cssVideoTourPlayIcon('VideoPlay2'),
),
cssVideoTourThumbnailText(t('3 minute video tour')),
),
dom.on('click', () => openVideoTour(videoPlayButtonElement)),
testId('video-tour'),
),
cssTutorial(
dom.hide(() => !isFeatureEnabled('tutorials') || !templateOrg || !onboardingTutorialDocId),
cssTutorialHeader(t('Finish our basics tutorial')),
cssTutorialBody(
cssTutorialProgress(
cssTutorialProgressText(
cssTutorialProgressPercentage(
dom.domComputed(percentComplete, (percent) => percent !== undefined ? `${percent}%` : null),
testId('tutorial-percent-complete'),
),
),
cssTutorialProgressBar(
(elem) => subscribeElem(elem, percentComplete, (val) => {
elem.style.setProperty('--percent-complete', String(val ?? 0));
})
),
),
dom('div',
primaryButtonLink(
t('Tutorial'),
urlState().setLinkUrl({org: templateOrg!, doc: onboardingTutorialDocId}),
),
)
),
testId('tutorial'),
),
cssNewDocument(
cssNewDocumentHeader(t('Start a new document')),
cssNewDocumentBody(
cssNewDocumentButton(
cssNewDocumentButtonIcon('Page'),
t('Blank document'),
dom.on('click', () => newDocMethods.createDocAndOpen(homeModel)),
dom.boolAttr('disabled', use => !use(homeModel.newDocWorkspace)),
testId('create-doc'),
),
cssNewDocumentButton(
cssNewDocumentButtonIcon('Import'),
t('Import file'),
dom.on('click', () => newDocMethods.importDocAndOpen(homeModel)),
dom.boolAttr('disabled', use => !use(homeModel.newDocWorkspace)),
testId('import-doc'),
),
cssNewDocumentButton(
dom.show(isFeatureEnabled("templates") && Boolean(templateOrg)),
cssNewDocumentButtonIcon('FieldTable'),
t('Templates'),
urlState().setLinkUrl({homePage: 'templates'}),
testId('templates'),
),
),
),
cssWebinars(
dom.show(isFeatureEnabled('helpCenter')),
cssWebinarsImage({src: 'img/webinars.svg'}),
t('Learn more {{webinarsLinks}}', {
webinarsLinks: cssWebinarsButton(
t('Webinars'),
{href: commonUrls.webinars, target: '_blank'},
testId('webinars'),
),
}),
),
cssHelpCenter(
dom.show(isFeatureEnabled('helpCenter')),
cssHelpCenterImage({src: 'img/help-center.svg'}),
t('Find solutions and explore more resources {{helpCenterLink}}', {
helpCenterLink: cssHelpCenterButton(
t('Help center'),
{href: commonUrls.help, target: '_blank'},
testId('help-center'),
),
}),
),
testId('cards'),
));
}
// Cards are hidden at specific breakpoints; we use non-standard ones
// here, as they work better than the ones defined in `cssVars.ts`.
const mediaXLarge = `(max-width: ${1440 - 0.02}px)`;
const mediaLarge = `(max-width: ${1280 - 0.02}px)`;
const mediaMedium = `(max-width: ${1048 - 0.02}px)`;
const mediaSmall = `(max-width: ${828 - 0.02}px)`;
const cssHomeIntroCards = styled('div', `
display: grid;
gap: 24px;
margin-bottom: 24px;
display: grid;
grid-template-columns: 239px minmax(0, 437px) minmax(196px, 1fr) minmax(196px, 1fr);
grid-template-rows: repeat(2, 1fr);
@media ${mediaLarge} {
& {
grid-template-columns: 239px minmax(0, 437px) minmax(196px, 1fr);
}
}
@media ${mediaMedium} {
& {
grid-template-columns: 239px minmax(0, 437px);
}
}
@media ${mediaSmall} {
& {
display: flex;
flex-direction: column;
}
}
`);
const cssVideoTour = styled('div', `
grid-area: 1 / 1 / 2 / 2;
flex-shrink: 0;
width: 239px;
overflow: hidden;
cursor: pointer;
border-radius: 4px;
@media ${mediaSmall} {
& {
width: unset;
}
}
`);
const cssVideoTourThumbnail = styled('div', `
display: flex;
flex-direction: column;
justify-content: space-between;
padding: 36px 32px;
background-image: url("img/youtube-screenshot.png");
background-color: rgba(0, 0, 0, 0.4);
background-blend-mode: multiply;
background-size: cover;
transform: scale(1.2);
width: 100%;
height: 100%;
`);
const cssVideoTourThumbnailSpacer = styled('div', ``);
const cssVideoTourPlayButton = styled('div', `
display: flex;
justify-content: center;
align-items: center;
align-self: center;
width: 32px;
height: 32px;
background-color: ${theme.controlPrimaryBg};
border-radius: 50%;
.${cssVideoTourThumbnail.className}:hover & {
background-color: ${theme.controlPrimaryHoverBg};
}
`);
const cssVideoTourPlayIcon = styled(icon, `
--icon-color: ${theme.controlPrimaryFg};
width: 24px;
height: 24px;
`);
const cssVideoTourThumbnailText = styled('div', `
color: ${colors.light};
font-weight: 700;
text-align: center;
`);
const cssTutorial = styled('div', `
grid-area: 1 / 2 / 2 / 3;
position: relative;
border-radius: 4px;
color: ${theme.announcementPopupFg};
background-color: ${theme.announcementPopupBg};
padding: 16px;
`);
const cssTutorialHeader = styled('div', `
display: flex;
align-items: flex-start;
justify-content: space-between;
font-size: 16px;
font-style: normal;
font-weight: 500;
margin-bottom: 8px;
`);
const cssTutorialBody = styled('div', `
display: flex;
flex-direction: column;
gap: 16px;
`);
const cssTutorialProgress = styled('div', `
display
flex: auto;
min-width: 120px;
`);
const cssTutorialProgressText = styled('div', `
display: flex;
justify-content: space-between;
`);
const cssTutorialProgressPercentage = styled('div', `
font-size: 18px;
font-style: normal;
font-weight: 700;
min-height: 21.5px;
`);
const cssTutorialProgressBar = styled('div', `
margin-top: 4px;
height: 10px;
border-radius: 8px;
background: ${theme.mainPanelBg};
--percent-complete: 0;
&::after {
content: '';
border-radius: 8px;
background: ${theme.progressBarFg};
display: block;
height: 100%;
width: calc((var(--percent-complete) / 100) * 100%);
}
`);
const cssNewDocument = styled('div', `
grid-area: 2 / 1 / 3 / 3;
grid-column: span 2;
display: flex;
flex-direction: column;
justify-content: space-between;
position: relative;
border-radius: 4px;
color: ${theme.announcementPopupFg};
background-color: ${theme.announcementPopupBg};
padding: 24px;
`);
const cssNewDocumentHeader = styled('div', `
font-weight: 500;
font-size: ${vars.xxlargeFontSize};
`);
const cssNewDocumentBody = styled('div', `
display: flex;
gap: 16px;
margin-top: 16px;
@media ${mediaSmall} {
& {
flex-direction: column;
}
}
`);
const cssNewDocumentButton = styled(bigPrimaryButton, `
display: flex;
align-items: center;
justify-content: center;
flex-grow: 1;
padding: 6px;
`);
const cssNewDocumentButtonIcon = styled(icon, `
flex-shrink: 0;
margin-right: 8px;
@media ${mediaXLarge} {
& {
display: none;
}
}
`);
const cssSecondaryCard = styled('div', `
font-weight: 500;
font-size: 14px;
display: flex;
flex-direction: column;
align-items: center;
text-align: center;
justify-content: center;
min-width: 196px;
color: ${theme.text};
background-color: ${theme.popupSecondaryBg};
position: relative;
border-radius: 4px;
padding: 16px;
`);
const cssSecondaryCardImage = styled('img', `
display: block;
height: auto;
`);
const cssSecondaryCardButton = styled(basicButtonLink, `
font-weight: 400;
font-size: ${vars.mediumFontSize};
margin-top: 8px;
`);
const cssWebinars = styled(cssSecondaryCard, `
grid-area: 2 / 3 / 3 / 4;
@media ${mediaMedium} {
& {
display: none;
}
}
`);
const cssWebinarsImage = styled(cssSecondaryCardImage, `
width: 105.78px;
margin-bottom: 8px;
`);
const cssWebinarsButton = cssSecondaryCardButton;
const cssHelpCenter = styled(cssSecondaryCard, `
grid-area: 2 / 4 / 3 / 5;
@media ${mediaLarge} {
& {
display: none;
}
}
`);
const cssHelpCenterImage = styled(cssSecondaryCardImage, `
width: 67.77px;
`);
const cssHelpCenterButton = cssSecondaryCardButton;

View File

@@ -1,232 +0,0 @@
import {makeT} from 'app/client/lib/localization';
import {urlState} from 'app/client/models/gristUrlState';
import {HomeModel} from 'app/client/models/HomeModel';
import {openVideoTour} from 'app/client/ui/OpenVideoTour';
import {bigPrimaryButtonLink} from 'app/client/ui2018/buttons';
import {colors, theme} from 'app/client/ui2018/cssVars';
import {icon} from 'app/client/ui2018/icons';
import {isFeatureEnabled} from 'app/common/gristUrls';
import {getGristConfig} from 'app/common/urlUtils';
import {Computed, dom, IDisposableOwner, makeTestId, styled, subscribeElem} from 'grainjs';
interface BuildOnboardingCardsOptions {
homeModel: HomeModel;
}
const t = makeT('OnboardingCards');
const testId = makeTestId('test-onboarding-');
export function buildOnboardingCards(
owner: IDisposableOwner,
{homeModel}: BuildOnboardingCardsOptions
) {
const {templateOrg, onboardingTutorialDocId} = getGristConfig();
if (!isFeatureEnabled('tutorials') || !templateOrg || !onboardingTutorialDocId) { return null; }
const percentComplete = Computed.create(owner, (use) => {
if (!homeModel.app.currentValidUser) { return 0; }
const tutorial = use(homeModel.onboardingTutorial);
if (!tutorial) { return undefined; }
return tutorial.forks?.[0]?.options?.tutorial?.percentComplete ?? 0;
});
const shouldShowCards = Computed.create(owner, (use) =>
!use(homeModel.app.dismissedPopups).includes('onboardingCards'));
let videoPlayButtonElement: HTMLElement;
return dom.maybe(shouldShowCards, () =>
cssOnboardingCards(
cssTutorialCard(
cssDismissCardsButton(
icon('CrossBig'),
dom.on('click', () => homeModel.app.dismissPopup('onboardingCards', true)),
testId('dismiss-cards'),
),
cssTutorialCardHeader(
t('Complete our basics tutorial'),
),
cssTutorialCardSubHeader(
t('Learn the basics of reference columns, linked widgets, column types, & cards.')
),
cssTutorialCardBody(
cssTutorialProgress(
cssTutorialProgressText(
cssProgressPercentage(
dom.domComputed(percentComplete, (percent) => percent !== undefined ? `${percent}%` : null),
testId('tutorial-percent-complete'),
),
cssStarIcon('Star'),
),
cssTutorialProgressBar(
(elem) => subscribeElem(elem, percentComplete, (val) => {
elem.style.setProperty('--percent-complete', String(val ?? 0));
})
),
),
bigPrimaryButtonLink(
t('Complete the tutorial'),
urlState().setLinkUrl({org: templateOrg, doc: onboardingTutorialDocId}),
),
),
testId('tutorial-card'),
),
cssVideoCard(
cssVideoThumbnail(
cssVideoThumbnailSpacer(),
videoPlayButtonElement = cssVideoPlayButton(
cssPlayIcon('VideoPlay2'),
),
cssVideoThumbnailText(t('3 minute video tour')),
),
dom.on('click', () => openVideoTour(videoPlayButtonElement)),
),
)
);
}
const cssOnboardingCards = styled('div', `
display: grid;
grid-template-columns: repeat(auto-fit, minmax(220px, max-content));
gap: 24px;
margin: 24px 0;
`);
const cssTutorialCard = styled('div', `
position: relative;
border-radius: 4px;
color: ${theme.announcementPopupFg};
background-color: ${theme.announcementPopupBg};
padding: 16px 24px;
`);
const cssTutorialCardHeader = styled('div', `
display: flex;
align-items: flex-start;
justify-content: space-between;
font-size: 18px;
font-style: normal;
font-weight: 700;
`);
const cssDismissCardsButton = styled('div', `
position: absolute;
top: 8px;
right: 8px;
padding: 4px;
border-radius: 4px;
cursor: pointer;
--icon-color: ${theme.popupCloseButtonFg};
&:hover {
background-color: ${theme.hover};
}
`);
const cssTutorialCardSubHeader = styled('div', `
font-size: 14px;
font-style: normal;
font-weight: 500;
margin: 8px 0;
`);
const cssTutorialCardBody = styled('div', `
display: flex;
flex-wrap: wrap;
gap: 24px;
margin: 16px 0;
align-items: end;
`);
const cssTutorialProgress = styled('div', `
flex: auto;
min-width: 120px;
`);
const cssTutorialProgressText = styled('div', `
display: flex;
justify-content: space-between;
`);
const cssProgressPercentage = styled('div', `
font-size: 20px;
font-style: normal;
font-weight: 700;
`);
const cssStarIcon = styled(icon, `
--icon-color: ${theme.accentIcon};
width: 24px;
height: 24px;
`);
const cssTutorialProgressBar = styled('div', `
margin-top: 4px;
height: 10px;
border-radius: 8px;
background: ${theme.mainPanelBg};
--percent-complete: 0;
&::after {
content: '';
border-radius: 8px;
background: ${theme.progressBarFg};
display: block;
height: 100%;
width: calc((var(--percent-complete) / 100) * 100%);
}
`);
const cssVideoCard = styled('div', `
width: 220px;
height: 158px;
overflow: hidden;
cursor: pointer;
border-radius: 4px;
`);
const cssVideoThumbnail = styled('div', `
display: flex;
flex-direction: column;
justify-content: space-between;
padding: 36px 32px;
background-image: url("img/youtube-screenshot.png");
background-color: rgba(0, 0, 0, 0.4);
background-blend-mode: multiply;
background-size: cover;
transform: scale(1.2);
width: 100%;
height: 100%;
`);
const cssVideoThumbnailSpacer = styled('div', ``);
const cssVideoPlayButton = styled('div', `
display: flex;
justify-content: center;
align-items: center;
align-self: center;
width: 32px;
height: 32px;
background-color: ${theme.controlPrimaryBg};
border-radius: 50%;
.${cssVideoThumbnail.className}:hover & {
background-color: ${theme.controlPrimaryHoverBg};
}
`);
const cssPlayIcon = styled(icon, `
--icon-color: ${theme.controlPrimaryFg};
width: 24px;
height: 24px;
`);
const cssVideoThumbnailText = styled('div', `
color: ${colors.light};
font-weight: 700;
text-align: center;
`);

View File

@@ -1,45 +1,32 @@
import {makeT} from 'app/client/lib/localization';
import {localStorageObs} from 'app/client/lib/localStorageObs';
import {getStorage} from 'app/client/lib/storage';
import {tokenFieldStyles} from 'app/client/lib/TokenField';
import {AppModel} from 'app/client/models/AppModel';
import {urlState} from 'app/client/models/gristUrlState';
import {TelemetryModel, TelemetryModelImpl} from 'app/client/models/TelemetryModel';
import {basicButton, basicButtonLink, bigPrimaryButton} from 'app/client/ui2018/buttons';
import {colors, isNarrowScreenObs, testId, theme, vars} from 'app/client/ui2018/cssVars';
import {colors, testId, theme, vars} from 'app/client/ui2018/cssVars';
import {icon} from 'app/client/ui2018/icons';
import {cssLink} from 'app/client/ui2018/links';
import {modal} from 'app/client/ui2018/modals';
import {commonUrls, isFeatureEnabled} from 'app/common/gristUrls';
import {getGristConfig} from 'app/common/urlUtils';
import {Computed, Disposable, dom, DomContents, Observable, styled, UseCB} from 'grainjs';
import {Computed, Disposable, dom, DomContents, Observable, styled} from 'grainjs';
const t = makeT('SupportGristNudge');
type ButtonState =
| 'collapsed'
| 'expanded';
/**
* Nudges users to support Grist by opting in to telemetry or sponsoring on Github.
* Button that nudges users to support Grist by opting in to telemetry or sponsoring on Github.
*
* For installation admins, this includes a card with a nudge which collapses into a "Support
* For installation admins, this includes a modal with a nudge which collapses into a "Support
* Grist" button in the top bar. When that's not applicable, it is only a "Support Grist" button
* that links to the Github sponsorship page.
*
* Users can dismiss these nudges.
* Users can dismiss this button.
*/
export class SupportGristNudge extends Disposable {
export class SupportGristButton extends Disposable {
private readonly _showButton: Computed<null|'link'|'expand'>;
private readonly _telemetryModel: TelemetryModel = TelemetryModelImpl.create(this, this._appModel);
private readonly _buttonStateKey = `u=${this._appModel.currentValidUser?.id ?? 0};supportGristNudge`;
private _buttonState = localStorageObs(this._buttonStateKey, 'expanded') as Observable<ButtonState>;
// Whether the nudge just got accepted, and we should temporarily show the "Thanks" version.
private _justAccepted = Observable.create(this, false);
private _showButton: Computed<null|'link'|'expand'>;
private _showNudge: Computed<null|'normal'|'accepted'>;
constructor(private _appModel: AppModel) {
super();
const {deploymentType, telemetry} = getGristConfig();
@@ -48,28 +35,16 @@ export class SupportGristNudge extends Disposable {
const isTelemetryOn = (telemetry && telemetry.telemetryLevel !== 'off');
const isAdminNudgeApplicable = isAdmin && !isTelemetryOn;
const generallyHide = (use: UseCB) => (
!isEnabled ||
use(_appModel.dismissedPopups).includes('supportGrist') ||
use(isNarrowScreenObs())
);
this._showButton = Computed.create(this, use => {
if (generallyHide(use)) { return null; }
if (!isAdminNudgeApplicable) { return 'link'; }
if (use(this._buttonState) !== 'expanded') { return 'expand'; }
return null;
});
if (!isEnabled || use(_appModel.dismissedPopups).includes('supportGrist')) {
return null;
}
this._showNudge = Computed.create(this, use => {
if (use(this._justAccepted)) { return 'accepted'; }
if (generallyHide(use)) { return null; }
if (isAdminNudgeApplicable && use(this._buttonState) === 'expanded') { return 'normal'; }
return null;
return isAdminNudgeApplicable ? 'expand' : 'link';
});
}
public buildTopBarButton(): DomContents {
public buildDom(): DomContents {
return dom.domComputed(this._showButton, (which) => {
if (!which) { return null; }
const elemType = (which === 'link') ? basicButtonLink : basicButton;
@@ -77,7 +52,7 @@ export class SupportGristNudge extends Disposable {
elemType(cssHeartIcon('💛 '), t('Support Grist'),
(which === 'link' ?
{href: commonUrls.githubSponsorGristLabs, target: '_blank'} :
dom.on('click', () => this._buttonState.set('expanded'))
dom.on('click', () => this._buildNudgeModal())
),
cssContributeButtonCloseButton(
@@ -85,7 +60,7 @@ export class SupportGristNudge extends Disposable {
dom.on('click', (ev) => {
ev.stopPropagation();
ev.preventDefault();
this._dismissAndClose();
this._markDismissed();
}),
testId('support-grist-button-dismiss'),
),
@@ -95,26 +70,31 @@ export class SupportGristNudge extends Disposable {
});
}
public buildNudgeCard() {
return dom.domComputed(this._showNudge, nudge => {
if (!nudge) { return null; }
return cssCard(
(nudge === 'normal' ?
this._buildSupportGristCardContent() :
this._buildOptedInCardContent()
private _buildNudgeModal() {
return modal((ctl, owner) => {
const currentStep = Observable.create<'opt-in' | 'opted-in'>(owner, 'opt-in');
return [
cssModal.cls(''),
cssCloseButton(
icon('CrossBig'),
dom.on('click', () => ctl.close()),
testId('support-nudge-close'),
),
testId('support-nudge'),
);
});
dom.domComputed(currentStep, (step) => {
return step === 'opt-in'
? this._buildOptInScreen(async () => {
await this._optInToTelemetry();
currentStep.set('opted-in');
})
: this._buildOptedInScreen(() => ctl.close());
}),
];
}, {});
}
private _buildSupportGristCardContent() {
private _buildOptInScreen(onOptIn: () => Promise<void>) {
return [
cssCloseButton(
icon('CrossBig'),
dom.on('click', () => this._buttonState.set('collapsed')),
testId('support-nudge-close'),
),
cssLeftAlignedHeader(t('Support Grist')),
cssParagraph(t(
'Opt in to telemetry to help us understand how the product ' +
@@ -132,19 +112,14 @@ export class SupportGristNudge extends Disposable {
),
cssFullWidthButton(
t('Opt in to Telemetry'),
dom.on('click', () => this._optInToTelemetry()),
dom.on('click', () => onOptIn()),
testId('support-nudge-opt-in'),
),
];
}
private _buildOptedInCardContent() {
private _buildOptedInScreen(onClose: () => void) {
return [
cssCloseButton(
icon('CrossBig'),
dom.on('click', () => this._justAccepted.set(false)),
testId('support-nudge-close'),
),
cssCenteredFlex(cssSparks()),
cssCenterAlignedHeader(t('Opted In')),
cssParagraph(
@@ -157,7 +132,7 @@ export class SupportGristNudge extends Disposable {
cssCenteredFlex(
cssPrimaryButton(
t('Close'),
dom.on('click', () => this._justAccepted.set(false)),
dom.on('click', () => onClose()),
testId('support-nudge-close-button'),
),
),
@@ -166,19 +141,11 @@ export class SupportGristNudge extends Disposable {
private _markDismissed() {
this._appModel.dismissPopup('supportGrist', true);
// Also cleanup the no-longer-needed button state from localStorage.
getStorage().removeItem(this._buttonStateKey);
}
private _dismissAndClose() {
this._markDismissed();
this._justAccepted.set(false);
}
private async _optInToTelemetry() {
await this._telemetryModel.updateTelemetryPrefs({telemetryLevel: 'limited'});
this._markDismissed();
this._justAccepted.set(true);
}
}
@@ -202,10 +169,7 @@ const cssCenteredFlex = styled('div', `
align-items: center;
`);
const cssContributeButton = styled('div', `
margin-left: 8px;
margin-right: 8px;
`);
const cssContributeButton = styled('div', ``);
const cssContributeButtonCloseButton = styled(tokenFieldStyles.cssDeleteButton, `
margin-left: 4px;
@@ -233,18 +197,6 @@ const cssContributeButtonCloseButton = styled(tokenFieldStyles.cssDeleteButton,
}
`);
const cssCard = styled('div', `
width: 297px;
padding: 24px;
color: ${theme.announcementPopupFg};
background: ${theme.announcementPopupBg};
border-radius: 4px;
align-self: flex-start;
position: sticky;
flex-shrink: 0;
top: 0px;
`);
const cssHeader = styled('div', `
font-size: ${vars.xxxlargeFontSize};
font-weight: 600;
@@ -303,3 +255,10 @@ const cssSparks = styled('div', `
const cssHeartIcon = styled('span', `
line-height: 1;
`);
const cssModal = styled('div', `
position: relative;
width: 100%;
max-width: 400px;
min-width: 0px;
`);

View File

@@ -8,7 +8,9 @@ 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 {UpgradeButton} from 'app/client/ui/ProductUpgrades';
import {buildShareMenuButton} from 'app/client/ui/ShareMenu';
import {SupportGristButton} from 'app/client/ui/SupportGristButton';
import {hoverTooltip} from 'app/client/ui/tooltips';
import {cssHoverCircle, cssTopBarBtn} from 'app/client/ui/TopBarCss';
import {buildLanguageMenu} from 'app/client/ui/LanguageMenu';
@@ -28,20 +30,20 @@ export function createTopBarHome(appModel: AppModel, onSave?: (personal: boolean
return [
cssFlexSpace(),
(appModel.isTeamSite && roles.canEditAccess(appModel.currentOrg?.access || null) ?
[
basicButton(
t("Manage Team"),
dom.on('click', () => manageTeamUsersApp({app: appModel, onSave})),
testId('topbar-manage-team')
),
cssSpacer()
] :
null
cssButtons(
dom.create(UpgradeButton, appModel),
dom.create(SupportGristButton, appModel),
(appModel.isTeamSite && roles.canEditAccess(appModel.currentOrg?.access || null) ?
[
basicButton(
t("Manage team"),
dom.on('click', () => manageTeamUsersApp({app: appModel, onSave})),
testId('topbar-manage-team')
),
] :
null
),
),
appModel.supportGristNudge.buildTopBarButton(),
buildLanguageMenu(appModel),
isAnonymous ? null : buildNotifyMenuButton(appModel.notifier, appModel),
dom('div', dom.create(AccountWidget, appModel)),
@@ -197,6 +199,12 @@ function topBarUndoBtn(iconName: IconName, ...domArgs: DomElementArg[]): Element
);
}
const cssButtons = styled('div', `
display: flex;
gap: 8px;
margin-right: 8px;
`);
const cssTopBarUndoBtn = styled(cssTopBarBtn, `
background-color: ${theme.topBarButtonSecondaryFg};

View File

@@ -265,6 +265,7 @@ export const theme = {
/* Popups */
popupBg: new CustomProp('theme-popup-bg', undefined, 'white'),
popupSecondaryBg: new CustomProp('theme-popup-secondary-bg', undefined, colors.lightGrey),
popupInnerShadow: new CustomProp('theme-popup-shadow-inner', undefined,
'rgba(31, 37, 50, 0.31)'),
popupOuterShadow: new CustomProp('theme-popup-shadow-outer', undefined,

View File

@@ -0,0 +1,115 @@
import {theme} from 'app/client/ui2018/cssVars';
import {dom, DomElementArg, Observable, styled} from 'grainjs';
interface ToggleSwitchOptions {
label?: string;
args?: DomElementArg[];
inputArgs?: DomElementArg[];
labelArgs?: DomElementArg[];
}
export function toggleSwitch(value: Observable<boolean>, options: ToggleSwitchOptions = {}) {
const {label, args = [], inputArgs = [], labelArgs = []} = options;
return cssToggleSwitch(
cssInput(
{type: 'checkbox'},
dom.prop('checked', value),
dom.prop('value', use => use(value) ? '1' : '0'),
dom.on('change', (_e, elem) => value.set(elem.checked)),
...inputArgs,
),
cssSwitch(
cssSwitchSlider(),
cssSwitchCircle(),
),
!label ? null : cssLabel(
label,
...labelArgs,
),
...args,
);
}
const cssToggleSwitch = styled('label', `
position: relative;
display: inline-flex;
margin-top: 8px;
cursor: pointer;
`);
const cssInput = styled('input', `
position: absolute;
height: 1px;
width: 1px;
top: 1px;
left: 4px;
margin: 0;
&::before, &::after {
height: 1px;
width: 1px;
}
&:focus {
outline: none;
}
`);
const cssSwitch = styled('div', `
position: relative;
width: 30px;
height: 17px;
display: inline-block;
flex: none;
`);
const cssSwitchSlider = styled('div', `
position: absolute;
cursor: pointer;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-color: ${theme.switchSliderFg};
border-radius: 17px;
-webkit-transition: background-color .4s;
transition: background-color .4s;
.${cssInput.className}:focus + .${cssSwitch.className} > & {
outline: 2px solid ${theme.controlPrimaryHoverBg};
outline-offset: 1px;
}
.${cssInput.className}:checked + .${cssSwitch.className} > & {
background-color: ${theme.controlPrimaryBg};
}
`);
const cssSwitchCircle = styled('div', `
position: absolute;
cursor: pointer;
content: "";
height: 13px;
width: 13px;
left: 2px;
bottom: 2px;
background-color: ${theme.switchCircleFg};
border-radius: 17px;
-webkit-transition: transform .4s;
transition: transform .4s;
.${cssInput.className}:checked + .${cssSwitch.className} > & {
-webkit-transform: translateX(13px);
-ms-transform: translateX(13px);
transform: translateX(13px);
}
`);
const cssLabel = styled('span', `
color: ${theme.text};
margin-left: 8px;
font-size: 13px;
font-weight: 400;
line-height: 16px;
overflow-wrap: anywhere;
`);

View File

@@ -33,6 +33,8 @@ export interface UserPrefs extends Prefs {
dismissedWelcomePopups?: DismissedReminder[];
// Localization support.
locale?: string;
// If only documents should be shown on the All Documents page.
onlyShowDocuments?: boolean;
}
// A collection of preferences related to a combination of user and org.
@@ -112,9 +114,9 @@ export const DismissedPopup = StringUnion(
'supportGrist', // nudge to opt in to telemetry
'publishForm', // confirmation for publishing a form
'unpublishForm', // confirmation for unpublishing a form
'onboardingCards', // onboarding cards shown on the doc menu
/* Deprecated */
'onboardingCards', // onboarding cards shown on the doc menu
'tutorialFirstCard', // first card of the tutorial
);
export type DismissedPopup = typeof DismissedPopup.type;

View File

@@ -90,6 +90,7 @@ export const ThemeColors = t.iface([], {
"modal-backdrop-close-button-fg": "string",
"modal-backdrop-close-button-hover-fg": "string",
"popup-bg": "string",
"popup-secondary-bg": "string",
"popup-shadow-inner": "string",
"popup-shadow-outer": "string",
"popup-close-button-fg": "string",

View File

@@ -106,6 +106,7 @@ export interface ThemeColors {
/* Popups */
'popup-bg': string;
'popup-secondary-bg': string;
'popup-shadow-inner': string;
'popup-shadow-outer': string;
'popup-close-button-fg': string;

View File

@@ -367,7 +367,7 @@ export interface UserAPI {
getOrg(orgId: number|string): Promise<Organization>;
getOrgWorkspaces(orgId: number|string, includeSupport?: boolean): Promise<Workspace[]>;
getOrgUsageSummary(orgId: number|string): Promise<OrgUsageSummary>;
getTemplates(onlyFeatured?: boolean): Promise<Workspace[]>;
getTemplates(): Promise<Workspace[]>;
getTemplate(docId: string): Promise<Document>;
getDoc(docId: string): Promise<Document>;
newOrg(props: Partial<OrganizationProperties>): Promise<number>;
@@ -584,8 +584,8 @@ export class UserAPIImpl extends BaseAPI implements UserAPI {
return this.requestJson(`${this._url}/api/orgs/${orgId}/usage`, { method: 'GET' });
}
public async getTemplates(onlyFeatured: boolean = false): Promise<Workspace[]> {
return this.requestJson(`${this._url}/api/templates?onlyFeatured=${onlyFeatured ? 1 : 0}`, { method: 'GET' });
public async getTemplates(): Promise<Workspace[]> {
return this.requestJson(`${this._url}/api/templates`, { method: 'GET' });
}
public async getTemplate(docId: string): Promise<Document> {

View File

@@ -96,6 +96,7 @@ export const commonUrls = {
plans: "https://www.getgrist.com/pricing",
contact: "https://www.getgrist.com/contact",
templates: 'https://www.getgrist.com/templates',
webinars: getWebinarsUrl(),
community: 'https://community.getgrist.com',
functions: 'https://support.getgrist.com/functions',
formulaSheet: 'https://support.getgrist.com/formula-cheat-sheet',
@@ -703,6 +704,9 @@ export interface GristLoadConfig {
// Url for "contact support" button on Grist's "not found" error page
contactSupportUrl?: string;
// Url for webinars.
webinarsUrl?: string;
// When set, this directs the client to encode org information in path, not in domain.
pathOnly?: boolean;
@@ -907,7 +911,7 @@ export function getKnownOrg(): string|null {
export function getHelpCenterUrl(): string {
const defaultUrl = "https://support.getgrist.com";
if(isClient()) {
if (isClient()) {
const gristConfig: GristLoadConfig = (window as any).gristConfig;
return gristConfig && gristConfig.helpCenterUrl || defaultUrl;
} else {
@@ -916,7 +920,7 @@ export function getHelpCenterUrl(): string {
}
export function getTermsOfServiceUrl(): string|undefined {
if(isClient()) {
if (isClient()) {
const gristConfig: GristLoadConfig = (window as any).gristConfig;
return gristConfig && gristConfig.termsOfServiceUrl || undefined;
} else {
@@ -926,7 +930,7 @@ export function getTermsOfServiceUrl(): string|undefined {
export function getFreeCoachingCallUrl(): string {
const defaultUrl = "https://calendly.com/grist-team/grist-free-coaching-call";
if(isClient()) {
if (isClient()) {
const gristConfig: GristLoadConfig = (window as any).gristConfig;
return gristConfig && gristConfig.freeCoachingCallUrl || defaultUrl;
} else {
@@ -935,8 +939,8 @@ export function getFreeCoachingCallUrl(): string {
}
export function getContactSupportUrl(): string {
const defaultUrl = "https://www.getgrist.com/contact/";
if(isClient()) {
const defaultUrl = "https://www.getgrist.com/contact";
if (isClient()) {
const gristConfig: GristLoadConfig = (window as any).gristConfig;
return gristConfig && gristConfig.contactSupportUrl || defaultUrl;
} else {
@@ -944,6 +948,16 @@ export function getContactSupportUrl(): string {
}
}
export function getWebinarsUrl(): string {
const defaultUrl = "https://www.getgrist.com/webinars";
if (isClient()) {
const gristConfig: GristLoadConfig = (window as any).gristConfig;
return gristConfig && gristConfig.webinarsUrl || defaultUrl;
} else {
return process.env.GRIST_WEBINARS_URL || defaultUrl;
}
}
/**
* Like getKnownOrg, but respects singleOrg/GRIST_SINGLE_ORG strictly.
* The main difference in behavior would be for orgs with custom domains

View File

@@ -85,6 +85,7 @@ export const GristDark: ThemeColors = {
/* Popups */
'popup-bg': '#32323F',
'popup-secondary-bg': '#262633',
'popup-shadow-inner': '#000000',
'popup-shadow-outer': '#000000',
'popup-close-button-fg': '#A4A4B1',

View File

@@ -85,6 +85,7 @@ export const GristLight: ThemeColors = {
/* Popups */
'popup-bg': 'white',
'popup-secondary-bg': '#F7F7F7',
'popup-shadow-inner': 'rgba(31, 37, 50, 0.31)',
'popup-shadow-outer': 'rgba(76, 86, 103, 0.24)',
'popup-close-button-fg': '#929299',

View File

@@ -293,18 +293,14 @@ export class ApiServer {
}));
// GET /api/templates/
// Get all templates (or only featured templates if `onlyFeatured` is set).
// Get all templates.
this._app.get('/api/templates/', expressWrap(async (req, res) => {
const templateOrg = getTemplateOrg();
if (!templateOrg) {
throw new ApiError('Template org is not configured', 500);
throw new ApiError('Template org is not configured', 501);
}
const onlyFeatured = isParameterOn(req.query.onlyFeatured);
const query = await this._dbManager.getOrgWorkspaces(
{...getScope(req), showOnlyPinned: onlyFeatured},
templateOrg
);
const query = await this._dbManager.getOrgWorkspaces(getScope(req), templateOrg);
return sendReply(req, res, query);
}));

View File

@@ -170,7 +170,6 @@ export interface Scope {
users?: AvailableUsers; // Set if available identities.
includeSupport?: boolean; // When set, include sample resources shared by support to scope.
showRemoved?: boolean; // When set, query is scoped to removed workspaces/docs.
showOnlyPinned?: boolean; // When set, query is scoped only to pinned docs.
showAll?: boolean; // When set, return both removed and regular resources.
specialPermit?: Permit; // When set, extra rights are granted on a specific resource.
}
@@ -3399,8 +3398,6 @@ export class HomeDBManager extends EventEmitter {
const onDefault = 'docs.workspace_id = workspaces.id';
if (scope.showAll) {
return onDefault;
} else if (scope.showOnlyPinned) {
return `${onDefault} AND docs.is_pinned = TRUE AND (workspaces.removed_at IS NULL AND docs.removed_at IS NULL)`;
} else if (scope.showRemoved) {
return `${onDefault} AND (workspaces.removed_at IS NOT NULL OR docs.removed_at IS NOT NULL)`;
} else {