Summary: Adds a new category of popups that are shown dynamically when certain parts of the UI are first rendered, and a free coaching call popup that's shown to users on their site home page. Test Plan: Browser tests. Reviewers: jarek Reviewed By: jarek Differential Revision: https://phab.getgrist.com/D3706pull/383/head
parent
fa75c93d67
commit
e52e15591d
@ -0,0 +1,114 @@
|
||||
import {showBehavioralPrompt} from 'app/client/components/modals';
|
||||
import {AppModel} from 'app/client/models/AppModel';
|
||||
import {GristBehavioralPrompts} from 'app/client/ui/GristTooltips';
|
||||
import {isNarrowScreen} from 'app/client/ui2018/cssVars';
|
||||
import {BehavioralPrompt} from 'app/common/Prefs';
|
||||
import {Computed, Disposable, dom} from 'grainjs';
|
||||
import {IPopupOptions} from 'popweasel';
|
||||
|
||||
export interface AttachOptions {
|
||||
/** Defaults to false. */
|
||||
hideArrow?: boolean;
|
||||
popupOptions?: IPopupOptions;
|
||||
onDispose?(): void;
|
||||
}
|
||||
|
||||
interface QueuedTip {
|
||||
prompt: BehavioralPrompt;
|
||||
refElement: Element;
|
||||
options: AttachOptions;
|
||||
}
|
||||
|
||||
/**
|
||||
* Manages tips that are shown the first time a user performs some action.
|
||||
*
|
||||
* Tips are shown in the order that they are attached.
|
||||
*/
|
||||
export class BehavioralPrompts extends Disposable {
|
||||
private _prefs = this._appModel.behavioralPrompts;
|
||||
private _dismissedTips: Computed<Set<BehavioralPrompt>> = Computed.create(this, use => {
|
||||
const {dismissedTips} = use(this._prefs);
|
||||
return new Set(dismissedTips.filter(BehavioralPrompt.guard));
|
||||
});
|
||||
private _queuedTips: QueuedTip[] = [];
|
||||
|
||||
constructor(private _appModel: AppModel) {
|
||||
super();
|
||||
}
|
||||
|
||||
public showTip(refElement: Element, prompt: BehavioralPrompt, options: AttachOptions = {}) {
|
||||
this._queueTip(refElement, prompt, options);
|
||||
}
|
||||
|
||||
public attachTip(prompt: BehavioralPrompt, options: AttachOptions = {}) {
|
||||
return (element: Element) => {
|
||||
this._queueTip(element, prompt, options);
|
||||
};
|
||||
}
|
||||
|
||||
public hasSeenTip(prompt: BehavioralPrompt) {
|
||||
return this._dismissedTips.get().has(prompt);
|
||||
}
|
||||
|
||||
private _queueTip(refElement: Element, prompt: BehavioralPrompt, options: AttachOptions) {
|
||||
if (isNarrowScreen() || this._prefs.get().dontShowTips || this.hasSeenTip(prompt)) {
|
||||
return;
|
||||
}
|
||||
|
||||
this._queuedTips.push({prompt, refElement, options});
|
||||
if (this._queuedTips.length > 1) {
|
||||
// If we're already showing a tip, wait for that one to be dismissed, which will
|
||||
// cause the next one in the queue to be shown.
|
||||
return;
|
||||
}
|
||||
|
||||
this._showTip(refElement, prompt, options);
|
||||
}
|
||||
|
||||
private _showTip(refElement: Element, prompt: BehavioralPrompt, options: AttachOptions) {
|
||||
const close = () => {
|
||||
if (!ctl.isDisposed()) {
|
||||
ctl.close();
|
||||
}
|
||||
};
|
||||
|
||||
const {hideArrow = false, onDispose, popupOptions} = options;
|
||||
const {title, content} = GristBehavioralPrompts[prompt];
|
||||
const ctl = showBehavioralPrompt(refElement, title, content(), {
|
||||
onClose: (dontShowTips) => {
|
||||
if (dontShowTips) { this._dontShowTips(); }
|
||||
this._markAsSeen(prompt);
|
||||
},
|
||||
hideArrow,
|
||||
popupOptions,
|
||||
});
|
||||
|
||||
ctl.onDispose(() => {
|
||||
onDispose?.();
|
||||
this._showNextQueuedTip();
|
||||
});
|
||||
dom.onElem(refElement, 'click', () => close());
|
||||
dom.onDisposeElem(refElement, () => close());
|
||||
}
|
||||
|
||||
private _showNextQueuedTip() {
|
||||
this._queuedTips.shift();
|
||||
if (this._queuedTips.length !== 0) {
|
||||
const [nextTip] = this._queuedTips;
|
||||
const {refElement, prompt, options} = nextTip;
|
||||
this._showTip(refElement, prompt, options);
|
||||
}
|
||||
}
|
||||
|
||||
private _markAsSeen(prompt: BehavioralPrompt) {
|
||||
const {dismissedTips} = this._prefs.get();
|
||||
const newDismissedTips = new Set(dismissedTips);
|
||||
newDismissedTips.add(prompt);
|
||||
this._prefs.set({...this._prefs.get(), dismissedTips: [...newDismissedTips]});
|
||||
}
|
||||
|
||||
private _dontShowTips() {
|
||||
this._prefs.set({...this._prefs.get(), dontShowTips: true});
|
||||
this._queuedTips = [];
|
||||
}
|
||||
}
|
@ -0,0 +1,5 @@
|
||||
import {AppModel} from 'app/client/models/AppModel';
|
||||
|
||||
export function showWelcomeCoachingCall(_triggerElement: Element, _app: AppModel): boolean {
|
||||
return false;
|
||||
}
|
@ -0,0 +1,118 @@
|
||||
import {cssButton} from 'app/client/ui2018/buttons';
|
||||
import {mediaSmall, testId, theme, vars} from 'app/client/ui2018/cssVars';
|
||||
import {Disposable, dom, DomElementArg, styled} from 'grainjs';
|
||||
|
||||
interface IPopupController extends Disposable {
|
||||
/** Close the popup. */
|
||||
close(): void;
|
||||
}
|
||||
|
||||
/**
|
||||
* A controller for an open popup.
|
||||
*
|
||||
* Callers are responsible for providing a suitable close callback (`_doClose`).
|
||||
* Typically, this callback should remove the popup from the DOM and run any of
|
||||
* its disposers.
|
||||
*
|
||||
* Used by popup DOM creator functions to close popups on certain interactions,
|
||||
* like clicking a dismiss button from the body of the popup.
|
||||
*/
|
||||
class PopupController extends Disposable implements IPopupController {
|
||||
constructor(
|
||||
private _doClose: () => void,
|
||||
) {
|
||||
super();
|
||||
}
|
||||
|
||||
public close(): void {
|
||||
this._doClose();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A simple card popup that's shown in the bottom-right corner of the screen.
|
||||
*
|
||||
* Disposed whenever the `trigger` element is disposed.
|
||||
*/
|
||||
export function cardPopup(
|
||||
triggerElement: Element,
|
||||
createFn: (ctl: PopupController) => DomElementArg,
|
||||
): void {
|
||||
// Closes this popup, removing it from the DOM.
|
||||
const closePopup = () => {
|
||||
document.body.removeChild(popupDom);
|
||||
// Ensure we run the disposers for the DOM contained in the popup.
|
||||
dom.domDispose(popupDom);
|
||||
};
|
||||
|
||||
const popupDom = cssPopupCard(
|
||||
dom.create((owner) => {
|
||||
// Create a controller for this popup. We'll pass it into `createFn` so that
|
||||
// the body of the popup can close this popup, if needed.
|
||||
const ctl = PopupController.create(owner, closePopup);
|
||||
return dom('div',
|
||||
createFn(ctl),
|
||||
testId('popup-card-content'),
|
||||
);
|
||||
}),
|
||||
testId('popup-card'),
|
||||
);
|
||||
|
||||
// Show the popup by appending it to the DOM.
|
||||
document.body.appendChild(popupDom);
|
||||
|
||||
// If the trigger element is disposed, close this popup.
|
||||
dom.onDisposeElem(triggerElement, closePopup);
|
||||
}
|
||||
|
||||
const cssPopupCard = styled('div', `
|
||||
position: absolute;
|
||||
right: 16px;
|
||||
bottom: 16px;
|
||||
margin-left: 16px;
|
||||
max-width: 428px;
|
||||
padding: 32px;
|
||||
background-color: ${theme.popupBg};
|
||||
box-shadow: 0 2px 18px 0 ${theme.popupInnerShadow}, 0 0 1px 0 ${theme.popupOuterShadow};
|
||||
outline: none;
|
||||
|
||||
@media ${mediaSmall} {
|
||||
& {
|
||||
padding: 24px;
|
||||
}
|
||||
}
|
||||
`);
|
||||
|
||||
export const cssPopupTitle = styled('div', `
|
||||
font-size: ${vars.xxxlargeFontSize};
|
||||
font-weight: ${vars.headerControlTextWeight};
|
||||
color: ${theme.text};
|
||||
margin: 0 0 16px 0;
|
||||
line-height: 32px;
|
||||
overflow-wrap: break-word;
|
||||
`);
|
||||
|
||||
export const cssPopupBody = styled('div', `
|
||||
color: ${theme.text};
|
||||
`);
|
||||
|
||||
export const cssPopupButtons = styled('div', `
|
||||
margin: 24px 0 0 0;
|
||||
|
||||
& > button,
|
||||
& > .${cssButton.className} {
|
||||
margin: 0 8px 0 0;
|
||||
}
|
||||
`);
|
||||
|
||||
export const cssPopupCloseButton = styled('div', `
|
||||
align-self: flex-end;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
padding: 4px;
|
||||
--icon-color: ${theme.popupCloseButtonFg};
|
||||
|
||||
&:hover {
|
||||
background-color: ${theme.hover};
|
||||
}
|
||||
`);
|
@ -0,0 +1 @@
|
||||
export * from 'app/client/ui/WelcomeCoachingCallStub';
|
@ -0,0 +1,137 @@
|
||||
import {assert, driver, Key} from 'mocha-webdriver';
|
||||
import * as gu from 'test/nbrowser/gristUtils';
|
||||
import {setupTestSuite} from 'test/nbrowser/testUtils';
|
||||
|
||||
describe('BehavioralPrompts', function() {
|
||||
this.timeout(20000);
|
||||
const cleanup = setupTestSuite();
|
||||
|
||||
let session: gu.Session;
|
||||
let docId: string;
|
||||
|
||||
before(async () => {
|
||||
session = await gu.session().user('user1').login({showTips: true});
|
||||
docId = await session.tempNewDoc(cleanup, 'BehavioralPrompts');
|
||||
});
|
||||
|
||||
afterEach(() => gu.checkForErrors());
|
||||
|
||||
it('should be shown when the column type select menu is opened', async function() {
|
||||
await assertPromptTitle(null);
|
||||
await gu.toggleSidePanel('right', 'open');
|
||||
await driver.find('.test-right-tab-field').click();
|
||||
await driver.find('.test-fbuilder-type-select').click();
|
||||
await assertPromptTitle('Reference Columns');
|
||||
});
|
||||
|
||||
it('should be temporarily dismissed on click-away', async function() {
|
||||
await gu.getCell({col: 'A', rowNum: 1}).click();
|
||||
await assertPromptTitle(null);
|
||||
});
|
||||
|
||||
it('should be shown again the next time the menu is opened', async function() {
|
||||
await driver.find('.test-fbuilder-type-select').click();
|
||||
await assertPromptTitle('Reference Columns');
|
||||
});
|
||||
|
||||
it('should be permanently dismissed when "Got it" is clicked', async function() {
|
||||
await gu.dismissBehavioralPrompts();
|
||||
await assertPromptTitle(null);
|
||||
|
||||
// Refresh the page and make sure the prompt isn't shown again.
|
||||
await session.loadDoc(`/doc/${docId}`);
|
||||
await driver.find('.test-fbuilder-type-select').click();
|
||||
await assertPromptTitle(null);
|
||||
await gu.sendKeys(Key.ESCAPE);
|
||||
});
|
||||
|
||||
it('should be shown after selecting a reference column type', async function() {
|
||||
await gu.setType(/Reference$/);
|
||||
await assertPromptTitle('Reference Columns');
|
||||
await gu.undo();
|
||||
});
|
||||
|
||||
it('should be shown after selecting a reference list column type', async function() {
|
||||
await gu.setType(/Reference List$/);
|
||||
await assertPromptTitle('Reference Columns');
|
||||
});
|
||||
|
||||
it('should be shown when opening the Raw Data page', async function() {
|
||||
await driver.find('.test-tools-raw').click();
|
||||
await assertPromptTitle('Raw Data page');
|
||||
});
|
||||
|
||||
it('should be shown when opening the Access Rules page', async function() {
|
||||
await driver.find('.test-tools-access-rules').click();
|
||||
await assertPromptTitle('Access Rules');
|
||||
});
|
||||
|
||||
it('should be shown when opening the filter menu', async function() {
|
||||
await gu.openPage('Table1');
|
||||
await gu.openColumnMenu('A', 'Filter');
|
||||
await assertPromptTitle('Filter Buttons');
|
||||
});
|
||||
|
||||
it('should be shown when adding a second pinned filter', async function() {
|
||||
await driver.find('.test-filter-menu-apply-btn').click();
|
||||
await assertPromptTitle(null);
|
||||
await gu.openColumnMenu('B', 'Filter');
|
||||
await driver.find('.test-filter-menu-apply-btn').click();
|
||||
await assertPromptTitle('Nested Filtering');
|
||||
});
|
||||
|
||||
it('should be shown when opening the page widget picker', async function() {
|
||||
await gu.openAddWidgetToPage();
|
||||
await assertPromptTitle('Selecting Data');
|
||||
await gu.dismissBehavioralPrompts();
|
||||
});
|
||||
|
||||
it('should be shown when select by is an available option', async function() {
|
||||
await driver.findContent('.test-wselect-table', /Table1/).click();
|
||||
await assertPromptTitle('Linking Widgets');
|
||||
await gu.dismissBehavioralPrompts();
|
||||
});
|
||||
|
||||
it('should be shown when adding a card widget', async function() {
|
||||
await gu.selectWidget('Card', /Table1/);
|
||||
await assertPromptTitle('Editing Card Layout');
|
||||
});
|
||||
|
||||
it('should not be shown when adding a non-card widget', async function() {
|
||||
await gu.addNewPage('Table', /Table1/);
|
||||
await assertPromptTitle(null);
|
||||
});
|
||||
|
||||
it('should be shown when adding a card list widget', async function() {
|
||||
await gu.addNewPage('Card List', /Table1/);
|
||||
await assertPromptTitle('Editing Card Layout');
|
||||
});
|
||||
|
||||
it(`should stop showing tips if "Don't show tips" is checked`, async function() {
|
||||
// Log in as a new user who hasn't seen any tips yet.
|
||||
session = await gu.session().user('user2').login({showTips: true});
|
||||
docId = await session.tempNewDoc(cleanup, 'BehavioralPromptsDontShowTips');
|
||||
await gu.loadDoc(`/doc/${docId}`);
|
||||
|
||||
// Check "Don't show tips" in the Reference Columns tip and dismiss it.
|
||||
await gu.setType(/Reference$/);
|
||||
await driver.findWait('.test-behavioral-prompt-dont-show-tips', 1000).click();
|
||||
await gu.dismissBehavioralPrompts();
|
||||
|
||||
// Now visit Raw Data and check that its tip isn't shown.
|
||||
await driver.find('.test-tools-raw').click();
|
||||
await assertPromptTitle(null);
|
||||
});
|
||||
});
|
||||
|
||||
async function assertPromptTitle(title: string | null) {
|
||||
if (title === null) {
|
||||
await gu.waitToPass(async () => {
|
||||
assert.equal(await driver.find('.test-behavioral-prompt').isPresent(), false);
|
||||
});
|
||||
} else {
|
||||
await gu.waitToPass(async () => {
|
||||
assert.equal(await driver.find('.test-behavioral-prompt-title').getText(), title);
|
||||
});
|
||||
}
|
||||
}
|
Loading…
Reference in new issue