diff --git a/app/client/aclui/AccessRules.ts b/app/client/aclui/AccessRules.ts index 8b1c1fd7..2021c909 100644 --- a/app/client/aclui/AccessRules.ts +++ b/app/client/aclui/AccessRules.ts @@ -335,7 +335,7 @@ export class AccessRules extends Disposable { public buildDom() { return cssOuter( - dom('div', this._gristDoc.behavioralPrompts.attachTip('accessRules', { + dom('div', this._gristDoc.behavioralPromptsManager.attachTip('accessRules', { hideArrow: true, })), cssAddTableRow( diff --git a/app/client/components/BehavioralPrompts.ts b/app/client/components/BehavioralPromptsManager.ts similarity index 89% rename from app/client/components/BehavioralPrompts.ts rename to app/client/components/BehavioralPromptsManager.ts index a01f2ec1..b533b908 100644 --- a/app/client/components/BehavioralPrompts.ts +++ b/app/client/components/BehavioralPromptsManager.ts @@ -1,10 +1,11 @@ import {showBehavioralPrompt} from 'app/client/components/modals'; import {AppModel} from 'app/client/models/AppModel'; +import {getUserPrefObs} from 'app/client/models/UserPrefs'; import {GristBehavioralPrompts} from 'app/client/ui/GristTooltips'; import {isNarrowScreen} from 'app/client/ui2018/cssVars'; -import {BehavioralPrompt} from 'app/common/Prefs'; +import {BehavioralPrompt, BehavioralPromptPrefs} from 'app/common/Prefs'; import {getGristConfig} from 'app/common/urlUtils'; -import {Computed, Disposable, dom} from 'grainjs'; +import {Computed, Disposable, dom, Observable} from 'grainjs'; import {IPopupOptions} from 'popweasel'; export interface AttachOptions { @@ -25,12 +26,15 @@ interface QueuedTip { * * Tips are shown in the order that they are attached. */ -export class BehavioralPrompts extends Disposable { - private _prefs = this._appModel.behavioralPrompts; +export class BehavioralPromptsManager extends Disposable { + private readonly _prefs = getUserPrefObs(this._appModel.userPrefsObs, 'behavioralPrompts', + { defaultValue: { dontShowTips: false, dismissedTips: [] } }) as Observable; + private _dismissedTips: Computed> = Computed.create(this, use => { const {dismissedTips} = use(this._prefs); return new Set(dismissedTips.filter(BehavioralPrompt.guard)); }); + private _queuedTips: QueuedTip[] = []; constructor(private _appModel: AppModel) { diff --git a/app/client/components/GristDoc.ts b/app/client/components/GristDoc.ts index 1af3ab4b..df4e092b 100644 --- a/app/client/components/GristDoc.ts +++ b/app/client/components/GristDoc.ts @@ -6,7 +6,6 @@ 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'; @@ -166,7 +165,7 @@ 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; - public readonly behavioralPrompts = BehavioralPrompts.create(this, this.docPageModel.appModel); + public readonly behavioralPromptsManager = this.docPageModel.appModel.behavioralPromptsManager; private _actionLog: ActionLog; private _undoStack: UndoStack; @@ -1100,7 +1099,7 @@ export class GristDoc extends DisposableWithEvents { // 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') + this.behavioralPromptsManager.hasSeenTip('editCardLayout') ) { return; } @@ -1114,7 +1113,7 @@ export class GristDoc extends DisposableWithEvents { 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', { + this.behavioralPromptsManager.showTip(editLayoutButton, 'editCardLayout', { popupOptions: { placement: 'left-start', } diff --git a/app/client/components/RawDataPage.ts b/app/client/components/RawDataPage.ts index 1175f54e..bd9b11ca 100644 --- a/app/client/components/RawDataPage.ts +++ b/app/client/components/RawDataPage.ts @@ -42,7 +42,7 @@ export class RawDataPage extends Disposable { public buildDom() { return cssContainer( - dom('div', this._gristDoc.behavioralPrompts.attachTip('rawDataPage', {hideArrow: true})), + dom('div', this._gristDoc.behavioralPromptsManager.attachTip('rawDataPage', {hideArrow: true})), dom('div', dom.create(DataTables, this._gristDoc), dom.create(DocumentUsage, this._gristDoc.docPageModel), diff --git a/app/client/models/AppModel.ts b/app/client/models/AppModel.ts index 4defcde7..ff339c27 100644 --- a/app/client/models/AppModel.ts +++ b/app/client/models/AppModel.ts @@ -1,3 +1,4 @@ +import {BehavioralPromptsManager} from 'app/client/components/BehavioralPromptsManager'; import {get as getBrowserGlobals} from 'app/client/lib/browserGlobals'; import {makeT} from 'app/client/lib/localization'; import {error} from 'app/client/lib/log'; @@ -12,9 +13,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 {BehavioralPromptPrefs, DeprecationWarning, DismissedPopup, DismissedReminder, - UserPrefs} from 'app/common/Prefs'; -import {isOwner} from 'app/common/roles'; +import {DeprecationWarning, DismissedPopup, DismissedReminder, UserPrefs} from 'app/common/Prefs'; +import {isOwner, isOwnerOrEditor} from 'app/common/roles'; import {getTagManagerScript} from 'app/common/tagManager'; import {getDefaultThemePrefs, Theme, ThemeAppearance, ThemeColors, ThemePrefs, ThemePrefsChecker} from 'app/common/ThemePrefs'; @@ -99,19 +99,21 @@ export interface AppModel { */ deprecatedWarnings: Observable; dismissedWelcomePopups: Observable; - behavioralPrompts: Observable; pageType: Observable; notifier: Notifier; planName: string|null; + behavioralPromptsManager: BehavioralPromptsManager; + refreshOrgUsage(): Promise; showUpgradeModal(): void; 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 + isOwnerOrEditor(): boolean; // If user is an owner or editor of this org } export class TopAppModelImpl extends Disposable implements TopAppModel { @@ -246,8 +248,6 @@ export class AppModelImpl extends Disposable implements AppModel { { defaultValue: [] }) as Observable; public readonly dismissedWelcomePopups = getUserPrefObs(this.userPrefsObs, 'dismissedWelcomePopups', { defaultValue: [] }) as Observable; - public readonly behavioralPrompts = getUserPrefObs(this.userPrefsObs, 'behavioralPrompts', - { defaultValue: { dontShowTips: false, dismissedTips: [] } }) as Observable; // Get the current PageType from the URL. public readonly pageType: Observable = Computed.create(this, urlState().state, @@ -255,6 +255,9 @@ export class AppModelImpl extends Disposable implements AppModel { public readonly notifier = this.topAppModel.notifier; + public readonly behavioralPromptsManager: BehavioralPromptsManager = + BehavioralPromptsManager.create(this, this); + constructor( public readonly topAppModel: TopAppModel, public readonly currentUser: FullUser|null, @@ -314,6 +317,10 @@ export class AppModelImpl extends Disposable implements AppModel { return Boolean(this.currentOrg && isOwner(this.currentOrg)); } + public isOwnerOrEditor() { + return Boolean(this.currentOrg && isOwnerOrEditor(this.currentOrg)); + } + /** * Fetch and update the current org's usage. */ diff --git a/app/client/models/HomeModel.ts b/app/client/models/HomeModel.ts index 01da3e92..2ac3efdb 100644 --- a/app/client/models/HomeModel.ts +++ b/app/client/models/HomeModel.ts @@ -75,6 +75,8 @@ export interface HomeModel { // user isn't allowed to create a doc. newDocWorkspace: Observable; + shouldShowAddNewTip: Observable; + createWorkspace(name: string): Promise; renameWorkspace(id: number, name: string): Promise; deleteWorkspace(id: number, forever: boolean): Promise; @@ -154,6 +156,9 @@ export class HomeModelImpl extends Disposable implements HomeModel, ViewSettings public readonly showIntro = Computed.create(this, this.workspaces, (use, wss) => ( wss.every((ws) => ws.isSupportWorkspace || ws.docs.length === 0))); + public readonly shouldShowAddNewTip = Observable.create(this, + !this._app.behavioralPromptsManager.hasSeenTip('addNew')); + private _userOrgPrefs = Observable.create(this, this._app.currentOrg?.userOrgPrefs); constructor(private _app: AppModel, clientScope: ClientScope) { diff --git a/app/client/ui/AddNewTip.ts b/app/client/ui/AddNewTip.ts new file mode 100644 index 00000000..19871357 --- /dev/null +++ b/app/client/ui/AddNewTip.ts @@ -0,0 +1,53 @@ +import {HomeModel} from 'app/client/models/HomeModel'; +import {shouldShowWelcomeQuestions} from 'app/client/ui/WelcomeQuestions'; + +export function attachAddNewTip(home: HomeModel): (el: Element) => void { + return () => { + const {app: {userPrefsObs}} = home; + if (shouldShowWelcomeQuestions(userPrefsObs)) { + return; + } + + if (shouldShowAddNewTip(home)) { + showAddNewTip(home); + } + }; +} + +function shouldShowAddNewTip(home: HomeModel): boolean { + return ( + // Only show if the user is an owner or editor. + home.app.isOwnerOrEditor() && + // And the tip hasn't been shown before. + home.shouldShowAddNewTip.get() && + // And the intro isn't being shown. + !home.showIntro.get() && + // And the workspace loaded correctly. + home.available.get() && + // And the current page isn't /p/trash; the Add New button is limited there. + home.currentPage.get() !== 'trash' + ); +} + +function showAddNewTip(home: HomeModel): void { + const addNewButton = document.querySelector('.behavioral-prompt-add-new'); + if (!addNewButton) { + console.warn('AddNewTip failed to find Add New button'); + return; + } + if (!isVisible(addNewButton as HTMLElement)) { + return; + } + + home.app.behavioralPromptsManager.showTip(addNewButton, 'addNew', { + popupOptions: { + placement: 'right-start', + }, + onDispose: () => home.shouldShowAddNewTip.set(false), + }); +} + +function isVisible(element: HTMLElement): boolean { + // From https://github.com/jquery/jquery/blob/c66d4700dcf98efccb04061d575e242d28741223/src/css/hiddenVisibleSelectors.js. + return Boolean(element.offsetWidth || element.offsetHeight || element.getClientRects().length); +} diff --git a/app/client/ui/ColumnFilterMenu.ts b/app/client/ui/ColumnFilterMenu.ts index da706552..c9fa0965 100644 --- a/app/client/ui/ColumnFilterMenu.ts +++ b/app/client/ui/ColumnFilterMenu.ts @@ -352,7 +352,7 @@ 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', { + gristDoc.behavioralPromptsManager.attachTip('filterButtons', { popupOptions: { attach: null, modifiers: { diff --git a/app/client/ui/DocMenu.ts b/app/client/ui/DocMenu.ts index 6d1be183..38323bbe 100644 --- a/app/client/ui/DocMenu.ts +++ b/app/client/ui/DocMenu.ts @@ -4,18 +4,19 @@ * Orgs, workspaces and docs are fetched asynchronously on build via the passed in API. */ import {loadUserManager} from 'app/client/lib/imports'; -import {AppModel, reportError} from 'app/client/models/AppModel'; +import {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'; +import {attachAddNewTip} from 'app/client/ui/AddNewTip'; import * as css from 'app/client/ui/DocMenuCss'; import {buildHomeIntro, buildWorkspaceIntro} from 'app/client/ui/HomeIntro'; 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 {shouldShowWelcomeCoachingCall, showWelcomeCoachingCall} from 'app/client/ui/WelcomeCoachingCall'; +import {shouldShowWelcomeQuestions, showWelcomeQuestions} from 'app/client/ui/WelcomeQuestions'; import {createVideoTourTextButton} from 'app/client/ui/OpenVideoTour'; import {buttonSelect, cssButtonSelect} from 'app/client/ui2018/buttonSelect'; import {isNarrowScreenObs, theme} from 'app/client/ui2018/cssVars'; @@ -47,7 +48,7 @@ const testId = makeTestId('test-dm-'); */ export function createDocMenu(home: HomeModel): DomElementArg[] { return [ - attachWelcomePopups(home.app), + attachWelcomePopups(home), dom.domComputed(home.loading, loading => ( loading === 'slow' ? css.spinner(loadingSpinner()) : loading ? null : @@ -56,12 +57,14 @@ export function createDocMenu(home: HomeModel): DomElementArg[] { ]; } -function attachWelcomePopups(app: AppModel): (el: Element) => void { +function attachWelcomePopups(home: HomeModel): (el: Element) => void { return (element: Element) => { - const isShowingPopup = showWelcomeQuestions(app.userPrefsObs); - if (isShowingPopup) { return; } - - showWelcomeCoachingCall(element, app); + const {app, app: {userPrefsObs}} = home; + if (shouldShowWelcomeQuestions(userPrefsObs)) { + showWelcomeQuestions(userPrefsObs); + } else if (shouldShowWelcomeCoachingCall(app)) { + showWelcomeCoachingCall(element, app); + } }; } @@ -70,6 +73,8 @@ function createLoadedDocMenu(owner: IDisposableOwner, home: HomeModel) { const upgradeButton = buildUpgradeButton(owner, home.app); return css.docList( css.docMenu( + attachAddNewTip(home), + dom.maybe(!home.app.currentFeatures.workspaces, () => [ css.docListHeader(t("This service is not available right now")), dom('span', t("(The organization needs a paid plan)")), diff --git a/app/client/ui/FilterBar.ts b/app/client/ui/FilterBar.ts index 36885cac..56a00c14 100644 --- a/app/client/ui/FilterBar.ts +++ b/app/client/ui/FilterBar.ts @@ -24,7 +24,7 @@ export function filterBar( dom.forEach(viewSection.activeFilters, (filterInfo) => makeFilterField(filterInfo, popupControls)), dom.maybe(viewSection.showNestedFilteringPopup, () => { return dom('div', - gristDoc.behavioralPrompts.attachTip('nestedFiltering', { + gristDoc.behavioralPromptsManager.attachTip('nestedFiltering', { onDispose: () => viewSection.showNestedFilteringPopup.set(false), }), ); diff --git a/app/client/ui/GristTooltips.ts b/app/client/ui/GristTooltips.ts index e3ab1f31..23494878 100644 --- a/app/client/ui/GristTooltips.ts +++ b/app/client/ui/GristTooltips.ts @@ -188,4 +188,12 @@ export const GristBehavioralPrompts: Record cssTooltipContent( + dom('div', 'Click the Add New button to create new documents or workspaces, ' + + 'or import data.'), + ...args, + ), + }, }; diff --git a/app/client/ui/HomeLeftPane.ts b/app/client/ui/HomeLeftPane.ts index 069c1357..7e378ed8 100644 --- a/app/client/ui/HomeLeftPane.ts +++ b/app/client/ui/HomeLeftPane.ts @@ -38,7 +38,8 @@ export function createHomeLeftPane(leftPanelOpen: Observable, home: Hom // "Add New" menu should have the same width as the "Add New" button that opens it. stretchToSelector: `.${cssAddNewButton.className}` }), - testId('dm-add-new') + dom.cls('behavioral-prompt-add-new'), + testId('dm-add-new'), ), cssScrollPane( cssPageEntry( diff --git a/app/client/ui/PageWidgetPicker.ts b/app/client/ui/PageWidgetPicker.ts index d3d4351a..8f4b278f 100644 --- a/app/client/ui/PageWidgetPicker.ts +++ b/app/client/ui/PageWidgetPicker.ts @@ -1,4 +1,4 @@ -import { BehavioralPrompts } from 'app/client/components/BehavioralPrompts'; +import { BehavioralPromptsManager } from 'app/client/components/BehavioralPromptsManager'; import { GristDoc } from 'app/client/components/GristDoc'; import { makeT } from 'app/client/lib/localization'; import { reportError } from 'app/client/models/AppModel'; @@ -138,7 +138,7 @@ export function buildPageWidgetPicker( onSave: ISaveFunc, options: IOptions = {} ) { - const {behavioralPrompts, docModel} = gristDoc; + const {behavioralPromptsManager, docModel} = gristDoc; const tables = fromKo(docModel.visibleTables.getObservable()); const columns = fromKo(docModel.columns.createAllRowsModel('parentPos').getObservable()); @@ -207,7 +207,8 @@ export function buildPageWidgetPicker( // dom return cssPopupWrapper( - dom.create(PageWidgetSelect, value, tables, columns, onSaveCB, behavioralPrompts, options), + dom.create(PageWidgetSelect, + value, tables, columns, onSaveCB, behavioralPromptsManager, options), // gives focus and binds keydown events (elem: any) => { setTimeout(() => elem.focus(), 0); }, @@ -276,7 +277,7 @@ export class PageWidgetSelect extends Disposable { private _tables: Observable, private _columns: Observable, private _onSave: () => Promise, - private _behavioralPrompts: BehavioralPrompts, + private _behavioralPromptsManager: BehavioralPromptsManager, private _options: ISelectOptions = {} ) { super(); } @@ -307,7 +308,7 @@ 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', { + this._behavioralPromptsManager.attachTip('pageWidgetPicker', { popupOptions: { attach: null, placement: 'right-start', @@ -365,7 +366,7 @@ export class PageWidgetSelect extends Disposable { ), GristTooltips.selectBy(), {tooltipMenuOptions: {attach: null}, domArgs: [ - this._behavioralPrompts.attachTip('pageWidgetPickerSelectBy', { + this._behavioralPromptsManager.attachTip('pageWidgetPickerSelectBy', { popupOptions: { attach: null, placement: 'bottom', diff --git a/app/client/ui/WelcomeCoachingCallStub.ts b/app/client/ui/WelcomeCoachingCallStub.ts index 37d1f5d8..79638caa 100644 --- a/app/client/ui/WelcomeCoachingCallStub.ts +++ b/app/client/ui/WelcomeCoachingCallStub.ts @@ -1,5 +1,9 @@ import {AppModel} from 'app/client/models/AppModel'; -export function showWelcomeCoachingCall(_triggerElement: Element, _app: AppModel): boolean { +export function shouldShowWelcomeCoachingCall(_app: AppModel) { return false; } + +export function showWelcomeCoachingCall(_triggerElement: Element, _app: AppModel) { + +} diff --git a/app/client/ui/WelcomeQuestions.ts b/app/client/ui/WelcomeQuestions.ts index 2952306f..ac1b1f8a 100644 --- a/app/client/ui/WelcomeQuestions.ts +++ b/app/client/ui/WelcomeQuestions.ts @@ -12,17 +12,15 @@ import {dom, input, Observable, styled, subscribeElem} from 'grainjs'; const t = makeT('WelcomeQuestions'); +export function shouldShowWelcomeQuestions(userPrefsObs: Observable): boolean { + return Boolean(getGristConfig().survey && userPrefsObs.get()?.showNewUserQuestions); +} + /** * 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): boolean { - if (!(getGristConfig().survey && userPrefsObs.get()?.showNewUserQuestions)) { - return false; - } - +export function showWelcomeQuestions(userPrefsObs: Observable) { saveModal((ctl, owner): ISaveModalOptions => { const selection = choices.map(c => Observable.create(owner, false)); const otherText = Observable.create(owner, ''); @@ -60,8 +58,6 @@ export function showWelcomeQuestions(userPrefsObs: Observable): boole modalArgs: cssModalCentered.cls(''), }; }); - - return true; } const choices: Array<{icon: IconName, color: string, textKey: string}> = [ diff --git a/app/client/widgets/FieldBuilder.ts b/app/client/widgets/FieldBuilder.ts index cd5ff26f..2e07025c 100644 --- a/app/client/widgets/FieldBuilder.ts +++ b/app/client/widgets/FieldBuilder.ts @@ -294,7 +294,7 @@ export class FieldBuilder extends Disposable { } if (op.label === 'Reference') { - return this.gristDoc.behavioralPrompts.attachTip('referenceColumns', { + return this.gristDoc.behavioralPromptsManager.attachTip('referenceColumns', { popupOptions: { attach: `.${cssTypeSelectMenu.className}`, placement: 'left-start', @@ -370,7 +370,7 @@ export class FieldBuilder extends Disposable { }); return [ cssLabel('DATA FROM TABLE', - !this._showRefConfigPopup.peek() ? null : this.gristDoc.behavioralPrompts.attachTip( + !this._showRefConfigPopup.peek() ? null : this.gristDoc.behavioralPromptsManager.attachTip( 'referenceColumnsConfig', { onDispose: () => this._showRefConfigPopup(false), diff --git a/app/common/Prefs.ts b/app/common/Prefs.ts index 3b5f6751..ec091771 100644 --- a/app/common/Prefs.ts +++ b/app/common/Prefs.ts @@ -77,6 +77,7 @@ export const BehavioralPrompt = StringUnion( 'pageWidgetPicker', 'pageWidgetPickerSelectBy', 'editCardLayout', + 'addNew', ); export type BehavioralPrompt = typeof BehavioralPrompt.type; diff --git a/app/common/roles.ts b/app/common/roles.ts index 7ef04be0..7b1e9152 100644 --- a/app/common/roles.ts +++ b/app/common/roles.ts @@ -45,6 +45,10 @@ export function isOwner(resource: {access: Role}|null): resource is {access: Rol return resource?.access === OWNER; } +export function isOwnerOrEditor(resource: {access: Role}|null): resource is {access: Role} { + return canEdit(resource?.access ?? null); +} + export function canUpgradeOrg(org: Organization|null): org is Organization { // TODO: Need to consider billing managers and support user. return isOwner(org);