(core) Add behavioral and coaching call popups

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/D3706
pull/383/head
George Gevoian 1 year ago
parent fa75c93d67
commit e52e15591d

@ -322,6 +322,9 @@ export class AccessRules extends Disposable {
public buildDom() {
return cssOuter(
dom('div', this._gristDoc.behavioralPrompts.attachTip('accessRules', {
hideArrow: true,
})),
cssAddTableRow(
bigBasicButton({disabled: true}, dom.hide(this._savingEnabled),
dom.text((use) => {

@ -733,8 +733,17 @@ BaseView.prototype.getLastDataRowIndex = function() {
* Creates and opens ColumnFilterMenu for a given field/column, and returns its PopupControl.
*/
BaseView.prototype.createFilterMenu = function(openCtl, filterInfo, options) {
return createFilterMenu(openCtl, this._sectionFilter, filterInfo, this._mainRowSource,
this.tableModel.tableData, options);
const {showAllFiltersButton, onClose} = options;
return createFilterMenu({
openCtl,
sectionFilter: this._sectionFilter,
filterInfo,
rowSource: this._mainRowSource,
tableData: this.tableModel.tableData,
gristDoc: this.gristDoc,
showAllFiltersButton,
onClose,
});
};
/**

@ -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 = [];
}
}

@ -45,7 +45,7 @@ export class DataTables extends Disposable {
cssTableList(
/*************** List section **********/
testId('list'),
docListHeader(t('RawDataTables')),
cssHeader(t('RawDataTables')),
cssList(
dom.forEach(this._tables, tableRec =>
cssItem(
@ -185,6 +185,10 @@ const container = styled('div', `
position: relative;
`);
const cssHeader = styled(docListHeader, `
display: inline-block;
`);
const cssList = styled('div', `
display: flex;
flex-direction: column;

@ -6,6 +6,7 @@
import {AccessRules} from 'app/client/aclui/AccessRules';
import {ActionLog} from 'app/client/components/ActionLog';
import BaseView from 'app/client/components/BaseView';
import {BehavioralPrompts} from 'app/client/components/BehavioralPrompts';
import {isNumericLike, isNumericOnly} from 'app/client/components/ChartView';
import {CodeEditorPanel} from 'app/client/components/CodeEditorPanel';
import * as commands from 'app/client/components/commands';
@ -46,6 +47,7 @@ import {isTourActive} from "app/client/ui/OnBoardingPopups";
import {IPageWidget, toPageWidget} from 'app/client/ui/PageWidgetPicker';
import {linkFromId, selectBy} from 'app/client/ui/selectBy';
import {startWelcomeTour} from 'app/client/ui/welcomeTour';
import {IWidgetType} from 'app/client/ui/widgetTypes';
import {isNarrowScreen, mediaSmall, testId} from 'app/client/ui2018/cssVars';
import {IconName} from 'app/client/ui2018/IconList';
import {invokePrompt} from 'app/client/ui2018/modals';
@ -164,6 +166,8 @@ export class GristDoc extends DisposableWithEvents {
// If the doc has a docTour. Used also to enable the UI button to restart the tour.
public readonly hasDocTour: Computed<boolean>;
public readonly behavioralPrompts = BehavioralPrompts.create(this, this.docPageModel.appModel);
private _actionLog: ActionLog;
private _undoStack: UndoStack;
private _lastOwnActionGroup: ActionGroupWithCursorPos|null = null;
@ -601,6 +605,8 @@ export class GristDoc extends DisposableWithEvents {
// The newly-added section should be given focus.
this.viewModel.activeSectionId(res.sectionRef);
this._maybeShowEditCardLayoutTip(val.type).catch(reportError);
}
/**
@ -641,6 +647,8 @@ export class GristDoc extends DisposableWithEvents {
await this.openDocPage(result.viewRef);
// The newly-added section should be given focus.
this.viewModel.activeSectionId(result.sectionRef);
this._maybeShowEditCardLayoutTip(val.type).catch(reportError);
}
}
@ -1087,6 +1095,32 @@ export class GristDoc extends DisposableWithEvents {
}
}
private async _maybeShowEditCardLayoutTip(selectedWidgetType: IWidgetType) {
if (
// Don't show the tip if a non-card widget was selected.
!['single', 'detail'].includes(selectedWidgetType) ||
// Or if we've already seen it.
this.behavioralPrompts.hasSeenTip('editCardLayout')
) {
return;
}
// Open the right panel to the widget subtab.
commands.allCommands.viewTabOpen.run();
// Wait for the right panel to finish animation if it was collapsed before.
await commands.allCommands.rightPanelOpen.run();
const editLayoutButton = document.querySelector('.behavioral-prompt-edit-card-layout');
if (!editLayoutButton) { throw new Error('GristDoc failed to find edit card layout button'); }
this.behavioralPrompts.showTip(editLayoutButton, 'editCardLayout', {
popupOptions: {
placement: 'left-start',
}
});
}
private async _promptForName() {
return await invokePrompt("Table name", "Create", '', "Default table name");
}

@ -42,6 +42,7 @@ export class RawDataPage extends Disposable {
public buildDom() {
return cssContainer(
dom('div', this._gristDoc.behavioralPrompts.attachTip('rawDataPage', {hideArrow: true})),
dom('div',
dom.create(DataTables, this._gristDoc),
dom.create(DocumentUsage, this._gristDoc.docPageModel),

@ -30,12 +30,10 @@
.g_record_delete_field {
position: absolute;
top: 0;
right: 0;
background-color: #404040;
border: 1px solid #404040;
top: 2px;
right: 2px;
border-radius: 1rem;
color: white;
color: var(--grist-theme-control-secondary-fg, #404040);
cursor: pointer;
display: none;

@ -125,7 +125,7 @@ RecordLayoutEditor.prototype.buildFinishButtons = function() {
RecordLayoutEditor.prototype.buildLeafDom = function() {
return dom('div.layout_grabbable.g_record_layout_editing',
dom('div.g_record_delete_field.glyphicon.glyphicon-remove',
dom('div.g_record_delete_field.glyphicon.glyphicon-eye-close',
dom.on('mousedown', (ev) => ev.stopPropagation()),
dom.on('click', (ev, elem) => {
ev.preventDefault();

@ -177,7 +177,8 @@ ViewConfigTab.prototype._buildLayoutDom = function() {
dom.autoDispose(layoutEditorObs),
dom.on('click', () => commands.allCommands.editLayout.run()),
grainjsDom.hide(layoutEditorObs),
testId('detail-edit-layout')
grainjsDom.cls('behavioral-prompt-edit-card-layout'),
testId('detail-edit-layout'),
)
);
}

@ -15,7 +15,7 @@ import {reportError} from 'app/client/models/errors';
import {filterBar} from 'app/client/ui/FilterBar';
import {viewSectionMenu} from 'app/client/ui/ViewSectionMenu';
import {buildWidgetTitle} from 'app/client/ui/WidgetTitle';
import {isNarrowScreenObs, colors, mediaSmall, testId, theme} from 'app/client/ui2018/cssVars';
import {colors, isNarrowScreenObs, mediaSmall, testId, theme} from 'app/client/ui2018/cssVars';
import {icon} from 'app/client/ui2018/icons';
import {DisposableWithEvents} from 'app/common/DisposableWithEvents';
import {mod} from 'app/common/gutil';
@ -323,7 +323,7 @@ export function buildViewSectionDom(options: {
dom.create(viewSectionMenu, gristDoc, vs)
)
)),
dom.create(filterBar, vs),
dom.create(filterBar, gristDoc, vs),
dom.maybe<BaseView|null>(vs.viewInstance, (viewInstance) => [
dom('div.view_data_pane_container.flexvbox',
cssResizing.cls('', isResizing),

@ -116,6 +116,11 @@ exports.groups = [{
keys: [],
desc: 'Shortcut to open the left panel',
},
{
name: 'rightPanelOpen',
keys: [],
desc: 'Shortcut to open the right panel',
},
{
name: 'videoTourToolsOpen',
keys: [],

@ -2,11 +2,14 @@ import * as commands from 'app/client/components/commands';
import {GristDoc} from 'app/client/components/GristDoc';
import {FocusLayer} from 'app/client/lib/FocusLayer';
import {reportSuccess} from 'app/client/models/errors';
import {basicButton, primaryButton} from 'app/client/ui2018/buttons';
import {basicButton, bigPrimaryButton, primaryButton} from 'app/client/ui2018/buttons';
import {labeledSquareCheckbox} from 'app/client/ui2018/checkbox';
import {testId, theme} from 'app/client/ui2018/cssVars';
import {modalTooltip} from 'app/client/ui2018/modals';
import {dom, DomContents, observable, styled} from 'grainjs';
import {testId, theme, vars} from 'app/client/ui2018/cssVars';
import {icon} from 'app/client/ui2018/icons';
import {cssModalTooltip, modalTooltip} from 'app/client/ui2018/modals';
import {dom, DomContents, keyframes, observable, styled, svg} from 'grainjs';
import {IPopupOptions} from 'popweasel';
import merge = require('lodash/merge');
/**
* This is a file for all custom and pre-configured popups, modals, toasts and tooltips, used
@ -131,6 +134,163 @@ export function reportUndo(
}
}
export interface ShowBehavioralPromptOptions {
onClose: (dontShowTips: boolean) => void;
/** Defaults to false. */
hideArrow?: boolean;
popupOptions?: IPopupOptions;
}
export function showBehavioralPrompt(
refElement: Element,
title: string,
content: DomContents,
options: ShowBehavioralPromptOptions
) {
const {onClose, hideArrow, popupOptions} = options;
const arrow = hideArrow ? null : buildArrow();
const dontShowTips = observable(false);
const tooltip = modalTooltip(refElement,
(ctl) => [
cssBehavioralPromptModal.cls(''),
arrow,
cssBehavioralPromptContainer(
dom.autoDispose(dontShowTips),
testId('behavioral-prompt'),
elem => { FocusLayer.create(ctl, {defaultFocusElem: elem, pauseMousetrap: true}); },
dom.onKeyDown({
Escape: () => ctl.close(),
Enter: () => { onClose(dontShowTips.get()); ctl.close(); },
}),
cssBehavioralPromptHeader(
cssHeaderIconAndText(
icon('Idea'),
cssHeaderText('TIP'),
),
),
cssBehavioralPromptBody(
cssBehavioralPromptTitle(title, testId('behavioral-prompt-title')),
content,
cssButtons(
dom.style('margin-top', '12px'),
dom.style('justify-content', 'space-between'),
dom.style('align-items', 'center'),
dom('div',
cssSkipTipsCheckbox(dontShowTips,
cssSkipTipsCheckboxLabel("Don't show tips"),
testId('behavioral-prompt-dont-show-tips')
),
),
cssDismissPromptButton('Got it', testId('behavioral-prompt-dismiss'),
dom.on('click', () => { onClose(dontShowTips.get()); ctl.close(); })
),
),
),
),
],
merge(popupOptions, {
modifiers: {
...(arrow ? {arrow: {element: arrow}}: {}),
offset: {
offset: '0,12',
},
}
})
);
dom.onDisposeElem(refElement, () => {
if (!tooltip.isDisposed()) {
tooltip.close();
}
});
return tooltip;
}
function buildArrow() {
return cssArrowContainer(
svg('svg',
{style: 'width: 13px; height: 18px;'},
svg('path', {'d': 'M 0 0 h 13 v 18 Z'}),
),
);
}
function sideSelectorChunk(side: 'top'|'bottom'|'left'|'right') {
return `.${cssModalTooltip.className}[x-placement^=${side}]`;
}
function fadeInFromSide(side: 'top'|'bottom'|'left'|'right') {
let startPosition: string;
switch(side) {
case 'top': {
startPosition = '0px -25px';
break;
}
case 'bottom': {
startPosition = '0px 25px';
break;
}
case'left': {
startPosition = '-25px 0px';
break;
}
case 'right': {
startPosition = '25px 0px';
break;
}
}
return keyframes(`
from {translate: ${startPosition}; opacity: 0;}
to {translate: 0px 0px; opacity: 1;}
`);
}
const HEADER_HEIGHT_PX = 30;
const cssArrowContainer = styled('div', `
position: absolute;
& path {
stroke: ${theme.popupBg};
stroke-width: 2px;
fill: ${theme.popupBg};
}
${sideSelectorChunk('bottom')} > & path {
stroke: ${theme.controlPrimaryBg};
fill: ${theme.controlPrimaryBg};
}
${sideSelectorChunk('top')} > & {
bottom: -17px;
}
${sideSelectorChunk('bottom')} > & {
top: -14px;
}
${sideSelectorChunk('right')} > & {
left: -12px;
margin: ${HEADER_HEIGHT_PX}px 0px ${HEADER_HEIGHT_PX}px 0px;
}
${sideSelectorChunk('left')} > & {
right: -12px;
margin: ${HEADER_HEIGHT_PX}px 0px ${HEADER_HEIGHT_PX}px 0px;
}
${sideSelectorChunk('top')} svg {
transform: rotate(-90deg);
}
${sideSelectorChunk('bottom')} svg {
transform: rotate(90deg);
}
${sideSelectorChunk('left')} svg {
transform: scalex(-1);
}
`);
const cssTheme = styled('div', `
color: ${theme.text};
`);
@ -147,3 +307,86 @@ const cssContainer = styled(cssTheme, `
const cssWideContainer = styled(cssTheme, `
max-width: 340px;
`);
const cssFadeInFromTop = fadeInFromSide('top');
const cssFadeInFromBottom = fadeInFromSide('bottom');
const cssFadeInFromLeft = fadeInFromSide('left');
const cssFadeInFromRight = fadeInFromSide('right');
const cssBehavioralPromptModal = styled('div', `
margin: 0px;
padding: 0px;
width: 400px;
border-radius: 4px;
animation-duration: 0.4s;
position: relative;
&[x-placement^=top] {
animation-name: ${cssFadeInFromTop};
}
&[x-placement^=bottom] {
animation-name: ${cssFadeInFromBottom};
}
&[x-placement^=left] {
animation-name: ${cssFadeInFromLeft};
}
&[x-placement^=right] {
animation-name: ${cssFadeInFromRight};
}
`);
const cssBehavioralPromptContainer = styled(cssTheme, `
line-height: 18px;
`);
const cssBehavioralPromptHeader = styled('div', `
display: flex;
justify-content: center;
background-color: ${theme.controlPrimaryBg};
color: ${theme.controlPrimaryFg};
--icon-color: ${theme.controlPrimaryFg};
border-radius: 4px 4px 0px 0px;
line-height: ${HEADER_HEIGHT_PX}px;
`);
const cssBehavioralPromptBody = styled('div', `
padding: 16px;
`);
const cssHeaderIconAndText = styled('div', `
display: flex;
align-items: center;
column-gap: 8px;
`);
const cssHeaderText = styled('div', `
font-weight: 600;
`);
const cssDismissPromptButton = styled(bigPrimaryButton, `
margin-right: 8px;
`);
const cssBehavioralPromptTitle = styled('div', `
font-size: ${vars.xxxlargeFontSize};
font-weight: ${vars.headerControlTextWeight};
color: ${theme.text};
margin: 0 0 16px 0;
line-height: 32px;
`);
const cssSkipTipsCheckbox = styled(labeledSquareCheckbox, `
line-height: normal;
`);
const cssSkipTipsCheckboxLabel = styled('span', `
color: ${theme.lightText};
`);

@ -12,7 +12,8 @@ import {Features, isLegacyPlan, Product} from 'app/common/Features';
import {GristLoadConfig} from 'app/common/gristUrls';
import {FullUser} from 'app/common/LoginSessionAPI';
import {LocalPlugin} from 'app/common/plugin';
import {DeprecationWarning, DismissedPopup, UserPrefs} from 'app/common/Prefs';
import {BehavioralPromptPrefs, DeprecationWarning, DismissedPopup, DismissedReminder,
UserPrefs} from 'app/common/Prefs';
import {isOwner} from 'app/common/roles';
import {getTagManagerScript} from 'app/common/tagManager';
import {getDefaultThemePrefs, Theme, ThemeAppearance, ThemeColors, ThemePrefs,
@ -23,7 +24,7 @@ import {getOrgName, Organization, OrgError, UserAPI, UserAPIImpl} from 'app/comm
import {getUserPrefObs, getUserPrefsObs} from 'app/client/models/UserPrefs';
import {bundleChanges, Computed, Disposable, Observable, subscribe} from 'grainjs';
const t = makeT('models.AppModel')
const t = makeT('models.AppModel');
// Reexported for convenience.
export {reportError} from 'app/client/models/errors';
@ -97,6 +98,8 @@ export interface AppModel {
* Deprecation messages that user has seen.
*/
deprecatedWarnings: Observable<DeprecationWarning[]>;
dismissedWelcomePopups: Observable<DismissedReminder[]>;
behavioralPrompts: Observable<BehavioralPromptPrefs>;
pageType: Observable<PageType>;
@ -108,6 +111,7 @@ export interface AppModel {
showNewSiteModal(): void;
isBillingManager(): boolean; // If user is a billing manager for this org
isSupport(): boolean; // If user is a Support user
isOwner(): boolean; // If user is an owner of this org
}
export class TopAppModelImpl extends Disposable implements TopAppModel {
@ -236,11 +240,14 @@ export class AppModelImpl extends Disposable implements AppModel {
}) as Observable<ThemePrefs>;
public readonly currentTheme = this._getCurrentThemeObs();
public readonly dismissedPopups =
getUserPrefObs(this.userPrefsObs, 'dismissedPopups', { defaultValue: [] }) as Observable<DismissedPopup[]>;
public readonly dismissedPopups = getUserPrefObs(this.userPrefsObs, 'dismissedPopups',
{ defaultValue: [] }) as Observable<DismissedPopup[]>;
public readonly deprecatedWarnings = getUserPrefObs(this.userPrefsObs, 'seenDeprecatedWarnings',
{ defaultValue: []}) as Observable<DeprecationWarning[]>;
{ defaultValue: [] }) as Observable<DeprecationWarning[]>;
public readonly dismissedWelcomePopups = getUserPrefObs(this.userPrefsObs, 'dismissedWelcomePopups',
{ defaultValue: [] }) as Observable<DismissedReminder[]>;
public readonly behavioralPrompts = getUserPrefObs(this.userPrefsObs, 'behavioralPrompts',
{ defaultValue: { dontShowTips: false, dismissedTips: [] } }) as Observable<BehavioralPromptPrefs>;
// Get the current PageType from the URL.
public readonly pageType: Observable<PageType> = Computed.create(this, urlState().state,
@ -303,18 +310,21 @@ export class AppModelImpl extends Disposable implements AppModel {
return Boolean(this.currentOrg?.billingAccount?.isManager);
}
public isOwner() {
return Boolean(this.currentOrg && isOwner(this.currentOrg));
}
/**
* Fetch and update the current org's usage.
*/
public async refreshOrgUsage() {
const currentOrg = this.currentOrg;
if (!isOwner(currentOrg)) {
if (!this.isOwner()) {
// Note: getOrgUsageSummary already checks for owner access; we do an early return
// here to skip making unnecessary API calls.
return;
}
const usage = await this.api.getOrgUsageSummary(currentOrg.id);
const usage = await this.api.getOrgUsageSummary(this.currentOrg!.id);
if (!this.isDisposed()) {
this.currentOrgUsage.set(usage);
}

@ -1,3 +1,4 @@
import { GristDoc } from "app/client/components/GristDoc";
import { ColumnFilter } from "app/client/models/ColumnFilter";
import { FilterInfo } from "app/client/models/entities/ViewSectionRec";
import { CellValue } from "app/plugin/GristData";
@ -29,6 +30,7 @@ interface ColumnFilterMenuModelParams {
columnFilter: ColumnFilter;
filterInfo: FilterInfo;
valueCount: Array<[CellValue, IFilterCount]>;
gristDoc: GristDoc;
limitShow?: number;
}
@ -37,6 +39,8 @@ export class ColumnFilterMenuModel extends Disposable {
public readonly filterInfo = this._params.filterInfo;
public readonly gristDoc = this._params.gristDoc;
public readonly initialPinned = this.filterInfo.isPinned.peek();
public readonly limitShown = this._params.limitShow ?? MAXIMUM_SHOWN_FILTER_ITEMS;

@ -336,13 +336,13 @@ function addMenu(importSources: ImportSource[], gristDoc: GristDoc, isReadonly:
const selectBy = gristDoc.selectBy.bind(gristDoc);
return [
menuItem(
(elem) => openPageWidgetPicker(elem, gristDoc.docModel, (val) => gristDoc.addNewPage(val).catch(reportError),
(elem) => openPageWidgetPicker(elem, gristDoc, (val) => gristDoc.addNewPage(val).catch(reportError),
{isNewPage: true, buttonLabel: 'Add Page'}),
menuIcon("Page"), t("AddPage"), testId('dp-add-new-page'),
dom.cls('disabled', isReadonly)
),
menuItem(
(elem) => openPageWidgetPicker(elem, gristDoc.docModel, (val) => gristDoc.addWidgetToPage(val).catch(reportError),
(elem) => openPageWidgetPicker(elem, gristDoc, (val) => gristDoc.addWidgetToPage(val).catch(reportError),
{isNewPage: false, selectBy}),
menuIcon("Widget"), t("AddWidgetToPage"), testId('dp-add-widget-to-page'),
// disable for readonly doc and all special views

@ -94,6 +94,10 @@ export interface ViewSectionRec extends IRowModel<"_grist_Views_section">, RuleO
// changes to their filters. (True indicates unsaved changes)
filterSpecChanged: Computed<boolean>;
// Set to true when a second pinned filter is added, to trigger a behavioral prompt. Note that
// the popup is only shown once, even if this observable is set to true again in the future.
showNestedFilteringPopup: Observable<boolean>;
// Customizable version of the JSON-stringified sort spec. It may diverge from the saved one.
activeSortJson: modelUtil.CustomComputed<string>;
@ -432,6 +436,8 @@ export function createViewSectionRec(this: ViewSectionRec, docModel: DocModel):
return use(this.filters).some(col => !use(col.filter.isSaved) || !use(col.pinned.isSaved));
});
this.showNestedFilteringPopup = Observable.create(this, false);
// Save all filters of fields/columns in the section.
this.saveFilters = () => {
return docModel.docData.bundleActions(`Save all filters in ${this.titleDef()}`,

@ -4,6 +4,7 @@
* but on Cancel the model is reset to its initial state prior to menu closing.
*/
import * as commands from 'app/client/components/commands';
import {GristDoc} from 'app/client/components/GristDoc';
import {makeT} from 'app/client/lib/localization';
import {ColumnFilter, NEW_FILTER_JSON} from 'app/client/models/ColumnFilter';
import {ColumnFilterMenuModel, IFilterCount} from 'app/client/models/ColumnFilterMenuModel';
@ -72,7 +73,7 @@ export type IColumnFilterViewType = 'listView'|'calendarView';
*/
export function columnFilterMenu(owner: IDisposableOwner, opts: IFilterMenuOptions): HTMLElement {
const { model, doCancel, doSave, onClose, renderValue, valueParser, showAllFiltersButton } = opts;
const { columnFilter, filterInfo } = model;
const { columnFilter, filterInfo, gristDoc } = model;
const valueFormatter = opts.valueFormatter || ((val) => val?.toString() || '');
// Map to keep track of displayed checkboxes
@ -351,6 +352,11 @@ export function columnFilterMenu(owner: IDisposableOwner, opts: IFilterMenuOptio
icon('PinTilted'),
cssPinButton.cls('-pinned', model.filterInfo.isPinned),
dom.on('click', () => filterInfo.pinned(!filterInfo.pinned())),
gristDoc.behavioralPrompts.attachTip('filterButtons', {
popupOptions: {
attach: null,
}
}),
testId('pin-btn'),
),
),
@ -604,27 +610,38 @@ function getEmptyCountMap(fieldOrColumn: ViewFieldRec|ColumnRec): Map<CellValue,
}
export interface IColumnFilterMenuOptions {
// Callback for when the filter menu is closed.
onClose?: () => void;
// If true, shows a button that opens the sort & filter widget menu.
/** If true, shows a button that opens the sort & filter widget menu. */
showAllFiltersButton?: boolean;
/** Callback for when the filter menu is closed. */
onClose?: () => void;
}
export interface ICreateFilterMenuParams extends IColumnFilterMenuOptions {
openCtl: IOpenController;
sectionFilter: SectionFilter;
filterInfo: FilterInfo;
rowSource: RowSource;
tableData: TableData;
gristDoc: GristDoc;
}
/**
* Returns content for the newly created columnFilterMenu; for use with setPopupToCreateDom().
*/
export function createFilterMenu(
openCtl: IOpenController,
sectionFilter: SectionFilter,
filterInfo: FilterInfo,
rowSource: RowSource,
tableData: TableData,
options: IColumnFilterMenuOptions = {}
) {
const {onClose = noop, showAllFiltersButton} = options;
export function createFilterMenu(params: ICreateFilterMenuParams) {
const {
openCtl,
sectionFilter,
filterInfo,
rowSource,
tableData,
gristDoc,
showAllFiltersButton,
onClose = noop
} = params;
// Go through all of our shown and hidden rows, and count them up by the values in this column.
const {fieldOrColumn, filter} = filterInfo;
const {fieldOrColumn, filter, isPinned} = filterInfo;
const columnType = fieldOrColumn.origCol.peek().type.peek();
const visibleColumnType = fieldOrColumn.visibleColModel.peek()?.type.peek() || columnType;
const {keyMapFunc, labelMapFunc, valueMapFunc} = getMapFuncs(columnType, tableData, fieldOrColumn);
@ -668,6 +685,7 @@ export function createFilterMenu(
columnFilter,
filterInfo,
valueCount: valueCountsArr,
gristDoc,
});
return columnFilterMenu(openCtl, {
@ -676,21 +694,31 @@ export function createFilterMenu(
onClose: () => { openCtl.close(); onClose(); },
doSave: (reset: boolean = false) => {
const spec = columnFilter.makeFilterJson();
sectionFilter.viewSection.setFilter(
const {viewSection} = sectionFilter;
viewSection.setFilter(
fieldOrColumn.origCol().origColRef(),
{filter: spec}
);
if (reset) {
sectionFilter.resetTemporaryRows();
}
// Check if the save was for a new filter, and if that new filter was pinned. If it was, and
// it is the second pinned filter in the section, trigger a tip that explains how multiple
// filters in the same section work.
const isNewPinnedFilter = columnFilter.initialFilterJson === NEW_FILTER_JSON && isPinned();
if (isNewPinnedFilter && viewSection.pinnedActiveFilters.get().length === 2) {
viewSection.showNestedFilteringPopup.set(true);
}
},
doCancel: () => {
const {viewSection} = sectionFilter;
if (columnFilter.initialFilterJson === NEW_FILTER_JSON) {
sectionFilter.viewSection.revertFilter(fieldOrColumn.origCol().origColRef());
viewSection.revertFilter(fieldOrColumn.origCol().origColRef());
} else {
const initialFilter = columnFilter.initialFilterJson;
columnFilter.setState(initialFilter);
sectionFilter.viewSection.setFilter(
viewSection.setFilter(
fieldOrColumn.origCol().origColRef(),
{filter: initialFilter, pinned: model.initialPinned}
);

@ -4,7 +4,7 @@
* Orgs, workspaces and docs are fetched asynchronously on build via the passed in API.
*/
import {loadUserManager} from 'app/client/lib/imports';
import {reportError} from 'app/client/models/AppModel';
import {AppModel, reportError} from 'app/client/models/AppModel';
import {docUrl, urlState} from 'app/client/models/gristUrlState';
import {getTimeFromNow, HomeModel, makeLocalViewSettings, ViewSettings} from 'app/client/models/HomeModel';
import {getWorkspaceInfo, workspaceName} from 'app/client/models/WorkspaceInfo';
@ -14,6 +14,7 @@ import {buildUpgradeButton} from 'app/client/ui/ProductUpgrades';
import {buildPinnedDoc, createPinnedDocs} from 'app/client/ui/PinnedDocs';
import {shadowScroll} from 'app/client/ui/shadowScroll';
import {transition} from 'app/client/ui/transitions';
import {showWelcomeCoachingCall} from 'app/client/ui/WelcomeCoachingCall';
import {showWelcomeQuestions} from 'app/client/ui/WelcomeQuestions';
import {createVideoTourTextButton} from 'app/client/ui/OpenVideoTour';
import {buttonSelect, cssButtonSelect} from 'app/client/ui2018/buttonSelect';
@ -26,7 +27,7 @@ import {IHomePage} from 'app/common/gristUrls';
import {SortPref, ViewPref} from 'app/common/Prefs';
import * as roles from 'app/common/roles';
import {Document, Workspace} from 'app/common/UserAPI';
import {computed, Computed, dom, DomArg, DomContents, IDisposableOwner,
import {computed, Computed, dom, DomArg, DomContents, DomElementArg, IDisposableOwner,
makeTestId, observable, Observable} from 'grainjs';
import {buildTemplateDocs} from 'app/client/ui/TemplateDocs';
import {makeT} from 'app/client/lib/localization';
@ -44,20 +45,30 @@ const testId = makeTestId('test-dm-');
* Usage:
* dom('div', createDocMenu(homeModel))
*/
export function createDocMenu(home: HomeModel) {
return dom.domComputed(home.loading, loading => (
loading === 'slow' ? css.spinner(loadingSpinner()) :
loading ? null :
dom.create(createLoadedDocMenu, home)
));
export function createDocMenu(home: HomeModel): DomElementArg[] {
return [
attachWelcomePopups(home.app),
dom.domComputed(home.loading, loading => (
loading === 'slow' ? css.spinner(loadingSpinner()) :
loading ? null :
dom.create(createLoadedDocMenu, home)
))
];
}
function attachWelcomePopups(app: AppModel): (el: Element) => void {
return (element: Element) => {
const isShowingPopup = showWelcomeQuestions(app.userPrefsObs);
if (isShowingPopup) { return; }
showWelcomeCoachingCall(element, app);
};
}
function createLoadedDocMenu(owner: IDisposableOwner, home: HomeModel) {
const flashDocId = observable<string|null>(null);
const upgradeButton = buildUpgradeButton(owner, home.app);
return css.docList(
showWelcomeQuestions(home.app.userPrefsObs),
css.docMenu(
dom.maybe(!home.app.currentFeatures.workspaces, () => [
css.docListHeader(t('ServiceNotAvailable')),

@ -1,3 +1,4 @@
import { GristDoc } from "app/client/components/GristDoc";
import { NEW_FILTER_JSON } from "app/client/models/ColumnFilter";
import { ColumnRec, ViewSectionRec } from "app/client/models/DocModel";
import { FilterInfo } from "app/client/models/entities/ViewSectionRec";
@ -9,11 +10,22 @@ import { menu, menuItemAsync } from "app/client/ui2018/menus";
import { dom, IDisposableOwner, IDomArgs, styled } from "grainjs";
import { IMenuOptions, PopupControl } from "popweasel";
export function filterBar(_owner: IDisposableOwner, viewSection: ViewSectionRec) {
export function filterBar(
_owner: IDisposableOwner,
gristDoc: GristDoc,
viewSection: ViewSectionRec
) {
const popupControls = new WeakMap<ColumnRec, PopupControl>();
return cssFilterBar(
testId('filter-bar'),
dom.forEach(viewSection.activeFilters, (filterInfo) => makeFilterField(filterInfo, popupControls)),
dom.maybe(viewSection.showNestedFilteringPopup, () => {
return dom('div',
gristDoc.behavioralPrompts.attachTip('nestedFiltering', {
onDispose: () => viewSection.showNestedFilteringPopup.set(false),
}),
);
}),
makePlusButton(viewSection, popupControls),
cssFilterBar.cls('-hidden', use => use(viewSection.pinnedActiveFilters).length === 0),
);

@ -1,6 +1,8 @@
import {cssLink} from 'app/client/ui2018/links';
import {commonUrls} from 'app/common/gristUrls';
import {BehavioralPrompt} from 'app/common/Prefs';
import {dom, DomContents, DomElementArg, styled} from 'grainjs';
import { icon } from '../ui2018/icons';
const cssTooltipContent = styled('div', `
display: flex;
@ -8,7 +10,20 @@ const cssTooltipContent = styled('div', `
row-gap: 8px;
`);
type TooltipName =
const cssBoldText = styled('span', `
font-weight: 600;
`);
const cssItalicizedText = styled('span', `
font-style: italic;
`);
const cssIcon = styled(icon, `
height: 18px;
width: 18px;
`);
export type Tooltip =
| 'dataSize'
| 'setTriggerFormula'
| 'selectBy'
@ -19,7 +34,8 @@ type TooltipName =
export type TooltipContentFunc = (...domArgs: DomElementArg[]) => DomContents;
export const GristTooltips: Record<TooltipName, TooltipContentFunc> = {
// TODO: i18n
export const GristTooltips: Record<Tooltip, TooltipContentFunc> = {
dataSize: (...args: DomElementArg[]) => cssTooltipContent(
dom('div', 'The total size of all data in this document, excluding attachments.'),
dom('div', 'Updates every 5 minutes.'),
@ -80,3 +96,96 @@ export const GristTooltips: Record<TooltipName, TooltipContentFunc> = {
...args,
),
};
export interface BehavioralPromptContent {
title: string;
content: (...domArgs: DomElementArg[]) => DomContents;
}
// TODO: i18n
export const GristBehavioralPrompts: Record<BehavioralPrompt, BehavioralPromptContent> = {
referenceColumns: {
title: 'Reference Columns',
content: (...args: DomElementArg[]) => cssTooltipContent(
dom('div', 'Reference columns are the key to ', cssBoldText('relational'), ' data in Grist.'),
dom('div', 'They allow for one record to point (or refer) to another.'),
dom('div',
cssLink({href: commonUrls.helpColRefs, target: '_blank'}, 'Learn more.'),
),
...args,
),
},
referenceColumnsConfig: {
title: 'Reference Columns',
content: (...args: DomElementArg[]) => cssTooltipContent(
dom('div', 'Select the table to link to.'),
dom('div', 'Cells in a reference column always identify an ', cssItalicizedText('entire'),
' record in that table, but you may select which column from that record to show.'),
dom('div',
cssLink({href: commonUrls.helpUnderstandingReferenceColumns, target: '_blank'}, 'Learn more.'),
),
...args,
),
},
rawDataPage: {
title: 'Raw Data page',
content: (...args: DomElementArg[]) => cssTooltipContent(
dom('div', 'The Raw Data page lists all data tables in your document, '
+ 'including summary tables and tables not included in page layouts.'),
dom('div', cssLink({href: commonUrls.helpRawData, target: '_blank'}, 'Learn more.')),
...args,
),
},
accessRules: {
title: 'Access Rules',
content: (...args: DomElementArg[]) => cssTooltipContent(
dom('div', 'Access rules give you the power to create nuanced rules '
+ 'to determine who can see or edit which parts of your document.'),
dom('div', cssLink({href: commonUrls.helpAccessRules, target: '_blank'}, 'Learn more.')),
...args,
),
},
filterButtons: {
title: 'Filter Buttons',
content: (...args: DomElementArg[]) => cssTooltipContent(
dom('div', 'Pinned filters are displayed as buttons above the widget.'),
dom('div', 'Unpin to hide the the button while keeping the filter.'),
dom('div', cssLink({href: commonUrls.helpFilterButtons, target: '_blank'}, 'Learn more.')),
...args,
),
},
nestedFiltering: {
title: 'Nested Filtering',
content: (...args: DomElementArg[]) => cssTooltipContent(
dom('div', 'You can filter by more than one column.'),
dom('div', 'Only those rows will appear which match all of the filters.'),
...args,
),
},
pageWidgetPicker: {
title: 'Selecting Data',
content: (...args: DomElementArg[]) => cssTooltipContent(
dom('div', 'Select the table containing the data to show.'),
dom('div', 'Use the 𝚺 icon to create summary (or pivot) tables, for totals or subtotals.'),
...args,
),
},
pageWidgetPickerSelectBy: {
title: 'Linking Widgets',
content: (...args: DomElementArg[]) => cssTooltipContent(
dom('div', 'Link your new widget to an existing widget on this page.'),
dom('div', `This is the secret to Grist's dynamic and productive layouts.`),
dom('div', cssLink({href: commonUrls.helpLinkingWidgets, target: '_blank'}, 'Learn more.')),
...args,
),
},
editCardLayout: {
title: 'Editing Card Layout',
content: (...args: DomElementArg[]) => cssTooltipContent(
dom('div', 'Rearrange the fields in your card by dragging and resizing cells.'),
dom('div', 'Clicking ', cssIcon('EyeHide'),
' in each cell hides the field from this view without deleting it.'),
...args,
),
},
};

@ -3,7 +3,7 @@ import {makeT} from 'app/client/lib/localization';
import {cssLinkText, cssPageEntryMain, cssPageIcon, cssPageLink} from 'app/client/ui/LeftPanelCommon';
import {theme} from 'app/client/ui2018/cssVars';
import {icon} from 'app/client/ui2018/icons';
import {modal} from 'app/client/ui2018/modals';
import {cssModalCloseButton, modal} from 'app/client/ui2018/modals';
import {commonUrls, shouldHideUiElement} from 'app/common/gristUrls';
import {dom, makeTestId, styled} from 'grainjs';
@ -19,7 +19,7 @@ const testId = makeTestId('test-video-tour-');
(ctl) => {
return [
cssModal.cls(''),
cssCloseButton(
cssModalCloseButton(
cssCloseIcon('CrossBig'),
dom.on('click', () => ctl.close()),
testId('close'),
@ -127,19 +127,6 @@ const cssVideoIcon = styled(icon, `
}
`);
const cssCloseButton = styled('div', `
align-self: flex-end;
margin: -8px;
padding: 4px;
border-radius: 4px;
cursor: pointer;
--icon-color: ${theme.modalCloseButtonFg};
&:hover {
background-color: ${theme.hover};
}
`);
const cssCloseIcon = styled(icon, `
padding: 12px;
`);

@ -9,7 +9,7 @@ import {transition, TransitionWatcher} from 'app/client/ui/transitions';
import {cssHideForNarrowScreen, isScreenResizing, mediaNotSmall, mediaSmall, theme} from 'app/client/ui2018/cssVars';
import {isNarrowScreenObs} from 'app/client/ui2018/cssVars';
import {icon} from 'app/client/ui2018/icons';
import {dom, DomArg, MultiHolder, noTestId, Observable, styled, subscribe, TestId} from "grainjs";
import {dom, DomElementArg, MultiHolder, noTestId, Observable, styled, subscribe, TestId} from "grainjs";
import noop from 'lodash/noop';
import once from 'lodash/once';
import {SessionObs} from 'app/client/lib/sessionObs';
@ -26,21 +26,21 @@ export interface PageSidePanel {
panelWidth: Observable<number>;
panelOpen: Observable<boolean>;
hideOpener?: boolean; // If true, don't show the opener handle.
header: DomArg;
content: DomArg;
header: DomElementArg;
content: DomElementArg;
}
export interface PageContents {
leftPanel: PageSidePanel;
rightPanel?: PageSidePanel; // If omitted, the right panel isn't shown at all.
headerMain: DomArg;
contentMain: DomArg;
headerMain: DomElementArg;
contentMain: DomElementArg;
onResize?: () => void; // Callback for when either pane is opened, closed, or resized.
testId?: TestId;
contentTop?: DomArg;
contentBottom?: DomArg;
contentTop?: DomElementArg;
contentBottom?: DomElementArg;
}
export function pagePanels(page: PageContents) {
@ -55,6 +55,7 @@ export function pagePanels(page: PageContents) {
let lastLeftOpen = left.panelOpen.get();
let lastRightOpen = right?.panelOpen.get() || false;
let leftPaneDom: HTMLElement;
let rightPaneDom: HTMLElement;
let onLeftTransitionFinish = noop;
// When switching to mobile mode, close panels; when switching to desktop, restore the
@ -89,6 +90,16 @@ export function pagePanels(page: PageContents) {
watcher.onDispose(() => resolve(undefined));
left.panelOpen.set(true);
}),
rightPanelOpen: () => new Promise((resolve, reject) => {
if (!right) {
reject(new Error('PagePanels rightPanelOpen called while right panel is undefined'));
return;
}
const watcher = new TransitionWatcher(rightPaneDom);
watcher.onDispose(() => resolve(undefined));
right.panelOpen.set(true);
}),
}, null, true);
let contentWrapper: HTMLElement;
return cssPageContainer(
@ -262,7 +273,7 @@ export function pagePanels(page: PageContents) {
dom.show(right.panelOpen),
cssHideForNarrowScreen.cls('')),
cssRightPane(
rightPaneDom = cssRightPane(
testId('right-panel'),
cssRightPaneHeader(right.header),
right.content,

@ -1,6 +1,8 @@
import {makeT} from 'app/client/lib/localization';
import { BehavioralPrompts } from 'app/client/components/BehavioralPrompts';
import { GristDoc } from 'app/client/components/GristDoc';
import { makeT } from 'app/client/lib/localization';
import { reportError } from 'app/client/models/AppModel';
import { ColumnRec, DocModel, TableRec, ViewSectionRec } from 'app/client/models/DocModel';
import { ColumnRec, TableRec, ViewSectionRec } from 'app/client/models/DocModel';
import { GristTooltips } from 'app/client/ui/GristTooltips';
import { linkId, NoLink } from 'app/client/ui/selectBy';
import { withInfoTooltip } from 'app/client/ui/tooltips';
@ -10,7 +12,7 @@ import { theme, vars } from "app/client/ui2018/cssVars";
import { icon } from "app/client/ui2018/icons";
import { spinnerModal } from 'app/client/ui2018/modals';
import { isLongerThan, nativeCompare } from "app/common/gutil";
import { computed, Computed, Disposable, dom, domComputed, fromKo, IOption, select} from "grainjs";
import { computed, Computed, Disposable, dom, domComputed, DomElementArg, fromKo, IOption, select} from "grainjs";
import { makeTestId, Observable, onKeyDown, styled} from "grainjs";
import without = require('lodash/without');
import Popper from 'popper.js';
@ -99,7 +101,7 @@ export type ISaveFunc = (val: IPageWidget) => Promise<any>;
const DELAY_BEFORE_SPINNER_MS = 500;
// Attaches the page widget picker to elem to open on 'click' on the left.
export function attachPageWidgetPicker(elem: HTMLElement, docModel: DocModel, onSave: ISaveFunc,
export function attachPageWidgetPicker(elem: HTMLElement, gristDoc: GristDoc, onSave: ISaveFunc,
options: IOptions = {}) {
// Overrides .placement, this is needed to enable the page widget to update position when user
// expand the `Group By` panel.
@ -108,7 +110,7 @@ export function attachPageWidgetPicker(elem: HTMLElement, docModel: DocModel, on
// particular listening to value.summarize to update popup position could be done directly in
// code).
options.placement = 'left';
const domCreator = (ctl: IOpenController) => buildPageWidgetPicker(ctl, docModel, onSave, options);
const domCreator = (ctl: IOpenController) => buildPageWidgetPicker(ctl, gristDoc, onSave, options);
setPopupToCreateDom(elem, domCreator, {
placement: 'left',
trigger: ['click'],
@ -118,10 +120,10 @@ export function attachPageWidgetPicker(elem: HTMLElement, docModel: DocModel, on
}
// Open page widget widget picker on the right of element.
export function openPageWidgetPicker(elem: HTMLElement, docModel: DocModel, onSave: ISaveFunc,
export function openPageWidgetPicker(elem: HTMLElement, gristDoc: GristDoc, onSave: ISaveFunc,
options: IOptions = {}) {
popupOpen(elem, (ctl) => buildPageWidgetPicker(
ctl, docModel, onSave, options
ctl, gristDoc, onSave, options
), { placement: 'right' });
}
@ -131,11 +133,12 @@ export function openPageWidgetPicker(elem: HTMLElement, docModel: DocModel, onSa
// to overlay the trigger element (which could happen when the 'Group By' panel is expanded for the
// first time). When saving is taking time, show a modal spinner (see DELAY_BEFORE_SPINNER_MS).
export function buildPageWidgetPicker(
ctl: IOpenController,
docModel: DocModel,
onSave: ISaveFunc,
options: IOptions = {}) {
ctl: IOpenController,
gristDoc: GristDoc,
onSave: ISaveFunc,
options: IOptions = {}
) {
const {behavioralPrompts, docModel} = gristDoc;
const tables = fromKo(docModel.visibleTables.getObservable());
const columns = fromKo(docModel.columns.createAllRowsModel('parentPos').getObservable());
@ -204,7 +207,7 @@ export function buildPageWidgetPicker(
// dom
return cssPopupWrapper(
dom.create(PageWidgetSelect, value, tables, columns, onSaveCB, options),
dom.create(PageWidgetSelect, value, tables, columns, onSaveCB, behavioralPrompts, options),
// gives focus and binds keydown events
(elem: any) => { setTimeout(() => elem.focus(), 0); },
@ -223,7 +226,6 @@ export type IWidgetValueObs = {
export interface ISelectOptions {
// the button's label
buttonLabel?: string;
@ -274,6 +276,7 @@ export class PageWidgetSelect extends Disposable {
private _tables: Observable<TableRec[]>,
private _columns: Observable<ColumnRec[]>,
private _onSave: () => Promise<void>,
private _behavioralPrompts: BehavioralPrompts,
private _options: ISelectOptions = {}
) { super(); }
@ -304,9 +307,15 @@ export class PageWidgetSelect extends Disposable {
cssIcon('TypeTable'), 'New Table',
// prevent the selection of 'New Table' if it is disabled
dom.on('click', (ev) => !this._isNewTableDisabled.get() && this._selectTable('New Table')),
this._behavioralPrompts.attachTip('pageWidgetPicker', {
popupOptions: {
attach: null,
placement: 'right-start',
}
}),
cssEntry.cls('-selected', (use) => use(this._value.table) === 'New Table'),
cssEntry.cls('-disabled', this._isNewTableDisabled),
testId('table')
testId('table'),
),
dom.forEach(this._tables, (table) => dom('div',
cssEntryWrapper(
@ -355,7 +364,14 @@ export class PageWidgetSelect extends Disposable {
testId('selectby'))
),
GristTooltips.selectBy(),
{tooltipMenuOptions: {attach: null}},
{tooltipMenuOptions: {attach: null}, domArgs: [
this._behavioralPrompts.attachTip('pageWidgetPickerSelectBy', {
popupOptions: {
attach: null,
placement: 'bottom',
}
}),
]},
)
),
dom('div', {style: 'flex-grow: 1'}),
@ -427,8 +443,8 @@ export class PageWidgetSelect extends Disposable {
}
function header(label: string) {
return cssHeader(dom('h4', label), testId('heading'));
function header(label: string, ...args: DomElementArg[]) {
return cssHeader(dom('h4', label), ...args, testId('heading'));
}
const cssContainer = styled('div', `

@ -537,7 +537,7 @@ export class RightPanel extends Disposable {
const gristDoc = this._gristDoc;
const section = gristDoc.viewModel.activeSection;
const onSave = (val: IPageWidget) => gristDoc.saveViewSection(section.peek(), val);
return (elem) => { attachPageWidgetPicker(elem, gristDoc.docModel, onSave, {
return (elem) => { attachPageWidgetPicker(elem, gristDoc, onSave, {
buttonLabel: t('Save'),
value: () => toPageWidget(section.peek()),
selectBy: (val) => gristDoc.selectBy(val),

@ -0,0 +1,5 @@
import {AppModel} from 'app/client/models/AppModel';
export function showWelcomeCoachingCall(_triggerElement: Element, _app: AppModel): boolean {
return false;
}

@ -12,12 +12,18 @@ import {dom, input, Observable, styled, subscribeElem} from 'grainjs';
const t = makeT('WelcomeQuestions');
export function showWelcomeQuestions(userPrefsObs: Observable<UserPrefs>) {
/**
* Shows a modal with welcome questions if surveying is enabled and the user hasn't
* dismissed the modal before.
*
* Returns a boolean indicating whether the modal was shown or not.
*/
export function showWelcomeQuestions(userPrefsObs: Observable<UserPrefs>): boolean {
if (!(getGristConfig().survey && userPrefsObs.get()?.showNewUserQuestions)) {
return null;
return false;
}
return saveModal((ctl, owner): ISaveModalOptions => {
saveModal((ctl, owner): ISaveModalOptions => {
const selection = choices.map(c => Observable.create(owner, false));
const otherText = Observable.create(owner, '');
const showQuestions = getUserPrefObs(userPrefsObs, 'showNewUserQuestions');
@ -54,6 +60,8 @@ export function showWelcomeQuestions(userPrefsObs: Observable<UserPrefs>) {
modalArgs: cssModalCentered.cls(''),
};
});
return true;
}
const choices: Array<{icon: IconName, color: string, textKey: string}> = [

@ -228,6 +228,8 @@ export const theme = {
'black'),
tooltipCloseButtonHoverBg: new CustomProp('theme-tooltip-close-button-hover-bg', undefined,
'white'),
tooltipPopupHeaderFg: new CustomProp('theme-tooltip-popup-header-fg', undefined, colors.light),
tooltipPopupHeaderBg: new CustomProp('theme-tooltip-popup-header-bg', undefined, colors.lightGreen),
/* Modals */
modalBg: new CustomProp('theme-modal-bg', undefined, 'white'),

@ -98,6 +98,11 @@ export const menuCssClass = cssMenuElem.className;
// Add grist-floating-menu class to support existing browser tests
const defaults = { menuCssClass: menuCssClass + ' grist-floating-menu' };
export interface SelectOptions<T> extends weasel.ISelectUserOptions {
/** Additional DOM element args to pass to each select option. */
renderOptionArgs?: (option: IOptionFull<T | null>) => DomElementArg;
}
/**
* Creates a select dropdown widget. The observable `obs` reflects the value of the selected
* option, and `optionArray` is an array (regular or observable) of option values and labels.
@ -121,15 +126,20 @@ const defaults = { menuCssClass: menuCssClass + ' grist-floating-menu' };
* ]);
* select(employee, allEmployees, {defLabel: "Select employee:"});
*
* const name = observable("alice");
* const names = ["alice", "bob", "carol"];
* select(name, names, {renderOptionArgs: (op) => console.log(`Rendered option ${op.value}`)});
*
* Note that this select element is not compatible with browser address autofill for usage in
* forms, and that formSelect should be used for this purpose.
*/
export function select<T>(obs: Observable<T>, optionArray: MaybeObsArray<IOption<T>>,
options: weasel.ISelectUserOptions = {}) {
options: SelectOptions<T> = {}) {
const {renderOptionArgs, ...weaselOptions} = options;
const _menu = cssSelectMenuElem(testId('select-menu'));
const _btn = cssSelectBtn(testId('select-open'));
const {menuCssClass: menuClass, ...otherOptions} = options;
const {menuCssClass: menuClass, ...otherOptions} = weaselOptions;
const selectOptions = {
buttonArrow: cssInlineCollapseIcon('Collapse'),
menuCssClass: _menu.className + ' ' + (menuClass || ''),
@ -141,6 +151,7 @@ export function select<T>(obs: Observable<T>, optionArray: MaybeObsArray<IOption
cssOptionRow(
op.icon ? cssOptionRowIcon(op.icon) : null,
cssOptionLabel(op.label),
renderOptionArgs ? renderOptionArgs(op) : null,
testId('select-row')
)
) as HTMLElement; // TODO: should be changed in weasel

@ -7,7 +7,7 @@ import {bigBasicButton, bigPrimaryButton, cssButton} from 'app/client/ui2018/but
import {mediaSmall, testId, theme, vars} from 'app/client/ui2018/cssVars';
import {loadingSpinner} from 'app/client/ui2018/loaders';
import {waitGrainObs} from 'app/common/gutil';
import {IOpenController, IPopupDomCreator, IPopupOptions, PopupControl, popupOpen} from 'popweasel';
import {IOpenController, IPopupOptions, PopupControl, popupOpen} from 'popweasel';
import {Computed, Disposable, dom, DomContents, DomElementArg, input, keyframes,
MultiHolder, Observable, styled} from 'grainjs';
import {cssMenuElem} from 'app/client/ui2018/menus';
@ -483,7 +483,7 @@ export function cssModalWidth(style: ModalWidth) {
*/
export function modalTooltip(
reference: Element,
domCreator: IPopupDomCreator,
domCreator: (ctl: IOpenController) => DomElementArg,
options: IPopupOptions = {}
): PopupControl {
return popupOpen(reference, (ctl: IOpenController) => {
@ -496,7 +496,7 @@ export function modalTooltip(
/* CSS styled components */
const cssModalTooltip = styled(cssMenuElem, `
export const cssModalTooltip = styled(cssMenuElem, `
padding: 16px 24px;
background: ${theme.modalBg};
border-radius: 3px;
@ -562,6 +562,19 @@ export const cssModalButtons = styled('div', `
}
`);
export const cssModalCloseButton = styled('div', `
align-self: flex-end;
margin: -8px;
padding: 4px;
border-radius: 4px;
cursor: pointer;
--icon-color: ${theme.modalCloseButtonFg};
&:hover {
background-color: ${theme.hover};
}
`);
const cssFadeIn = keyframes(`
from {background-color: transparent}
`);

@ -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};
}
`);

@ -104,6 +104,7 @@ export class FieldBuilder extends Disposable {
private readonly _docModel: DocModel;
private readonly _readonly: Computed<boolean>;
private readonly _comments: ko.Computed<boolean>;
private readonly _showRefConfigPopup: ko.Observable<boolean>;
public constructor(public readonly gristDoc: GristDoc, public readonly field: ViewFieldRec,
private _cursor: Cursor, private _options: { isPreview?: boolean } = {}) {
@ -202,6 +203,8 @@ export class FieldBuilder extends Disposable {
}, this).extend({ deferred: true }));
this.diffImpl = this.autoDispose(DiffBox.create(this.field));
this._showRefConfigPopup = ko.observable(false);
}
public buildSelectWidgetDom() {
@ -261,6 +264,9 @@ export class FieldBuilder extends Disposable {
selectType.onWrite(newType => {
const sameType = newType === this._readOnlyPureType.peek();
if (!sameType || commonType.get() === 'mixed') {
if (['Ref', 'RefList'].includes(newType)) {
this._showRefConfigPopup(true);
}
return this._setType(newType);
}
});
@ -280,7 +286,24 @@ export class FieldBuilder extends Disposable {
// If we are waiting for a server response
use(this.isCallPending),
menuCssClass: cssTypeSelectMenu.className,
defaultLabel: 'Mixed types'
defaultLabel: 'Mixed types',
renderOptionArgs: (op) => {
if (['Ref', 'RefList'].includes(selectType.get())) {
// Don't show tip if a reference column type is already selected.
return;
}
if (op.label === 'Reference') {
return this.gristDoc.behavioralPrompts.attachTip('referenceColumns', {
popupOptions: {
attach: `.${cssTypeSelectMenu.className}`,
placement: 'left-start',
}
});
} else {
return null;
}
}
}),
testId('type-select'),
grainjsDom.cls('tour-type-selector'),
@ -346,7 +369,17 @@ export class FieldBuilder extends Disposable {
return use(this.origColumn.disableModifyBase) || use(this.field.config.multiselect);
});
return [
cssLabel('DATA FROM TABLE'),
cssLabel('DATA FROM TABLE',
!this._showRefConfigPopup.peek() ? null : this.gristDoc.behavioralPrompts.attachTip(
'referenceColumnsConfig',
{
onDispose: () => this._showRefConfigPopup(false),
popupOptions: {
placement: 'left-start',
},
}
),
),
cssRow(
dom.autoDispose(allTables),
dom.autoDispose(isDisabled),

@ -27,6 +27,10 @@ export interface UserPrefs extends Prefs {
seenDeprecatedWarnings?: DeprecationWarning[];
// List of dismissedPopups user have seen.
dismissedPopups?: DismissedPopup[];
// Behavioral prompt preferences.
behavioralPrompts?: BehavioralPromptPrefs;
// Welcome popups a user has dismissed.
dismissedWelcomePopups?: DismissedReminder[];
}
// A collection of preferences related to a combination of user and org.
@ -63,11 +67,47 @@ export const DeprecationWarning = StringUnion(
);
export type DeprecationWarning = typeof DeprecationWarning.type;
export const BehavioralPrompt = StringUnion(
'referenceColumns',
'referenceColumnsConfig',
'rawDataPage',
'accessRules',
'filterButtons',
'nestedFiltering',
'pageWidgetPicker',
'pageWidgetPickerSelectBy',
'editCardLayout',
);
export type BehavioralPrompt = typeof BehavioralPrompt.type;
export interface BehavioralPromptPrefs {
/** Defaults to false. */
dontShowTips: boolean;
/** List of tips that have been dismissed. */
dismissedTips: BehavioralPrompt[];
}
/**
* List of all popups that user can see and dismiss
*/
export const DismissedPopup = StringUnion(
'deleteRecords', // confirmation for deleting records keyboard shortcut
'deleteFields' // confirmation for deleting columns keyboard shortcut
'deleteFields', // confirmation for deleting columns keyboard shortcut
);
export type DismissedPopup = typeof DismissedPopup.type;
export const WelcomePopup = StringUnion(
'coachingCall',
);
export type WelcomePopup = typeof WelcomePopup.type;
export interface DismissedReminder {
/** The name of the popup. */
id: WelcomePopup;
/** Unix timestamp in ms when the popup was last dismissed. */
lastDismissedAt: number;
/** If non-null, Unix timestamp in ms when the popup will reappear. */
nextAppearanceAt: number | null;
/** The number of times this popup has been dismissed. */
timesDismissed: number;
}

@ -75,6 +75,8 @@ export const ThemeColors = t.iface([], {
"tooltip-close-button-fg": "string",
"tooltip-close-button-hover-fg": "string",
"tooltip-close-button-hover-bg": "string",
"tooltip-popup-header-fg": "string",
"tooltip-popup-header-bg": "string",
"modal-bg": "string",
"modal-backdrop": "string",
"modal-border": "string",

@ -87,6 +87,8 @@ export interface ThemeColors {
'tooltip-close-button-fg': string;
'tooltip-close-button-hover-fg': string;
'tooltip-close-button-hover-bg': string;
'tooltip-popup-header-fg': string;
'tooltip-popup-header-bg': string;
/* Modals */
'modal-bg': string;

@ -62,8 +62,12 @@ export const MIN_URLID_PREFIX_LENGTH = 12;
export const commonUrls = {
help: getHelpCenterUrl(),
helpAccessRules: "https://support.getgrist.com/access-rules",
helpColRefs: "https://support.getgrist.com/col-refs",
helpConditionalFormatting: "https://support.getgrist.com/conditional-formatting",
helpFilterButtons: "https://support.getgrist.com/search-sort-filter/#filter-buttons",
helpLinkingWidgets: "https://support.getgrist.com/linking-widgets",
helpRawData: "https://support.getgrist.com/raw-data",
helpUnderstandingReferenceColumns: "https://support.getgrist.com/col-refs/#understanding-reference-columns",
helpTriggerFormulas: "https://support.getgrist.com/formulas/#trigger-formulas",
helpTryingOutChanges: "https://support.getgrist.com/copying-docs/#trying-out-changes",
plans: "https://www.getgrist.com/pricing",

@ -66,6 +66,8 @@ export const GristDark: ThemeColors = {
'tooltip-close-button-fg': 'white',
'tooltip-close-button-hover-fg': 'black',
'tooltip-close-button-hover-bg': 'white',
'tooltip-popup-header-fg': '#EFEFEF',
'tooltip-popup-header-bg': '#1DA270',
/* Modals */
'modal-bg': '#32323F',

@ -66,6 +66,8 @@ export const GristLight: ThemeColors = {
'tooltip-close-button-fg': 'white',
'tooltip-close-button-hover-fg': 'black',
'tooltip-close-button-hover-bg': 'white',
'tooltip-popup-header-fg': 'white',
'tooltip-popup-header-bg': '#16B378',
/* Modals */
'modal-bg': 'white',

@ -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);
});
}
}

@ -51,6 +51,8 @@ export const listDocs = homeUtil.listDocs.bind(homeUtil);
export const createHomeApi = homeUtil.createHomeApi.bind(homeUtil);
export const simulateLogin = homeUtil.simulateLogin.bind(homeUtil);
export const removeLogin = homeUtil.removeLogin.bind(homeUtil);
export const enableTips = homeUtil.enableTips.bind(homeUtil);
export const disableTips = homeUtil.disableTips.bind(homeUtil);
export const setValue = homeUtil.setValue.bind(homeUtil);
export const isOnLoginPage = homeUtil.isOnLoginPage.bind(homeUtil);
export const isOnGristLoginPage = homeUtil.isOnLoginPage.bind(homeUtil);
@ -1008,9 +1010,19 @@ export async function addNewTable(name?: string) {
export interface PageWidgetPickerOptions {
tableName?: string;
selectBy?: RegExp|string; // Optional pattern of SELECT BY option to pick.
summarize?: (RegExp|string)[]; // Optional list of patterns to match Group By columns.
dontAdd?: boolean; // If true, configure the widget selection without actually adding to the page
/** Optional pattern of SELECT BY option to pick. */
selectBy?: RegExp|string;
/** Optional list of patterns to match Group By columns. */
summarize?: (RegExp|string)[];
/** If true, configure the widget selection without actually adding to the page. */
dontAdd?: boolean;
/**
* If true, dismiss any tooltips that are shown.
*
* TODO: Only needed by one test. Can be removed once a fix has landed for the bug
* where user-level preferences aren't loaded when the session's org is null.
*/
dismissTips?: boolean;
}
// Add a new page using the 'Add New' menu and wait for the new page to be shown.
@ -1051,11 +1063,15 @@ export async function openAddWidgetToPage() {
export async function selectWidget(
typeRe: RegExp|string,
tableRe: RegExp|string = '',
options: PageWidgetPickerOptions = {}) {
options: PageWidgetPickerOptions = {}
) {
if (options.dismissTips) { await dismissBehavioralPrompts(); }
// select right type
await driver.findContent('.test-wselect-type', typeRe).doClick();
if (options.dismissTips) { await dismissBehavioralPrompts(); }
if (tableRe) {
const tableEl = driver.findContent('.test-wselect-table', tableRe);
@ -1067,6 +1083,8 @@ export async function selectWidget(
// let's select table
await tableEl.click();
if (options.dismissTips) { await dismissBehavioralPrompts(); }
const pivotEl = tableEl.find('.test-wselect-pivot');
if (await pivotEl.isPresent()) {
await toggleSelectable(pivotEl, Boolean(options.summarize));
@ -1207,10 +1225,9 @@ export async function renameColumn(col: IColHeader, newName: string) {
}
/**
* Removes a table using RAW data view. Returns a current url.
* Removes a table using RAW data view.
*/
export async function removeTable(tableId: string, goBack: boolean = false) {
const back = await driver.getCurrentUrl();
export async function removeTable(tableId: string) {
await driver.find(".test-tools-raw").click();
const tableIdList = await driver.findAll('.test-raw-data-table-id', e => e.getText());
const tableIndex = tableIdList.indexOf(tableId);
@ -1221,11 +1238,6 @@ export async function removeTable(tableId: string, goBack: boolean = false) {
await driver.find(".test-raw-data-menu-remove").click();
await driver.find(".test-modal-confirm").click();
await waitForServer();
if (goBack) {
await driver.get(back);
await waitAppFocus();
}
return back;
}
/**
@ -1526,14 +1538,18 @@ export async function deleteColumn(col: IColHeader|string) {
/**
* Sets the type of the currently selected field to value.
*/
export async function setType(type: RegExp|string, options: {skipWait?: boolean, apply?: boolean} = {}) {
export async function setType(
type: RegExp|string,
options: {skipWait?: boolean, apply?: boolean} = {}
) {
const {skipWait, apply} = options;
await toggleSidePanel('right', 'open');
await driver.find('.test-right-tab-field').click();
await driver.find('.test-fbuilder-type-select').click();
type = typeof type === 'string' ? exactMatch(type) : type;
await driver.findContentWait('.test-select-menu .test-select-row', type, 500).click();
if (!options.skipWait || options.apply) { await waitForServer(); }
if (options.apply) {
if (!skipWait || apply) { await waitForServer(); }
if (apply) {
await driver.findWait('.test-type-transform-apply', 1000).click();
await waitForServer();
}
@ -1912,13 +1928,21 @@ export class Session {
public async login(options?: {loginMethod?: UserProfile['loginMethod'],
freshAccount?: boolean,
isFirstLogin?: boolean,
showTips?: boolean,
retainExistingLogin?: boolean}) {
// Optimize testing a little bit, so if we are already logged in as the expected
// user on the expected org, and there are no options set, we can just continue.
if (!options && await this.isLoggedInCorrectly()) { return this; }
if (!options?.retainExistingLogin) {
await removeLogin();
if (this.settings.email === 'anon@getgrist.com') { return this; }
if (this.settings.email === 'anon@getgrist.com') {
if (options?.showTips) {
await enableTips(this.settings.email);
} else {
await disableTips(this.settings.email);
}
return this;
}
}
await server.simulateLogin(this.settings.name, this.settings.email, this.settings.orgDomain,
{isFirstLogin: false, cacheCredentials: true, ...options});
@ -2701,6 +2725,39 @@ export async function refreshDismiss() {
await waitForDocToLoad();
}
/**
* Dismisses all behavioral prompts that are present.
*/
export async function dismissBehavioralPrompts() {
let i = 0;
const max = 10;
// Keep dismissing prompts until there are no more, up to a maximum of 10 times.
while (i < max && await driver.find('.test-behavioral-prompt').isPresent()) {
await driver.find('.test-behavioral-prompt-dismiss').click();
await waitForServer();
i += 1;
}
}
/**
* Dismisses all card popups that are present.
*
* Optionally takes a `waitForServerTimeoutMs`, which may be null to skip waiting
* after closing each popup.
*/
export async function dismissCardPopups(waitForServerTimeoutMs: number | null = 2000) {
let i = 0;
const max = 10;
// Keep dismissing popups until there are no more, up to a maximum of 10 times.
while (i < max && await driver.find('.test-popup-card').isPresent()) {
await driver.find('.test-popup-close-button').click();
if (waitForServerTimeoutMs) { await waitForServer(waitForServerTimeoutMs); }
i += 1;
}
}
/**
* Confirms that anchor link was used for navigation.
*/

@ -11,6 +11,7 @@ import * as path from 'path';
import {WebDriver} from 'selenium-webdriver';
import {UserProfile} from 'app/common/LoginSessionAPI';
import {BehavioralPrompt, UserPrefs, WelcomePopup} from 'app/common/Prefs';
import {DocWorkerAPI, UserAPI, UserAPIImpl} from 'app/common/UserAPI';
import {HomeDBManager} from 'app/gen-server/lib/HomeDBManager';
import log from 'app/server/lib/log';
@ -25,6 +26,29 @@ export interface Server {
isExternalServer(): boolean;
}
const ALL_TIPS_ENABLED = {
behavioralPrompts: {
dontShowTips: false,
dismissedTips: [],
},
dismissedWelcomePopups: [],
};
const ALL_TIPS_DISABLED = {
behavioralPrompts: {
dontShowTips: true,
dismissedTips: BehavioralPrompt.values,
},
dismissedWelcomePopups: WelcomePopup.values.map(id => {
return {
id,
lastDismissedAt: 0,
nextAppearanceAt: null,
timesDismissed: 1,
};
}),
};
export class HomeUtil {
// Cache api keys of test users. It is often convenient to have various instances
// of the home api available while making browser tests.
@ -54,9 +78,13 @@ export class HomeUtil {
freshAccount?: boolean,
isFirstLogin?: boolean,
showGristTour?: boolean,
showTips?: boolean,
cacheCredentials?: boolean,
} = {}) {
const {loginMethod, isFirstLogin} = defaults(options, {loginMethod: 'Email + Password'});
const {loginMethod, isFirstLogin, showTips} = defaults(options, {
loginMethod: 'Email + Password',
showTips: false,
});
const showGristTour = options.showGristTour ?? (options.freshAccount ?? isFirstLogin);
// For regular tests, we can log in through a testing hook.
@ -64,6 +92,11 @@ export class HomeUtil {
if (options.freshAccount) { await this._deleteUserByEmail(email); }
if (isFirstLogin !== undefined) { await this._setFirstLogin(email, isFirstLogin); }
if (showGristTour !== undefined) { await this._initShowGristTour(email, showGristTour); }
if (showTips) {
await this.enableTips(email);
} else {
await this.disableTips(email);
}
// TestingHooks communicates via JSON, so it's impossible to send an `undefined` value for org
// through it. Using the empty string happens to work though.
const testingHooks = await this.server.getTestingHooks();
@ -122,6 +155,14 @@ export class HomeUtil {
}
}
public async enableTips(email: string) {
await this._toggleTips(true, email);
}
public async disableTips(email: string) {
await this._toggleTips(false, email);
}
// Check if the url looks like a welcome page. The check is weak, but good enough
// for testing.
public async isWelcomePage() {
@ -396,4 +437,30 @@ export class HomeUtil {
newFormData: () => new FormData() as any, // form-data isn't quite type compatible
logger: log});
}
private async _toggleTips(enabled: boolean, email: string) {
if (this.server.isExternalServer()) { throw new Error('not supported'); }
const dbManager = await this.server.getDatabase();
const user = await dbManager.getUserByLogin(email);
if (!user) { return; }
if (user.personalOrg) {
const org = await dbManager.getOrg({userId: user.id}, user.personalOrg.id);
const userPrefs = (org.data as any)?.userPrefs ?? {};
const newUserPrefs: UserPrefs = {
...userPrefs,
...(enabled ? ALL_TIPS_ENABLED : ALL_TIPS_DISABLED),
};
await dbManager.updateOrg({userId: user.id}, user.personalOrg.id, {userPrefs: newUserPrefs});
} else {
await this.driver.executeScript(`
const userPrefs = JSON.parse(localStorage.getItem('userPrefs:u=${user.id}') || '{}');
localStorage.setItem('userPrefs:u=${user.id}', JSON.stringify({
...userPrefs,
...${JSON.stringify(enabled ? ALL_TIPS_ENABLED : ALL_TIPS_DISABLED)},
}));
`);
}
}
}

Loading…
Cancel
Save