gristlabs_grist-core/app/client/ui/NotifyUI.ts
Dmitry S 8100272e9a (core) Update HelpScout beacon to work with embedded documentation articles.
Summary:
- Fix base href in HelpScout beacon when showing articles (in particular for Firefox)
- Show the 'Answers' tab normally except when reporting an error.
- Combine the "Give Feedback" and "Help Center" buttons into one that normally
  opens the beacon (with a link to Help Center and to Community Forum), and a
  smaller one that opens the Help Center site in a new tab.
- Update HELP_SCOUT_* env vars to use _V2 suffix, to allow them to coexist with
  code using the previous beacon.

Test Plan: Updated the browser test to check the new behavior.

Reviewers: georgegevoian

Reviewed By: georgegevoian

Differential Revision: https://phab.getgrist.com/D3170
2021-12-09 22:22:55 -05:00

410 lines
12 KiB
TypeScript

import {beaconOpenMessage, IBeaconOpenOptions} from 'app/client/lib/helpScout';
import {AppModel} from 'app/client/models/AppModel';
import {ConnectState} from 'app/client/models/ConnectState';
import {urlState} from 'app/client/models/gristUrlState';
import {Expirable, IAppError, Notification, Notifier, NotifyAction, Progress} from 'app/client/models/NotifyModel';
import {cssHoverCircle, cssTopBarBtn} from 'app/client/ui/TopBarCss';
import {colors, 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} from 'app/common/gristUrls';
import {dom, makeTestId, styled} from 'grainjs';
import {cssMenu, defaultMenuOptions, IOpenController, setPopupToCreateDom} from 'popweasel';
const testId = makeTestId('test-notifier-');
function buildAction(action: NotifyAction, item: Notification, options: IBeaconOpenOptions): HTMLElement|null {
const appModel = options.appModel;
switch (action) {
case 'upgrade':
return dom('a', cssToastAction.cls(''), 'Upgrade Plan', {target: '_blank'},
{href: commonUrls.plans});
case 'renew':
// If already on the billing page, nothing to return.
if (urlState().state.get().billing === 'billing') { return null; }
// If not a billing manager, nothing to return.
if (appModel && appModel.currentOrg && appModel.currentOrg.billingAccount &&
!appModel.currentOrg.billingAccount.isManager) { return null; }
// Otherwise return a link to the billing page.
return dom('a', cssToastAction.cls(''), 'Renew', {target: '_blank'},
{href: urlState().makeUrl({billing: 'billing'})});
case 'personal':
if (!appModel) { return null; }
return cssToastAction('Go to your free personal site', dom.on('click', async () => {
const info = await appModel.api.getSessionAll();
const orgs = info.orgs.filter(org => org.owner && org.owner.id === appModel.currentUser?.id);
if (orgs.length !== 1) {
throw new Error('Cannot find personal site, sorry!');
}
window.location.assign(urlState().makeUrl({org: orgs[0].domain || undefined}));
}));
case 'report-problem':
return cssToastAction('Report a problem', testId('toast-report-problem'),
dom.on('click', () => beaconOpenMessage({...options, includeAppErrors: true})));
case 'ask-for-help': {
const errors: IAppError[] = [{
error: new Error(item.options.message as string),
timestamp: item.options.timestamp,
}];
return cssToastAction('Ask for help',
dom.on('click', () => beaconOpenMessage({...options, includeAppErrors: true, errors})));
}
}
}
function notificationIcon(item: Notification) {
let iconName: IconName|null = null;
switch(item.options.level) {
case "error": iconName = "Warning"; break;
case "warning": iconName = "Warning"; break;
case "success": iconName = "TickSolid"; break;
case "info": iconName = "Info"; break;
}
return iconName ? icon(iconName, dom.cls(cssToastIcon.className)) : null;
}
function buildNotificationDom(item: Notification, options: IBeaconOpenOptions) {
const iconElement = notificationIcon(item);
const hasLeftIcon = Boolean(!item.options.title && iconElement);
return cssToastWrapper(testId('toast-wrapper'),
cssToastWrapper.cls(use => `-${use(item.status)}`),
cssToastWrapper.cls(`-${item.options.level}`),
cssToastWrapper.cls(hasLeftIcon ? '-left-icon' : ''),
item.options.title ? null : iconElement,
cssToastBody(
item.options.title ? cssToastTitle(notificationIcon(item), cssToastTitle(item.options.title)) : null,
cssToastText(testId('toast-message'),
item.options.message,
),
item.options.actions.length ? cssToastActions(
item.options.actions.map((action) => buildAction(action, item, options))
) : null,
item.options.memos.length ? cssToastMemos(
item.options.memos.map(memo => cssToastMemo(memo))
) : null,
),
dom.maybe(item.options.canUserClose, () =>
cssToastClose(testId('toast-close'),
'✕',
dom.on('click', () => item.dispose())
)
)
);
}
function buildProgressDom(item: Progress) {
return cssToastWrapper(testId('progress-wrapper'),
cssToastBody(
cssToastText(testId('progress-message'),
dom.text(item.options.name),
dom.maybe(item.options.size, size => cssProgressBarSize(` (${size})`))
),
cssProgressBarWrapper(
cssProgressBarStatus(
dom.style('width', use => `${use(item.progress)}%`)
)
)
)
);
}
export function buildNotifyMenuButton(notifier: Notifier, appModel: AppModel|null) {
const {connectState} = notifier.getStateForUI();
return cssHoverCircle({style: `margin: 5px;`},
dom.domComputed(connectState, (state) => buildConnectStateButton(state)),
(elem) => {
setPopupToCreateDom(elem, (ctl) => buildNotifyDropdown(ctl, notifier, appModel),
{...defaultMenuOptions, placement: 'bottom-end'});
},
testId('menu-btn'),
);
}
function buildNotifyDropdown(ctl: IOpenController, notifier: Notifier, appModel: AppModel|null): Element {
const {connectState, disconnectMsg, dropdownItems} = notifier.getStateForUI();
return cssDropdownWrapper(
// Reuse css classes for menus (combination of popweasel classes and those from Grist menus)
dom.cls(cssMenu.className),
dom.cls(menuCssClass),
// Close on Escape.
dom.onKeyDown({Escape: () => ctl.close()}),
// Once attached, focus this element, so that it accepts keyboard events.
(elem) => { setTimeout(() => elem.focus(), 0); },
cssDropdownContent(
cssDropdownHeader(
cssDropdownHeaderTitle('Notifications'),
cssDropdownFeedbackLink(
cssDropdownFeedbackIcon('Feedback'),
'Give feedback',
dom.on('click', () => beaconOpenMessage({appModel, onOpen: () => ctl.close(), route: '/ask/message/'})),
testId('feedback'),
)
),
dom.maybe(disconnectMsg, (msg) =>
cssDropdownStatus(
buildConnectStateButton(connectState.get()),
dom('div', cssDropdownStatusText(msg.message), testId('disconnect-msg')),
)
),
dom.maybe((use) => use(dropdownItems).length === 0 && !use(disconnectMsg), () =>
cssDropdownStatus(
dom('div', cssDropdownStatusText('No notifications')),
)
),
dom.forEach(dropdownItems, item =>
buildNotificationDom(item, {appModel, onOpen: () => ctl.close()})),
),
testId('dropdown'),
);
}
export function buildSnackbarDom(notifier: Notifier, appModel: AppModel|null): Element {
const {progressItems, toasts} = notifier.getStateForUI();
return cssSnackbarWrapper(testId('snackbar-wrapper'),
dom.forEach(progressItems, item => buildProgressDom(item)),
dom.forEach(toasts, toast => buildNotificationDom(toast, {appModel})),
);
}
function buildConnectStateButton(state: ConnectState): Element {
switch (state) {
case ConnectState.JustDisconnected: return cssTopBarBtn('Notification', cssTopBarBtn.cls('-slate'));
case ConnectState.RecentlyDisconnected: return cssTopBarBtn('Offline', cssTopBarBtn.cls('-slate'));
case ConnectState.ReallyDisconnected: return cssTopBarBtn('Offline', cssTopBarBtn.cls('-error'));
case ConnectState.Connected:
default:
return cssTopBarBtn('Notification');
}
}
const cssDropdownWrapper = styled('div', `
background-color: white;
border: 1px solid ${colors.darkGrey};
padding: 0px;
`);
const cssDropdownContent = styled('div', `
min-width: 320px;
max-width: 320px;
`);
const cssDropdownHeader = styled('div', `
display: flex;
justify-content: space-between;
align-items: center;
padding: 24px;
background-color: ${colors.lightGrey};
outline: 1px solid ${colors.darkGrey};
`);
const cssDropdownHeaderTitle = styled('span', `
font-weight: bold;
`);
const cssDropdownFeedbackLink = styled('div', `
display: flex;
color: ${colors.lightGreen};
cursor: pointer;
user-select: none;
&:hover {
text-decoration: underline;
}
`);
const cssDropdownFeedbackIcon = styled(icon, `
background-color: ${colors.lightGreen};
margin-right: 4px;
`);
const cssDropdownStatus = styled('div', `
padding: 16px 48px 24px 48px;
text-align: center;
border-top: 1px solid ${colors.darkGrey};
`);
const cssDropdownStatusText = styled('div', `
display: inline-block;
margin: 8px 0 0 0;
text-align: left;
color: ${colors.slate};
`);
// z-index below is set above other assorted children of <body> which include z-index such as 999
// and 1050 (for new-style and old-style modals, for example).
const cssSnackbarWrapper = styled('div', `
position: fixed;
bottom: 8px;
right: 8px;
z-index: 1100;
display: flex;
flex-direction: column;
align-items: flex-end;
font-size: ${vars.mediumFontSize};
pointer-events: none; /* Allow mouse clicks through */
`);
const cssToastBody = styled('div', `
display: flex;
flex-direction: column;
flex-grow: 1;
padding: 0 12px;
overflow-wrap: anywhere;
`);
const cssToastIcon = styled('div', `
flex-shrink: 0;
height: 18px;
width: 18px;
`);
const cssToastActions = styled('div', `
display: flex;
align-items: flex-end;
margin-top: 16px;
color: ${colors.lightGreen};
`);
const cssToastWrapper = styled('div', `
display: flex;
min-width: 240px;
max-width: 320px;
overflow: hidden;
margin: 4px;
padding: 12px;
border-radius: 3px;
color: ${colors.light};
background-color: ${vars.toastBg};
pointer-events: auto;
opacity: 1;
transition: opacity ${Expirable.fadeDelay}ms;
&-error {
border-left: 6px solid ${colors.error};
padding-left: 6px;
--icon-color: ${colors.error};
}
&-success {
border-left: 6px solid ${colors.darkGreen};
padding-left: 6px;
--icon-color: ${colors.darkGreen};
}
&-warning {
border-left: 6px solid ${colors.warningBg};
padding-left: 6px;
--icon-color: ${colors.warning};
}
&-info {
border-left: 6px solid ${colors.lightBlue};
padding-left: 6px;
--icon-color: ${colors.lightBlue};
}
&-info .${cssToastActions.className} {
color: ${colors.lighterBlue};
}
&-left-icon {
padding-left: 12px;
}
&-left-icon > .${cssToastBody.className} {
padding-left: 10px;
}
&-expiring, &-expired {
opacity: 0;
}
.${cssDropdownContent.className} > & > .notification-icon {
display: none;
}
.${cssDropdownContent.className} > & {
background-color: unset;
color: unset;
border-radius: 0px;
border-top: 1px solid ${colors.darkGrey};
margin: 0px;
padding: 16px 20px;
}
`);
const cssToastText = styled('div', `
`);
const cssToastTitle = styled(cssToastText, `
display: flex;
gap: 8px;
font-weight: bold;
margin-bottom: 8px;
`);
const cssToastClose = styled('div', `
cursor: pointer;
user-select: none;
width: 16px;
height: 16px;
line-height: 16px;
text-align: center;
margin: -4px -4px -4px 4px;
`);
const cssToastAction = styled('div', `
cursor: pointer;
user-select: none;
margin-right: 24px;
&, &:hover, &:focus {
color: inherit;
}
&:hover {
text-decoration: underline;
}
`);
const cssToastMemos = styled('div', `
margin-top: 16px;
display: flex;
flex-direction: column;
`);
const cssToastMemo = styled('div', `
margin: 3px;
color: ${colors.dark};
background: ${colors.light};
padding: 3px;
`);
const cssProgressBarWrapper = styled('div', `
margin-top: 18px;
margin-bottom: 11px;
height: 3px;
border-radius: 3px;
background-color: ${colors.light};
`);
const cssProgressBarSize = styled('span', `
color: ${colors.slate};
`);
const cssProgressBarStatus = styled('div', `
height: 3px;
min-width: 3px;
border-radius: 3px;
background-color: ${colors.lightGreen};
`);