mirror of
https://github.com/gristlabs/grist-core.git
synced 2026-03-02 04:09:24 +00:00
(core) Admin Panel and InstallAdmin class to identify installation admins.
Summary: - Add InstallAdmin class to identify users who can manage Grist installation. This is overridable by different Grist flavors (e.g. different in SaaS). It generalizes previous logic used to decide who can control Activation settings (e.g. enable telemetry). - Implement a basic Admin Panel at /admin, and move items previously in the "Support Grist" page into the "Support Grist" section of the Admin Panel. - Replace "Support Grist" menu items with "Admin Panel" and show only to admins. - Add "Support Grist" links to Github sponsorship to user-account menu. - Add "Support Grist" button to top-bar, which - for admins, replaces the previous "Contribute" button and reopens the "Support Grist / opt-in to telemetry" nudge (unchanged) - for everyone else, links to Github sponsorship - in either case, user can dismiss it. Test Plan: Shuffled some test cases between Support Grist and the new Admin Panel, and added some new cases. Reviewers: jarek, paulfitz Reviewed By: jarek, paulfitz Differential Revision: https://phab.getgrist.com/D4194
This commit is contained in:
@@ -1,6 +1,7 @@
|
||||
import {AppModel} from 'app/client/models/AppModel';
|
||||
import {DocPageModel} from 'app/client/models/DocPageModel';
|
||||
import {getLoginOrSignupUrl, getLoginUrl, getLogoutUrl, getSignupUrl, urlState} from 'app/client/models/gristUrlState';
|
||||
import {getAdminPanelName} from 'app/client/ui/AdminPanel';
|
||||
import {manageTeamUsers} from 'app/client/ui/OpenUserManager';
|
||||
import {createUserImage} from 'app/client/ui/UserImage';
|
||||
import * as viewport from 'app/client/ui/viewport';
|
||||
@@ -146,8 +147,8 @@ export class AccountWidget extends Disposable {
|
||||
|
||||
this._maybeBuildBillingPageMenuItem(),
|
||||
this._maybeBuildActivationPageMenuItem(),
|
||||
this._maybeBuildSupportGristPageMenuItem(),
|
||||
|
||||
this._maybeBuildAdminPanelMenuItem(),
|
||||
this._maybeBuildSupportGristButton(),
|
||||
mobileModeToggle,
|
||||
|
||||
// TODO Add section ("Here right now") listing icons of other users currently on this doc.
|
||||
@@ -209,26 +210,34 @@ export class AccountWidget extends Disposable {
|
||||
}
|
||||
|
||||
private _maybeBuildActivationPageMenuItem() {
|
||||
const {activation, deploymentType} = getGristConfig();
|
||||
if (deploymentType !== 'enterprise' || !activation?.isManager) {
|
||||
const {deploymentType} = getGristConfig();
|
||||
if (deploymentType !== 'enterprise' || !this._appModel.isInstallAdmin()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return menuItemLink(t('Activation'), urlState().setLinkUrl({activation: 'activation'}));
|
||||
}
|
||||
|
||||
private _maybeBuildSupportGristPageMenuItem() {
|
||||
const {deploymentType} = getGristConfig();
|
||||
if (deploymentType !== 'core') {
|
||||
return null;
|
||||
private _maybeBuildAdminPanelMenuItem() {
|
||||
// Only show Admin Panel item to the installation admins.
|
||||
if (this._appModel.currentUser?.isInstallAdmin) {
|
||||
return menuItemLink(
|
||||
getAdminPanelName(),
|
||||
urlState().setLinkUrl({adminPanel: 'admin'}),
|
||||
testId('usermenu-admin-panel'),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return menuItemLink(
|
||||
t('Support Grist'),
|
||||
cssHeartIcon('💛'),
|
||||
urlState().setLinkUrl({supportGrist: 'support'}),
|
||||
testId('usermenu-support-grist'),
|
||||
);
|
||||
private _maybeBuildSupportGristButton() {
|
||||
const {deploymentType} = getGristConfig();
|
||||
const isEnabled = (deploymentType === 'core') && isFeatureEnabled("supportGrist");
|
||||
if (isEnabled) {
|
||||
return menuItemLink(t('Support Grist'), ' 💛',
|
||||
{href: commonUrls.githubSponsorGristLabs, target: '_blank'},
|
||||
testId('usermenu-support-grist'),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -245,10 +254,6 @@ export const cssUserIcon = styled('div', `
|
||||
cursor: pointer;
|
||||
`);
|
||||
|
||||
const cssHeartIcon = styled('span', `
|
||||
margin-left: 8px;
|
||||
`);
|
||||
|
||||
const cssUserInfo = styled('div', `
|
||||
padding: 12px 24px 12px 16px;
|
||||
min-width: 200px;
|
||||
|
||||
270
app/client/ui/AdminPanel.ts
Normal file
270
app/client/ui/AdminPanel.ts
Normal file
@@ -0,0 +1,270 @@
|
||||
import {getPageTitleSuffix} from 'app/common/gristUrls';
|
||||
import {getGristConfig} from 'app/common/urlUtils';
|
||||
import * as version from 'app/common/version';
|
||||
import {buildHomeBanners} from 'app/client/components/Banners';
|
||||
import {makeT} from 'app/client/lib/localization';
|
||||
import {AppModel} from 'app/client/models/AppModel';
|
||||
import {urlState} from 'app/client/models/gristUrlState';
|
||||
import {AppHeader} from 'app/client/ui/AppHeader';
|
||||
import {leftPanelBasic} from 'app/client/ui/LeftPanelCommon';
|
||||
import {pagePanels} from 'app/client/ui/PagePanels';
|
||||
import {SupportGristPage} from 'app/client/ui/SupportGristPage';
|
||||
import {createTopBarHome} from 'app/client/ui/TopBar';
|
||||
import {transition} from 'app/client/ui/transitions';
|
||||
import {cssBreadcrumbs, separator} from 'app/client/ui2018/breadcrumbs';
|
||||
import {mediaSmall, testId, theme, vars} from 'app/client/ui2018/cssVars';
|
||||
import {icon} from 'app/client/ui2018/icons';
|
||||
import {cssLink} from 'app/client/ui2018/links';
|
||||
import {Disposable, dom, DomContents, IDisposableOwner, Observable, styled} from 'grainjs';
|
||||
|
||||
const t = makeT('AdminPanel');
|
||||
|
||||
// Translated "Admin Panel" name, made available to other modules.
|
||||
export function getAdminPanelName() {
|
||||
return t("Admin Panel");
|
||||
}
|
||||
|
||||
export class AdminPanel extends Disposable {
|
||||
private _supportGrist = SupportGristPage.create(this, this._appModel);
|
||||
|
||||
constructor(private _appModel: AppModel) {
|
||||
super();
|
||||
document.title = getAdminPanelName() + getPageTitleSuffix(getGristConfig());
|
||||
}
|
||||
|
||||
public buildDom() {
|
||||
const panelOpen = Observable.create(this, false);
|
||||
return pagePanels({
|
||||
leftPanel: {
|
||||
panelWidth: Observable.create(this, 240),
|
||||
panelOpen,
|
||||
hideOpener: true,
|
||||
header: dom.create(AppHeader, this._appModel),
|
||||
content: leftPanelBasic(this._appModel, panelOpen),
|
||||
},
|
||||
headerMain: this._buildMainHeader(),
|
||||
contentTop: buildHomeBanners(this._appModel),
|
||||
contentMain: dom.create(this._buildMainContent.bind(this)),
|
||||
});
|
||||
}
|
||||
|
||||
private _buildMainHeader() {
|
||||
return dom.frag(
|
||||
cssBreadcrumbs({style: 'margin-left: 16px;'},
|
||||
cssLink(
|
||||
urlState().setLinkUrl({}),
|
||||
t('Home'),
|
||||
),
|
||||
separator(' / '),
|
||||
dom('span', getAdminPanelName()),
|
||||
),
|
||||
createTopBarHome(this._appModel),
|
||||
);
|
||||
}
|
||||
|
||||
private _buildMainContent(owner: IDisposableOwner) {
|
||||
return cssPageContainer(
|
||||
dom.cls('clipboard'),
|
||||
{tabIndex: "-1"},
|
||||
cssSection(
|
||||
cssSectionTitle(t('Support Grist')),
|
||||
this._buildItem(owner, {
|
||||
id: 'telemetry',
|
||||
name: t('Telemetry'),
|
||||
description: t('Help us make Grist better'),
|
||||
value: maybeSwitchToggle(this._supportGrist.getTelemetryOptInObservable()),
|
||||
expandedContent: this._supportGrist.buildTelemetrySection(),
|
||||
}),
|
||||
this._buildItem(owner, {
|
||||
id: 'sponsor',
|
||||
name: t('Sponsor'),
|
||||
description: t('Support Grist Labs on GitHub'),
|
||||
value: this._supportGrist.buildSponsorshipSmallButton(),
|
||||
expandedContent: this._supportGrist.buildSponsorshipSection(),
|
||||
}),
|
||||
),
|
||||
cssSection(
|
||||
cssSectionTitle(t('Version')),
|
||||
this._buildItem(owner, {
|
||||
id: 'version',
|
||||
name: t('Current'),
|
||||
description: t('Current version of Grist'),
|
||||
value: cssValueLabel(`Version ${version.version}`),
|
||||
}),
|
||||
),
|
||||
testId('admin-panel'),
|
||||
);
|
||||
}
|
||||
|
||||
private _buildItem(owner: IDisposableOwner, options: {
|
||||
id: string,
|
||||
name: DomContents,
|
||||
description: DomContents,
|
||||
value: DomContents,
|
||||
expandedContent?: DomContents,
|
||||
}) {
|
||||
const itemContent = [
|
||||
cssItemName(options.name, testId(`admin-panel-item-name-${options.id}`)),
|
||||
cssItemDescription(options.description),
|
||||
cssItemValue(options.value,
|
||||
testId(`admin-panel-item-value-${options.id}`),
|
||||
dom.on('click', ev => ev.stopPropagation())),
|
||||
];
|
||||
if (options.expandedContent) {
|
||||
const isCollapsed = Observable.create(owner, true);
|
||||
return cssItem(
|
||||
cssItemShort(
|
||||
dom.domComputed(isCollapsed, (c) => cssCollapseIcon(c ? 'Expand' : 'Collapse')),
|
||||
itemContent,
|
||||
cssItemShort.cls('-expandable'),
|
||||
dom.on('click', () => isCollapsed.set(!isCollapsed.get())),
|
||||
),
|
||||
cssExpandedContentWrap(
|
||||
transition(isCollapsed, {
|
||||
prepare(elem, close) { elem.style.maxHeight = close ? elem.scrollHeight + 'px' : '0'; },
|
||||
run(elem, close) { elem.style.maxHeight = close ? '0' : elem.scrollHeight + 'px'; },
|
||||
finish(elem, close) { elem.style.maxHeight = close ? '0' : 'unset'; },
|
||||
}),
|
||||
cssExpandedContent(
|
||||
options.expandedContent,
|
||||
),
|
||||
),
|
||||
testId(`admin-panel-item-${options.id}`),
|
||||
);
|
||||
} else {
|
||||
return cssItem(
|
||||
cssItemShort(itemContent),
|
||||
testId(`admin-panel-item-${options.id}`),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function maybeSwitchToggle(value: Observable<boolean|null>): DomContents {
|
||||
return dom('div.widget_switch',
|
||||
(elem) => elem.style.setProperty('--grist-actual-cell-color', theme.controlFg.toString()),
|
||||
dom.hide((use) => use(value) === null),
|
||||
dom.cls('switch_on', (use) => use(value) || false),
|
||||
dom.cls('switch_transition', true),
|
||||
dom.on('click', () => value.set(!value.get())),
|
||||
dom('div.switch_slider'),
|
||||
dom('div.switch_circle'),
|
||||
);
|
||||
}
|
||||
|
||||
const cssPageContainer = styled('div', `
|
||||
overflow: auto;
|
||||
padding: 40px;
|
||||
font-size: ${vars.introFontSize};
|
||||
color: ${theme.text};
|
||||
|
||||
@media ${mediaSmall} {
|
||||
& {
|
||||
padding: 0px;
|
||||
font-size: ${vars.mediumFontSize};
|
||||
}
|
||||
}
|
||||
`);
|
||||
|
||||
const cssSection = styled('div', `
|
||||
padding: 24px;
|
||||
max-width: 600px;
|
||||
width: 100%;
|
||||
margin: 16px auto;
|
||||
border: 1px solid ${theme.widgetBorder};
|
||||
border-radius: 4px;
|
||||
|
||||
@media ${mediaSmall} {
|
||||
& {
|
||||
width: auto;
|
||||
padding: 12px;
|
||||
margin: 8px;
|
||||
}
|
||||
}
|
||||
`);
|
||||
|
||||
const cssSectionTitle = styled('div', `
|
||||
height: 32px;
|
||||
line-height: 32px;
|
||||
margin-bottom: 16px;
|
||||
font-size: ${vars.headerControlFontSize};
|
||||
font-weight: ${vars.headerControlTextWeight};
|
||||
`);
|
||||
|
||||
const cssItem = styled('div', `
|
||||
margin-top: 8px;
|
||||
`);
|
||||
|
||||
const cssItemShort = styled('div', `
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
padding: 8px;
|
||||
margin: 0 -8px;
|
||||
border-radius: 4px;
|
||||
&-expandable {
|
||||
cursor: pointer;
|
||||
}
|
||||
&-expandable:hover {
|
||||
background-color: ${theme.lightHover};
|
||||
}
|
||||
`);
|
||||
|
||||
const cssItemName = styled('div', `
|
||||
width: 112px;
|
||||
font-weight: bold;
|
||||
font-size: ${vars.largeFontSize};
|
||||
&:first-child {
|
||||
margin-left: 28px;
|
||||
}
|
||||
@media ${mediaSmall} {
|
||||
& {
|
||||
width: calc(100% - 28px);
|
||||
}
|
||||
&:first-child {
|
||||
margin-left: 0;
|
||||
}
|
||||
}
|
||||
`);
|
||||
|
||||
const cssItemDescription = styled('div', `
|
||||
margin-right: auto;
|
||||
`);
|
||||
|
||||
const cssItemValue = styled('div', `
|
||||
flex: none;
|
||||
margin: -16px;
|
||||
padding: 16px;
|
||||
cursor: auto;
|
||||
`);
|
||||
|
||||
const cssCollapseIcon = styled(icon, `
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
margin-right: 4px;
|
||||
margin-left: -4px;
|
||||
--icon-color: ${theme.lightText};
|
||||
`);
|
||||
|
||||
const cssExpandedContentWrap = styled('div', `
|
||||
transition: max-height 0.3s ease-in-out;
|
||||
overflow: hidden;
|
||||
max-height: 0;
|
||||
`);
|
||||
|
||||
const cssExpandedContent = styled('div', `
|
||||
margin-left: 24px;
|
||||
padding: 24px 0;
|
||||
border-bottom: 1px solid ${theme.widgetBorder};
|
||||
.${cssItem.className}:last-child & {
|
||||
padding-bottom: 0;
|
||||
border-bottom: none;
|
||||
}
|
||||
`);
|
||||
|
||||
const cssValueLabel = styled('div', `
|
||||
padding: 4px 8px;
|
||||
color: ${theme.text};
|
||||
border: 1px solid ${theme.inputBorder};
|
||||
border-radius: ${vars.controlBorderRadius};
|
||||
`);
|
||||
@@ -167,8 +167,8 @@ export class AppHeader extends Disposable {
|
||||
}
|
||||
|
||||
private _maybeBuildActivationPageMenuItem() {
|
||||
const {activation, deploymentType} = getGristConfig();
|
||||
if (deploymentType !== 'enterprise' || !activation?.isManager) {
|
||||
const {deploymentType} = getGristConfig();
|
||||
if (deploymentType !== 'enterprise' || !this._appModel.isInstallAdmin()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import {buildDocumentBanners, buildHomeBanners} from 'app/client/components/Banners';
|
||||
import {ViewAsBanner} from 'app/client/components/ViewAsBanner';
|
||||
import {domAsync} from 'app/client/lib/domAsync';
|
||||
import {loadAccountPage, loadActivationPage, loadBillingPage, loadSupportGristPage} from 'app/client/lib/imports';
|
||||
import {loadAccountPage, loadActivationPage, loadAdminPanel, loadBillingPage} from 'app/client/lib/imports';
|
||||
import {createSessionObs, isBoolean, isNumber} from 'app/client/lib/sessionObs';
|
||||
import {AppModel, TopAppModel} from 'app/client/models/AppModel';
|
||||
import {DocPageModelImpl} from 'app/client/models/DocPageModel';
|
||||
@@ -81,8 +81,8 @@ function createMainPage(appModel: AppModel, appObj: App) {
|
||||
return dom.create(WelcomePage, appModel);
|
||||
} else if (pageType === 'account') {
|
||||
return domAsync(loadAccountPage().then(ap => dom.create(ap.AccountPage, appModel)));
|
||||
} else if (pageType === 'support') {
|
||||
return domAsync(loadSupportGristPage().then(sgp => dom.create(sgp.SupportGristPage, appModel)));
|
||||
} else if (pageType === 'admin') {
|
||||
return domAsync(loadAdminPanel().then(m => dom.create(m.AdminPanel, appModel)));
|
||||
} else if (pageType === 'activation') {
|
||||
return domAsync(loadActivationPage().then(ap => dom.create(ap.ActivationPage, appModel)));
|
||||
} else {
|
||||
|
||||
@@ -180,7 +180,7 @@ function createLoadedDocMenu(owner: IDisposableOwner, home: HomeModel) {
|
||||
// manage card popups will be needed if more are added later.
|
||||
return [
|
||||
upgradeButton.showUpgradeCard(css.upgradeCard.cls('')),
|
||||
home.app.supportGristNudge.showCard(),
|
||||
home.app.supportGristNudge.buildNudgeCard(),
|
||||
];
|
||||
}),
|
||||
));
|
||||
|
||||
@@ -5,6 +5,7 @@ import {reportError} from 'app/client/models/AppModel';
|
||||
import {docUrl, urlState} from 'app/client/models/gristUrlState';
|
||||
import {HomeModel} from 'app/client/models/HomeModel';
|
||||
import {getWorkspaceInfo, workspaceName} from 'app/client/models/WorkspaceInfo';
|
||||
import {getAdminPanelName} from 'app/client/ui/AdminPanel';
|
||||
import {addNewButton, cssAddNewButton} from 'app/client/ui/AddNewButton';
|
||||
import {docImport, importFromPlugin} from 'app/client/ui/HomeImports';
|
||||
import {
|
||||
@@ -136,6 +137,14 @@ export function createHomeLeftPane(leftPanelOpen: Observable<boolean>, home: Hom
|
||||
),
|
||||
),
|
||||
createVideoTourToolsButton(),
|
||||
(home.app.isInstallAdmin() ?
|
||||
cssPageEntry(
|
||||
cssPageLink(cssPageIcon('Settings'), cssLinkText(getAdminPanelName()),
|
||||
urlState().setLinkUrl({adminPanel: "admin"}),
|
||||
testId('dm-admin-panel'),
|
||||
),
|
||||
) : null
|
||||
),
|
||||
createHelpTools(home.app),
|
||||
)
|
||||
)
|
||||
|
||||
@@ -5,15 +5,13 @@ 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 {bigPrimaryButton} from 'app/client/ui2018/buttons';
|
||||
import {colors, isNarrowScreenObs, theme, vars} from 'app/client/ui2018/cssVars';
|
||||
import {basicButton, basicButtonLink, bigPrimaryButton} from 'app/client/ui2018/buttons';
|
||||
import {colors, isNarrowScreenObs, testId, theme, vars} from 'app/client/ui2018/cssVars';
|
||||
import {icon} from 'app/client/ui2018/icons';
|
||||
import {cssLink} from 'app/client/ui2018/links';
|
||||
import {commonUrls} from 'app/common/gristUrls';
|
||||
import {commonUrls, isFeatureEnabled} from 'app/common/gristUrls';
|
||||
import {getGristConfig} from 'app/common/urlUtils';
|
||||
import {Disposable, dom, makeTestId, Observable, styled} from 'grainjs';
|
||||
|
||||
const testId = makeTestId('test-support-grist-nudge-');
|
||||
import {Computed, Disposable, dom, DomContents, Observable, styled, UseCB} from 'grainjs';
|
||||
|
||||
const t = makeT('SupportGristNudge');
|
||||
|
||||
@@ -21,115 +19,93 @@ type ButtonState =
|
||||
| 'collapsed'
|
||||
| 'expanded';
|
||||
|
||||
type CardPage =
|
||||
| 'support'
|
||||
| 'opted-in';
|
||||
|
||||
/**
|
||||
* Nudges users to support Grist by opting in to telemetry.
|
||||
* Nudges users to support Grist by opting in to telemetry or sponsoring on Github.
|
||||
*
|
||||
* This currently includes a button that opens a card with the nudge.
|
||||
* The button is hidden when the card is visible, and vice versa.
|
||||
* For installation admins, this includes a card 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.
|
||||
*/
|
||||
export class SupportGristNudge extends Disposable {
|
||||
private readonly _telemetryModel: TelemetryModel = new TelemetryModelImpl(this._appModel);
|
||||
private readonly _telemetryModel: TelemetryModel = TelemetryModelImpl.create(this, this._appModel);
|
||||
|
||||
private readonly _buttonState: Observable<ButtonState>;
|
||||
private readonly _currentPage: Observable<CardPage>;
|
||||
private readonly _isClosed: Observable<boolean>;
|
||||
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();
|
||||
if (!this._shouldShowCardOrButton()) { return; }
|
||||
const {deploymentType, telemetry} = getGristConfig();
|
||||
const isEnabled = (deploymentType === 'core') && isFeatureEnabled("supportGrist");
|
||||
const isAdmin = _appModel.isInstallAdmin();
|
||||
const isTelemetryOn = (telemetry && telemetry.telemetryLevel !== 'off');
|
||||
const isAdminNudgeApplicable = isAdmin && !isTelemetryOn;
|
||||
|
||||
this._buttonState = localStorageObs(
|
||||
`u=${this._appModel.currentValidUser?.id ?? 0};supportGristNudge`, 'expanded'
|
||||
) as Observable<ButtonState>;
|
||||
this._currentPage = Observable.create(null, 'support');
|
||||
this._isClosed = Observable.create(this, false);
|
||||
}
|
||||
|
||||
public showButton() {
|
||||
if (!this._shouldShowCardOrButton()) { return null; }
|
||||
|
||||
return dom.maybe(
|
||||
use => !use(isNarrowScreenObs()) && (use(this._buttonState) === 'collapsed' && !use(this._isClosed)),
|
||||
() => this._buildButton()
|
||||
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;
|
||||
});
|
||||
|
||||
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;
|
||||
});
|
||||
}
|
||||
|
||||
public showCard() {
|
||||
if (!this._shouldShowCardOrButton()) { return null; }
|
||||
public buildTopBarButton(): DomContents {
|
||||
return dom.domComputed(this._showButton, (which) => {
|
||||
if (!which) { return null; }
|
||||
const elemType = (which === 'link') ? basicButtonLink : basicButton;
|
||||
return cssContributeButton(
|
||||
elemType(cssHeartIcon('💛 '), t('Support Grist'),
|
||||
(which === 'link' ?
|
||||
{href: commonUrls.githubSponsorGristLabs, target: '_blank'} :
|
||||
dom.on('click', () => this._buttonState.set('expanded'))
|
||||
),
|
||||
|
||||
return dom.maybe(
|
||||
use => !use(isNarrowScreenObs()) && (use(this._buttonState) === 'expanded' && !use(this._isClosed)),
|
||||
() => this._buildCard()
|
||||
);
|
||||
cssContributeButtonCloseButton(
|
||||
icon('CrossSmall'),
|
||||
dom.on('click', (ev) => {
|
||||
ev.stopPropagation();
|
||||
ev.preventDefault();
|
||||
this._dismissAndClose();
|
||||
}),
|
||||
testId('support-grist-button-dismiss'),
|
||||
),
|
||||
testId('support-grist-button'),
|
||||
)
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
private _markAsDismissed() {
|
||||
this._appModel.dismissedPopup('supportGrist').set(true);
|
||||
getStorage().removeItem(
|
||||
`u=${this._appModel.currentValidUser?.id ?? 0};supportGristNudge`);
|
||||
|
||||
}
|
||||
|
||||
private _close() {
|
||||
this._isClosed.set(true);
|
||||
}
|
||||
|
||||
private _dismissAndClose() {
|
||||
this._markAsDismissed();
|
||||
this._close();
|
||||
}
|
||||
|
||||
private _shouldShowCardOrButton() {
|
||||
if (this._appModel.dismissedPopups.get().includes('supportGrist')) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const {activation, deploymentType, telemetry} = getGristConfig();
|
||||
if (deploymentType !== 'core' || !activation?.isManager) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (telemetry && telemetry.telemetryLevel !== 'off') {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private _buildButton() {
|
||||
return cssContributeButton(
|
||||
cssButtonIconAndText(
|
||||
icon('Fireworks'),
|
||||
t('Contribute'),
|
||||
),
|
||||
cssContributeButtonCloseButton(
|
||||
icon('CrossSmall'),
|
||||
dom.on('click', (ev) => {
|
||||
ev.stopPropagation();
|
||||
this._dismissAndClose();
|
||||
}),
|
||||
testId('contribute-button-close'),
|
||||
),
|
||||
dom.on('click', () => { this._buttonState.set('expanded'); }),
|
||||
testId('contribute-button'),
|
||||
);
|
||||
}
|
||||
|
||||
private _buildCard() {
|
||||
return cssCard(
|
||||
dom.domComputed(this._currentPage, page => {
|
||||
if (page === 'support') {
|
||||
return this._buildSupportGristCardContent();
|
||||
} else {
|
||||
return this._buildOptedInCardContent();
|
||||
}
|
||||
}),
|
||||
testId('card'),
|
||||
);
|
||||
public buildNudgeCard() {
|
||||
return dom.domComputed(this._showNudge, nudge => {
|
||||
if (!nudge) { return null; }
|
||||
return cssCard(
|
||||
(nudge === 'normal' ?
|
||||
this._buildSupportGristCardContent() :
|
||||
this._buildOptedInCardContent()
|
||||
),
|
||||
testId('support-nudge'),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
private _buildSupportGristCardContent() {
|
||||
@@ -137,7 +113,7 @@ export class SupportGristNudge extends Disposable {
|
||||
cssCloseButton(
|
||||
icon('CrossBig'),
|
||||
dom.on('click', () => this._buttonState.set('collapsed')),
|
||||
testId('card-close'),
|
||||
testId('support-nudge-close'),
|
||||
),
|
||||
cssLeftAlignedHeader(t('Support Grist')),
|
||||
cssParagraph(t(
|
||||
@@ -150,14 +126,14 @@ export class SupportGristNudge extends Disposable {
|
||||
'document contents. Opt out any time from the {{supportGristLink}} in the user menu.',
|
||||
{
|
||||
helpCenterLink: helpCenterLink(),
|
||||
supportGristLink: supportGristLink(),
|
||||
supportGristLink: adminPanelLink(),
|
||||
},
|
||||
),
|
||||
),
|
||||
cssFullWidthButton(
|
||||
t('Opt in to Telemetry'),
|
||||
dom.on('click', () => this._optInToTelemetry()),
|
||||
testId('card-opt-in'),
|
||||
testId('support-nudge-opt-in'),
|
||||
),
|
||||
];
|
||||
}
|
||||
@@ -166,8 +142,8 @@ export class SupportGristNudge extends Disposable {
|
||||
return [
|
||||
cssCloseButton(
|
||||
icon('CrossBig'),
|
||||
dom.on('click', () => this._close()),
|
||||
testId('card-close-icon-button'),
|
||||
dom.on('click', () => this._justAccepted.set(false)),
|
||||
testId('support-nudge-close'),
|
||||
),
|
||||
cssCenteredFlex(cssSparks()),
|
||||
cssCenterAlignedHeader(t('Opted In')),
|
||||
@@ -175,23 +151,34 @@ export class SupportGristNudge extends Disposable {
|
||||
t(
|
||||
'Thank you! Your trust and support is greatly appreciated. ' +
|
||||
'Opt out any time from the {{link}} in the user menu.',
|
||||
{link: supportGristLink()},
|
||||
{link: adminPanelLink()},
|
||||
),
|
||||
),
|
||||
cssCenteredFlex(
|
||||
cssPrimaryButton(
|
||||
t('Close'),
|
||||
dom.on('click', () => this._close()),
|
||||
testId('card-close-button'),
|
||||
dom.on('click', () => this._justAccepted.set(false)),
|
||||
testId('support-nudge-close-button'),
|
||||
),
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
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._currentPage.set('opted-in');
|
||||
this._markAsDismissed();
|
||||
this._markDismissed();
|
||||
this._justAccepted.set(true);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -202,10 +189,10 @@ function helpCenterLink() {
|
||||
);
|
||||
}
|
||||
|
||||
function supportGristLink() {
|
||||
function adminPanelLink() {
|
||||
return cssLink(
|
||||
t('Support Grist page'),
|
||||
{href: urlState().makeUrl({supportGrist: 'support'}), target: '_blank'},
|
||||
t('Admin Panel'),
|
||||
{href: urlState().makeUrl({adminPanel: 'admin'}), target: '_blank'},
|
||||
);
|
||||
}
|
||||
|
||||
@@ -216,26 +203,8 @@ const cssCenteredFlex = styled('div', `
|
||||
`);
|
||||
|
||||
const cssContributeButton = styled('div', `
|
||||
position: relative;
|
||||
background: ${theme.controlPrimaryBg};
|
||||
color: ${theme.controlPrimaryFg};
|
||||
border-radius: 25px;
|
||||
padding: 4px 12px 4px 8px;
|
||||
font-style: normal;
|
||||
font-weight: medium;
|
||||
font-size: 13px;
|
||||
line-height: 16px;
|
||||
cursor: pointer;
|
||||
--icon-color: ${theme.controlPrimaryFg};
|
||||
|
||||
&:hover {
|
||||
background: ${theme.controlPrimaryHoverBg};
|
||||
}
|
||||
`);
|
||||
|
||||
const cssButtonIconAndText = styled('div', `
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
margin-left: 8px;
|
||||
margin-right: 8px;
|
||||
`);
|
||||
|
||||
const cssContributeButtonCloseButton = styled(tokenFieldStyles.cssDeleteButton, `
|
||||
@@ -254,10 +223,14 @@ const cssContributeButtonCloseButton = styled(tokenFieldStyles.cssDeleteButton,
|
||||
display: none;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
--icon-color: ${colors.light};
|
||||
|
||||
.${cssContributeButton.className}:hover & {
|
||||
display: flex;
|
||||
}
|
||||
&:hover {
|
||||
--icon-color: ${colors.lightGreen};
|
||||
}
|
||||
`);
|
||||
|
||||
const cssCard = styled('div', `
|
||||
@@ -325,3 +298,8 @@ const cssSparks = styled('div', `
|
||||
display: inline-block;
|
||||
background-repeat: no-repeat;
|
||||
`);
|
||||
|
||||
// This is just to avoid the emoji pushing the button to be taller.
|
||||
const cssHeartIcon = styled('span', `
|
||||
line-height: 1;
|
||||
`);
|
||||
|
||||
@@ -1,29 +1,20 @@
|
||||
import {buildHomeBanners} from 'app/client/components/Banners';
|
||||
import {makeT} from 'app/client/lib/localization';
|
||||
import {AppModel} from 'app/client/models/AppModel';
|
||||
import {urlState} from 'app/client/models/gristUrlState';
|
||||
import {TelemetryModel, TelemetryModelImpl} from 'app/client/models/TelemetryModel';
|
||||
import {AppHeader} from 'app/client/ui/AppHeader';
|
||||
import {leftPanelBasic} from 'app/client/ui/LeftPanelCommon';
|
||||
import {pagePanels} from 'app/client/ui/PagePanels';
|
||||
import {createTopBarHome} from 'app/client/ui/TopBar';
|
||||
import {cssBreadcrumbs, separator} from 'app/client/ui2018/breadcrumbs';
|
||||
import {bigBasicButton, bigBasicButtonLink, bigPrimaryButton} from 'app/client/ui2018/buttons';
|
||||
import {mediaSmall, theme, vars} from 'app/client/ui2018/cssVars';
|
||||
import {basicButtonLink, bigBasicButton, bigBasicButtonLink, bigPrimaryButton} from 'app/client/ui2018/buttons';
|
||||
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 {commonUrls, getPageTitleSuffix} from 'app/common/gristUrls';
|
||||
import {commonUrls} from 'app/common/gristUrls';
|
||||
import {TelemetryPrefsWithSources} from 'app/common/InstallAPI';
|
||||
import {getGristConfig} from 'app/common/urlUtils';
|
||||
import {Computed, Disposable, dom, makeTestId, Observable, styled, subscribe} from 'grainjs';
|
||||
import {Computed, Disposable, dom, makeTestId, styled} from 'grainjs';
|
||||
|
||||
const testId = makeTestId('test-support-grist-page-');
|
||||
|
||||
const t = makeT('SupportGristPage');
|
||||
|
||||
export class SupportGristPage extends Disposable {
|
||||
private readonly _currentPage = Computed.create(this, urlState().state, (_use, s) => s.supportGrist);
|
||||
private readonly _model: TelemetryModel = new TelemetryModelImpl(this._appModel);
|
||||
private readonly _optInToTelemetry = Computed.create(this, this._model.prefs,
|
||||
(_use, prefs) => {
|
||||
@@ -38,62 +29,19 @@ export class SupportGristPage extends Disposable {
|
||||
|
||||
constructor(private _appModel: AppModel) {
|
||||
super();
|
||||
this._setPageTitle();
|
||||
this._model.fetchTelemetryPrefs().catch(reportError);
|
||||
}
|
||||
|
||||
public buildDom() {
|
||||
const panelOpen = Observable.create(this, false);
|
||||
return pagePanels({
|
||||
leftPanel: {
|
||||
panelWidth: Observable.create(this, 240),
|
||||
panelOpen,
|
||||
hideOpener: true,
|
||||
header: dom.create(AppHeader, this._appModel),
|
||||
content: leftPanelBasic(this._appModel, panelOpen),
|
||||
},
|
||||
headerMain: this._buildMainHeader(),
|
||||
contentTop: buildHomeBanners(this._appModel),
|
||||
contentMain: this._buildMainContent(),
|
||||
});
|
||||
}
|
||||
|
||||
private _buildMainHeader() {
|
||||
return dom.frag(
|
||||
cssBreadcrumbs({style: 'margin-left: 16px;'},
|
||||
cssLink(
|
||||
urlState().setLinkUrl({}),
|
||||
t('Home'),
|
||||
),
|
||||
separator(' / '),
|
||||
dom('span', t('Support Grist')),
|
||||
),
|
||||
createTopBarHome(this._appModel),
|
||||
);
|
||||
}
|
||||
|
||||
private _buildMainContent() {
|
||||
return cssPageContainer(
|
||||
cssPage(
|
||||
dom('div',
|
||||
cssPageTitle(t('Support Grist')),
|
||||
this._buildTelemetrySection(),
|
||||
this._buildSponsorshipSection(),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
private _buildTelemetrySection() {
|
||||
public buildTelemetrySection() {
|
||||
return cssSection(
|
||||
cssSectionTitle(t('Telemetry')),
|
||||
dom.domComputed(this._model.prefs, prefs => {
|
||||
if (prefs === null) {
|
||||
return cssSpinnerBox(loadingSpinner());
|
||||
}
|
||||
|
||||
const {activation} = getGristConfig();
|
||||
if (!activation?.isManager) {
|
||||
if (!this._appModel.isInstallAdmin()) {
|
||||
// TODO: We are no longer serving this page to non-admin users, so this branch should no
|
||||
// longer match, and this version perhaps should be removed.
|
||||
if (prefs.telemetryLevel.value === 'limited') {
|
||||
return [
|
||||
cssParagraph(t(
|
||||
@@ -127,7 +75,9 @@ export class SupportGristPage extends Disposable {
|
||||
);
|
||||
}
|
||||
|
||||
private _buildTelemetrySectionButtons(prefs: TelemetryPrefsWithSources) {
|
||||
public getTelemetryOptInObservable() { return this._optInToTelemetry; }
|
||||
|
||||
public _buildTelemetrySectionButtons(prefs: TelemetryPrefsWithSources) {
|
||||
const {telemetryLevel: {value, source}} = prefs;
|
||||
if (source === 'preferences') {
|
||||
return dom.domComputed(this._optInToTelemetry, (optedIn) => {
|
||||
@@ -159,9 +109,8 @@ export class SupportGristPage extends Disposable {
|
||||
}
|
||||
}
|
||||
|
||||
private _buildSponsorshipSection() {
|
||||
public buildSponsorshipSection() {
|
||||
return cssSection(
|
||||
cssSectionTitle(t('Sponsor Grist Labs on GitHub')),
|
||||
cssParagraph(
|
||||
t(
|
||||
'Grist software is developed by Grist Labs, which offers free and paid ' +
|
||||
@@ -189,16 +138,9 @@ export class SupportGristPage extends Disposable {
|
||||
);
|
||||
}
|
||||
|
||||
private _setPageTitle() {
|
||||
this.autoDispose(subscribe(this._currentPage, (_use, page): string => {
|
||||
const suffix = getPageTitleSuffix(getGristConfig());
|
||||
switch (page) {
|
||||
case undefined:
|
||||
case 'support': {
|
||||
return document.title = `Support Grist${suffix}`;
|
||||
}
|
||||
}
|
||||
}));
|
||||
public buildSponsorshipSmallButton() {
|
||||
return basicButtonLink('💛 ', t('Sponsor'),
|
||||
{href: commonUrls.githubSponsorGristLabs, target: '_blank'});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -223,44 +165,7 @@ function gristCoreLink() {
|
||||
);
|
||||
}
|
||||
|
||||
const cssPageContainer = styled('div', `
|
||||
overflow: auto;
|
||||
padding: 64px 80px;
|
||||
|
||||
@media ${mediaSmall} {
|
||||
& {
|
||||
padding: 0px;
|
||||
}
|
||||
}
|
||||
`);
|
||||
|
||||
const cssPage = styled('div', `
|
||||
padding: 16px;
|
||||
max-width: 600px;
|
||||
width: 100%;
|
||||
`);
|
||||
|
||||
const cssPageTitle = styled('div', `
|
||||
height: 32px;
|
||||
line-height: 32px;
|
||||
margin-bottom: 24px;
|
||||
color: ${theme.text};
|
||||
font-size: 24px;
|
||||
font-weight: ${vars.headerControlTextWeight};
|
||||
`);
|
||||
|
||||
const cssSectionTitle = styled('div', `
|
||||
height: 24px;
|
||||
line-height: 24px;
|
||||
margin-bottom: 24px;
|
||||
color: ${theme.text};
|
||||
font-size: ${vars.xlargeFontSize};
|
||||
font-weight: ${vars.headerControlTextWeight};
|
||||
`);
|
||||
|
||||
const cssSection = styled('div', `
|
||||
margin-bottom: 60px;
|
||||
`);
|
||||
const cssSection = styled('div', ``);
|
||||
|
||||
const cssParagraph = styled('div', `
|
||||
color: ${theme.text};
|
||||
|
||||
@@ -28,7 +28,6 @@ export function createTopBarHome(appModel: AppModel) {
|
||||
|
||||
return [
|
||||
cssFlexSpace(),
|
||||
appModel.supportGristNudge.showButton(),
|
||||
(appModel.isTeamSite && roles.canEditAccess(appModel.currentOrg?.access || null) ?
|
||||
[
|
||||
basicButton(
|
||||
@@ -41,6 +40,8 @@ export function createTopBarHome(appModel: AppModel) {
|
||||
null
|
||||
),
|
||||
|
||||
appModel.supportGristNudge.buildTopBarButton(),
|
||||
|
||||
buildLanguageMenu(appModel),
|
||||
isAnonymous ? null : buildNotifyMenuButton(appModel.notifier, appModel),
|
||||
dom('div', dom.create(AccountWidget, appModel)),
|
||||
|
||||
@@ -15,13 +15,11 @@ export function buildTutorialCard(owner: IDisposableOwner, options: Options) {
|
||||
if (!isFeatureEnabled('tutorials')) { return null; }
|
||||
|
||||
const {app} = options;
|
||||
const dismissed = app.dismissedPopup('tutorialFirstCard');
|
||||
owner.autoDispose(dismissed);
|
||||
function onClose() {
|
||||
dismissed.set(true);
|
||||
app.dismissPopup('tutorialFirstCard', true);
|
||||
}
|
||||
const visible = Computed.create(owner, (use) =>
|
||||
!use(dismissed)
|
||||
!use(app.dismissedPopups).includes('tutorialFirstCard')
|
||||
&& !use(isNarrowScreenObs())
|
||||
);
|
||||
return dom.maybe(visible, () => {
|
||||
|
||||
Reference in New Issue
Block a user