(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
This commit is contained in:
George Gevoian
2022-12-19 21:06:39 -05:00
parent fa75c93d67
commit e52e15591d
41 changed files with 1236 additions and 126 deletions

View File

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

View File

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

View File

@@ -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

View File

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