diff --git a/README.md b/README.md
index 67adb5a9..ece2fb5a 100644
--- a/README.md
+++ b/README.md
@@ -43,8 +43,8 @@ Here are some specific feature highlights of Grist:
- Any tool that can read SQLite can read numeric and text data from a Grist file.
- Great format for [backups](https://support.getgrist.com/exports/#backing-up-an-entire-document) that you can be confident you can restore in full.
- Great format for moving between different hosts.
- - Can be displayed on a static website with [grist-static](https://github.com/gristlabs/grist-static).
- - There's a self-contained desktop app available for viewing and editing: [grist-electron](https://github.com/gristlabs/grist-electron).
+ - Can be displayed on a static website with [grist-static](https://github.com/gristlabs/grist-static).
+ - There's a self-contained desktop app available for viewing and editing: [grist-electron](https://github.com/gristlabs/grist-electron).
* Convenient editing and formatting features.
- Choices and [choice lists](https://support.getgrist.com/col-types/#choice-list-columns), for adding colorful tags to records without fuss.
- [References](https://support.getgrist.com/col-refs/#creating-a-new-reference-list-column) and reference lists, for cross-referencing records in other tables.
@@ -78,7 +78,7 @@ Here are some specific feature highlights of Grist:
[gVisor](https://github.com/google/gvisor) sandboxing at the individual
document level.
- On OSX, you can use native sandboxing.
- - On any OS, including Windows, you can use a wasm-based sandbox.
+ - On any OS, including Windows, you can use a wasm-based sandbox.
* Translated to many languages.
If you are curious about where Grist is going heading,
@@ -93,7 +93,7 @@ If you just want a quick demo of Grist:
* You can try Grist out at the hosted service run
by Grist Labs at [docs.getgrist.com](https://docs.getgrist.com)
- (no registration needed).
+ (no registration needed).
* Or you can see an experimental fully in-browser build of Grist
at [gristlabs.github.io/grist-static](https://gristlabs.github.io/grist-static/).
* Or you can download Grist as a desktop app from [github.com/gristlabs/grist-electron](https://github.com/gristlabs/grist-electron).
@@ -227,9 +227,9 @@ Grist benefits its users:
## Sponsors
-
-
-
+
+
+
## Reviews
@@ -260,7 +260,7 @@ GRIST_DEFAULT_PRODUCT | if set, this controls enabled features and limits of ne
GRIST_DEFAULT_LOCALE | Locale to use as fallback when Grist cannot honour the browser locale.
GRIST_DOMAIN | in hosted Grist, Grist is served from subdomains of this domain. Defaults to "getgrist.com".
GRIST_EXPERIMENTAL_PLUGINS | enables experimental plugins
-GRIST_HIDE_UI_ELEMENTS | comma-separated list of parts of the UI to hide. Allowed names of parts: `helpCenter,billing,templates,multiSite,multiAccounts,sendToDrive`
+GRIST_HIDE_UI_ELEMENTS | comma-separated list of UI features to disable. Allowed names of parts: `helpCenter,billing,templates,multiSite,multiAccounts,sendToDrive,tutorials`. If a part also exists in GRIST_UI_FEATURES, it will still be disabled.
GRIST_HOME_INCLUDE_STATIC | if set, home server also serves static resources
GRIST_HOST | hostname to use when listening on a port.
GRIST_ID_PREFIX | for subdomains of form o-*, expect or produce o-${GRIST_ID_PREFIX}*.
@@ -286,6 +286,7 @@ GRIST_SUPPORT_ANON | if set to 'true', show UI for anonymous access (not shown b
GRIST_SUPPORT_EMAIL | if set, give a user with the specified email support powers. The main extra power is the ability to share sites, workspaces, and docs with all users in a listed way.
GRIST_THROTTLE_CPU | if set, CPU throttling is enabled
GRIST_USER_ROOT | an extra path to look for plugins in.
+GRIST_UI_FEATURES | comma-separated list of UI features to enable. Allowed names of parts: `helpCenter,billing,templates,multiSite,multiAccounts,sendToDrive,tutorials`. If a part also exists in GRIST_HIDE_UI_ELEMENTS, it won't be enabled.
GRIST_WIDGET_LIST_URL | a url pointing to a widget manifest
COOKIE_MAX_AGE | session cookie max age, defaults to 90 days; can be set to "none" to make it a session cookie
HOME_PORT | port number to listen on for REST API server; if set to "share", add API endpoints to regular grist port.
diff --git a/app/client/ui/AccountWidget.ts b/app/client/ui/AccountWidget.ts
index 869cd26c..413f4d66 100644
--- a/app/client/ui/AccountWidget.ts
+++ b/app/client/ui/AccountWidget.ts
@@ -9,7 +9,7 @@ import {primaryButton} from 'app/client/ui2018/buttons';
import {mediaDeviceNotSmall, testId, theme, vars} from 'app/client/ui2018/cssVars';
import {icon} from 'app/client/ui2018/icons';
import {menu, menuDivider, menuItem, menuItemLink, menuSubHeader} from 'app/client/ui2018/menus';
-import {commonUrls, shouldHideUiElement} from 'app/common/gristUrls';
+import {commonUrls, isFeatureEnabled} from 'app/common/gristUrls';
import {FullUser} from 'app/common/LoginSessionAPI';
import * as roles from 'app/common/roles';
import {Disposable, dom, DomElementArg, styled} from 'grainjs';
@@ -107,7 +107,7 @@ export class AccountWidget extends Disposable {
// In case of a single-org setup, skip all the account-switching UI. We'll also skip the
// org-listing UI below.
- this._appModel.topAppModel.isSingleOrg || shouldHideUiElement("multiAccounts") ? [] : [
+ this._appModel.topAppModel.isSingleOrg || !isFeatureEnabled("multiAccounts") ? [] : [
menuDivider(),
menuSubHeader(dom.text((use) => use(users).length > 1 ? t("Switch Accounts") : t("Accounts"))),
dom.forEach(users, (_user) => {
diff --git a/app/client/ui/HomeIntro.ts b/app/client/ui/HomeIntro.ts
index c34426d7..a5d52d10 100644
--- a/app/client/ui/HomeIntro.ts
+++ b/app/client/ui/HomeIntro.ts
@@ -9,7 +9,7 @@ import {bigBasicButton, cssButton} 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, shouldHideUiElement} from 'app/common/gristUrls';
+import {commonUrls, isFeatureEnabled} from 'app/common/gristUrls';
import {FullUser} from 'app/common/LoginSessionAPI';
import * as roles from 'app/common/roles';
import {Computed, dom, DomContents, styled} from 'grainjs';
@@ -90,7 +90,7 @@ function makeTeamSiteIntro(homeModel: HomeModel) {
testId('welcome-title')
),
cssIntroLine(t("Get started by inviting your team and creating your first Grist document.")),
- (shouldHideUiElement('helpCenter') ? null :
+ (!isFeatureEnabled('helpCenter') ? null :
cssIntroLine(
'Learn more in our ', helpCenterLink(), ', or find an expert via our ', sproutsProgram, '.', // TODO i18n
testId('welcome-text')
@@ -104,7 +104,7 @@ 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.")),
- (shouldHideUiElement('helpCenter') ? null :
+ (!isFeatureEnabled('helpCenter') ? null :
cssIntroLine(t("Visit our {{link}} to learn more.", { link: helpCenterLink() }),
testId('welcome-text'))
),
@@ -118,7 +118,7 @@ function makeAnonIntro(homeModel: HomeModel) {
css.docListHeader(t("Welcome to Grist!"), testId('welcome-title')),
cssIntroLine(t("Get started by exploring templates, or creating your first Grist document.")),
cssIntroLine(t("{{signUp}} to save your work. ", {signUp}),
- (shouldHideUiElement('helpCenter') ? null : t("Visit our {{link}} to learn more.", { link: helpCenterLink() })),
+ (!isFeatureEnabled('helpCenter') ? null : t("Visit our {{link}} to learn more.", { link: helpCenterLink() })),
testId('welcome-text')),
makeCreateButtons(homeModel),
];
@@ -143,7 +143,7 @@ function buildButtons(homeModel: HomeModel, options: {
!options.templates ? null :
cssBtn(cssBtnIcon('FieldTable'), t("Browse Templates"), testId('intro-templates'),
cssButton.cls('-primary'),
- dom.hide(shouldHideUiElement("templates")),
+ dom.show(isFeatureEnabled("templates")),
urlState().setLinkUrl({homePage: 'templates'}),
),
!options.import ? null :
diff --git a/app/client/ui/HomeLeftPane.ts b/app/client/ui/HomeLeftPane.ts
index bb8cd1b8..398a907a 100644
--- a/app/client/ui/HomeLeftPane.ts
+++ b/app/client/ui/HomeLeftPane.ts
@@ -16,7 +16,7 @@ import {testId, theme} from 'app/client/ui2018/cssVars';
import {icon} from 'app/client/ui2018/icons';
import {menu, menuIcon, menuItem, upgradableMenuItem, upgradeText} from 'app/client/ui2018/menus';
import {confirmModal} from 'app/client/ui2018/modals';
-import {commonUrls, shouldHideUiElement} from 'app/common/gristUrls';
+import {commonUrls, isFeatureEnabled} from 'app/common/gristUrls';
import * as roles from 'app/common/roles';
import {Workspace} from 'app/common/UserAPI';
import {computed, dom, domComputed, DomElementArg, observable, Observable, styled} from 'grainjs';
@@ -109,7 +109,7 @@ export function createHomeLeftPane(leftPanelOpen: Observable, home: Hom
)),
cssTools(
cssPageEntry(
- dom.hide(shouldHideUiElement("templates")),
+ dom.show(isFeatureEnabled("templates")),
cssPageEntry.cls('-selected', (use) => use(home.currentPage) === "templates"),
cssPageLink(cssPageIcon('Board'), cssLinkText(t("Examples & Templates")),
urlState().setLinkUrl({homePage: "templates"}),
@@ -125,7 +125,7 @@ export function createHomeLeftPane(leftPanelOpen: Observable, home: Hom
),
cssSpacer(),
cssPageEntry(
- dom.hide(shouldHideUiElement("templates")),
+ dom.show(isFeatureEnabled('tutorials')),
cssPageLink(cssPageIcon('Bookmark'), cssLinkText(t("Tutorial")),
{ href: commonUrls.basicTutorial, target: '_blank' },
testId('dm-basic-tutorial'),
diff --git a/app/client/ui/LeftPanelCommon.ts b/app/client/ui/LeftPanelCommon.ts
index 802e38a3..1fb75f56 100644
--- a/app/client/ui/LeftPanelCommon.ts
+++ b/app/client/ui/LeftPanelCommon.ts
@@ -18,7 +18,7 @@ import {makeT} from 'app/client/lib/localization';
import {AppModel} from 'app/client/models/AppModel';
import {testId, theme, vars} from 'app/client/ui2018/cssVars';
import {icon} from 'app/client/ui2018/icons';
-import {commonUrls, shouldHideUiElement} from 'app/common/gristUrls';
+import {commonUrls, isFeatureEnabled} from 'app/common/gristUrls';
import {dom, DomContents, Observable, styled} from 'grainjs';
const t = makeT('LeftPanelCommon');
@@ -28,7 +28,7 @@ const t = makeT('LeftPanelCommon');
* HelpCenter in a new tab.
*/
export function createHelpTools(appModel: AppModel): DomContents {
- if (shouldHideUiElement("helpCenter")) {
+ if (!isFeatureEnabled("helpCenter")) {
return [];
}
return cssSplitPageEntry(
diff --git a/app/client/ui/NotifyUI.ts b/app/client/ui/NotifyUI.ts
index 779f6ec6..acc1e2b6 100644
--- a/app/client/ui/NotifyUI.ts
+++ b/app/client/ui/NotifyUI.ts
@@ -10,7 +10,7 @@ import {theme, vars} from 'app/client/ui2018/cssVars';
import {icon} from 'app/client/ui2018/icons';
import {IconName} from "app/client/ui2018/IconList";
import {menuCssClass} from 'app/client/ui2018/menus';
-import {commonUrls, shouldHideUiElement} from 'app/common/gristUrls';
+import {commonUrls, isFeatureEnabled} from 'app/common/gristUrls';
import {dom, makeTestId, styled} from 'grainjs';
import {cssMenu, defaultMenuOptions, IOpenController, setPopupToCreateDom} from 'popweasel';
@@ -159,7 +159,7 @@ function buildNotifyDropdown(ctl: IOpenController, notifier: Notifier, appModel:
cssDropdownContent(
cssDropdownHeader(
cssDropdownHeaderTitle(t("Notifications")),
- shouldHideUiElement("helpCenter") ? null :
+ !isFeatureEnabled("helpCenter") ? null :
cssDropdownFeedbackLink(
cssDropdownFeedbackIcon('Feedback'),
t("Give feedback"),
diff --git a/app/client/ui/OpenVideoTour.ts b/app/client/ui/OpenVideoTour.ts
index e970461a..fa96d8ff 100644
--- a/app/client/ui/OpenVideoTour.ts
+++ b/app/client/ui/OpenVideoTour.ts
@@ -7,7 +7,7 @@ import {YouTubePlayer} from 'app/client/ui/YouTubePlayer';
import {theme} from 'app/client/ui2018/cssVars';
import {icon} from 'app/client/ui2018/icons';
import {cssModalCloseButton, modal} from 'app/client/ui2018/modals';
-import {shouldHideUiElement} from 'app/common/gristUrls';
+import {isFeatureEnabled} from 'app/common/gristUrls';
import {dom, makeTestId, styled} from 'grainjs';
const t = makeT('OpenVideoTour');
@@ -79,7 +79,7 @@ export function createVideoTourTextButton(): HTMLDivElement {
* Shows the video tour on click.
*/
export function createVideoTourToolsButton(): HTMLDivElement | null {
- if (shouldHideUiElement('helpCenter')) { return null; }
+ if (!isFeatureEnabled('helpCenter')) { return null; }
let iconElement: HTMLElement;
diff --git a/app/client/ui/ShareMenu.ts b/app/client/ui/ShareMenu.ts
index 5b04beeb..a68d4bda 100644
--- a/app/client/ui/ShareMenu.ts
+++ b/app/client/ui/ShareMenu.ts
@@ -11,7 +11,7 @@ import {primaryButton} from 'app/client/ui2018/buttons';
import {mediaXSmall, testId, theme} from 'app/client/ui2018/cssVars';
import {icon} from 'app/client/ui2018/icons';
import {menu, menuAnnotate, menuDivider, menuIcon, menuItem, menuItemLink, menuText} from 'app/client/ui2018/menus';
-import {buildUrlId, parseUrlId, shouldHideUiElement} from 'app/common/gristUrls';
+import {buildUrlId, isFeatureEnabled, parseUrlId} from 'app/common/gristUrls';
import * as roles from 'app/common/roles';
import {Document} from 'app/common/UserAPI';
import {dom, DomContents, styled} from 'grainjs';
@@ -244,7 +244,7 @@ function menuExports(doc: Document, pageModel: DocPageModel) {
href: pageModel.appModel.api.getDocAPI(doc.id).getDownloadXlsxUrl(),
target: '_blank', download: ''
}, menuIcon('Download'), t("Export XLSX"), testId('tb-share-option')),
- (shouldHideUiElement("sendToDrive") ? null : menuItem(() => sendToDrive(doc, pageModel),
+ (!isFeatureEnabled("sendToDrive") ? null : menuItem(() => sendToDrive(doc, pageModel),
menuIcon('Download'), t("Send to Google Drive"), testId('tb-share-option'))),
];
}
diff --git a/app/client/ui/SiteSwitcher.ts b/app/client/ui/SiteSwitcher.ts
index af8b00dc..77054b55 100644
--- a/app/client/ui/SiteSwitcher.ts
+++ b/app/client/ui/SiteSwitcher.ts
@@ -1,5 +1,5 @@
import {dom, makeTestId, styled} from 'grainjs';
-import {getSingleOrg, shouldHideUiElement} from 'app/common/gristUrls';
+import {getSingleOrg, isFeatureEnabled} from 'app/common/gristUrls';
import {getOrgName} from 'app/common/UserAPI';
import {makeT} from 'app/client/lib/localization';
import {AppModel} from 'app/client/models/AppModel';
@@ -17,7 +17,7 @@ const testId = makeTestId('test-site-switcher-');
*/
export function maybeAddSiteSwitcherSection(appModel: AppModel) {
const orgs = appModel.topAppModel.orgs;
- return dom.maybe((use) => use(orgs).length > 0 && !getSingleOrg() && !shouldHideUiElement("multiSite"), () => [
+ return dom.maybe((use) => use(orgs).length > 0 && !getSingleOrg() && isFeatureEnabled("multiSite"), () => [
menuDivider(),
buildSiteSwitcher(appModel),
]);
diff --git a/app/client/ui/TutorialCard.ts b/app/client/ui/TutorialCard.ts
index c224c3ce..4ecad7dc 100644
--- a/app/client/ui/TutorialCard.ts
+++ b/app/client/ui/TutorialCard.ts
@@ -2,7 +2,7 @@ import {AppModel} from 'app/client/models/AppModel';
import {bigPrimaryButton} from 'app/client/ui2018/buttons';
import {isNarrowScreenObs, theme} from 'app/client/ui2018/cssVars';
import {icon} from 'app/client/ui2018/icons';
-import {commonUrls, shouldHideUiElement} from 'app/common/gristUrls';
+import {commonUrls, isFeatureEnabled} from 'app/common/gristUrls';
import {Computed, dom, IDisposableOwner, makeTestId, styled} from 'grainjs';
const testId = makeTestId('test-tutorial-card-');
@@ -12,7 +12,7 @@ interface Options {
}
export function buildTutorialCard(owner: IDisposableOwner, options: Options) {
- if (shouldHideUiElement('templates')) { return null; }
+ if (!isFeatureEnabled('tutorials')) { return null; }
const {app} = options;
const dismissed = app.dismissedPopup('tutorialFirstCard');
diff --git a/app/common/gristUrls.ts b/app/common/gristUrls.ts
index eb16ea34..679d9a1d 100644
--- a/app/common/gristUrls.ts
+++ b/app/common/gristUrls.ts
@@ -584,8 +584,8 @@ export interface GristLoadConfig {
activation?: Activation;
- // Parts of the UI to hide
- hideUiElements?: IHideableUiElement[];
+ // List of enabled features.
+ features?: IFeature[];
// String to append to the end of the HTML document.title
pageTitleSuffix?: string;
@@ -612,12 +612,19 @@ export interface GristLoadConfig {
userLocale?: string;
}
-export const HideableUiElements = StringUnion("helpCenter", "billing", "templates", "multiSite", "multiAccounts",
-"sendToDrive");
-export type IHideableUiElement = typeof HideableUiElements.type;
+export const Features = StringUnion(
+ "helpCenter",
+ "billing",
+ "templates",
+ "multiSite",
+ "multiAccounts",
+ "sendToDrive",
+ "tutorials",
+);
+export type IFeature = typeof Features.type;
-export function shouldHideUiElement(elem: IHideableUiElement): boolean {
- return (getGristConfig().hideUiElements || []).includes(elem);
+export function isFeatureEnabled(feature: IFeature): boolean {
+ return (getGristConfig().features || []).includes(feature);
}
export function getPageTitleSuffix(config?: GristLoadConfig) {
diff --git a/app/server/lib/sendAppPage.ts b/app/server/lib/sendAppPage.ts
index 68c90683..a571be15 100644
--- a/app/server/lib/sendAppPage.ts
+++ b/app/server/lib/sendAppPage.ts
@@ -1,4 +1,4 @@
-import {getPageTitleSuffix, GristLoadConfig, HideableUiElements, IHideableUiElement} from 'app/common/gristUrls';
+import {Features, getPageTitleSuffix, GristLoadConfig, IFeature} from 'app/common/gristUrls';
import {isAffirmative} from 'app/common/gutil';
import {getTagManagerSnippet} from 'app/common/tagManager';
import {Document} from 'app/common/UserAPI';
@@ -13,6 +13,7 @@ import * as fse from 'fs-extra';
import jsesc from 'jsesc';
import * as handlebars from 'handlebars';
import * as path from 'path';
+import difference = require('lodash/difference');
const translate = (req: express.Request, key: string, args?: any) => req.t(`sendAppPage.${key}`, args);
@@ -47,7 +48,7 @@ export function makeGristConfig(homeUrl: string|null, extra: Partial