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