mirror of
https://github.com/gristlabs/grist-core.git
synced 2024-10-27 20:44:07 +00:00
(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:
parent
fa75c93d67
commit
e52e15591d
@ -322,6 +322,9 @@ export class AccessRules extends Disposable {
|
|||||||
|
|
||||||
public buildDom() {
|
public buildDom() {
|
||||||
return cssOuter(
|
return cssOuter(
|
||||||
|
dom('div', this._gristDoc.behavioralPrompts.attachTip('accessRules', {
|
||||||
|
hideArrow: true,
|
||||||
|
})),
|
||||||
cssAddTableRow(
|
cssAddTableRow(
|
||||||
bigBasicButton({disabled: true}, dom.hide(this._savingEnabled),
|
bigBasicButton({disabled: true}, dom.hide(this._savingEnabled),
|
||||||
dom.text((use) => {
|
dom.text((use) => {
|
||||||
|
@ -733,8 +733,17 @@ BaseView.prototype.getLastDataRowIndex = function() {
|
|||||||
* Creates and opens ColumnFilterMenu for a given field/column, and returns its PopupControl.
|
* Creates and opens ColumnFilterMenu for a given field/column, and returns its PopupControl.
|
||||||
*/
|
*/
|
||||||
BaseView.prototype.createFilterMenu = function(openCtl, filterInfo, options) {
|
BaseView.prototype.createFilterMenu = function(openCtl, filterInfo, options) {
|
||||||
return createFilterMenu(openCtl, this._sectionFilter, filterInfo, this._mainRowSource,
|
const {showAllFiltersButton, onClose} = options;
|
||||||
this.tableModel.tableData, options);
|
return createFilterMenu({
|
||||||
|
openCtl,
|
||||||
|
sectionFilter: this._sectionFilter,
|
||||||
|
filterInfo,
|
||||||
|
rowSource: this._mainRowSource,
|
||||||
|
tableData: this.tableModel.tableData,
|
||||||
|
gristDoc: this.gristDoc,
|
||||||
|
showAllFiltersButton,
|
||||||
|
onClose,
|
||||||
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
114
app/client/components/BehavioralPrompts.ts
Normal file
114
app/client/components/BehavioralPrompts.ts
Normal file
@ -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(
|
cssTableList(
|
||||||
/*************** List section **********/
|
/*************** List section **********/
|
||||||
testId('list'),
|
testId('list'),
|
||||||
docListHeader(t('RawDataTables')),
|
cssHeader(t('RawDataTables')),
|
||||||
cssList(
|
cssList(
|
||||||
dom.forEach(this._tables, tableRec =>
|
dom.forEach(this._tables, tableRec =>
|
||||||
cssItem(
|
cssItem(
|
||||||
@ -185,6 +185,10 @@ const container = styled('div', `
|
|||||||
position: relative;
|
position: relative;
|
||||||
`);
|
`);
|
||||||
|
|
||||||
|
const cssHeader = styled(docListHeader, `
|
||||||
|
display: inline-block;
|
||||||
|
`);
|
||||||
|
|
||||||
const cssList = styled('div', `
|
const cssList = styled('div', `
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
|
@ -6,6 +6,7 @@
|
|||||||
import {AccessRules} from 'app/client/aclui/AccessRules';
|
import {AccessRules} from 'app/client/aclui/AccessRules';
|
||||||
import {ActionLog} from 'app/client/components/ActionLog';
|
import {ActionLog} from 'app/client/components/ActionLog';
|
||||||
import BaseView from 'app/client/components/BaseView';
|
import BaseView from 'app/client/components/BaseView';
|
||||||
|
import {BehavioralPrompts} from 'app/client/components/BehavioralPrompts';
|
||||||
import {isNumericLike, isNumericOnly} from 'app/client/components/ChartView';
|
import {isNumericLike, isNumericOnly} from 'app/client/components/ChartView';
|
||||||
import {CodeEditorPanel} from 'app/client/components/CodeEditorPanel';
|
import {CodeEditorPanel} from 'app/client/components/CodeEditorPanel';
|
||||||
import * as commands from 'app/client/components/commands';
|
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 {IPageWidget, toPageWidget} from 'app/client/ui/PageWidgetPicker';
|
||||||
import {linkFromId, selectBy} from 'app/client/ui/selectBy';
|
import {linkFromId, selectBy} from 'app/client/ui/selectBy';
|
||||||
import {startWelcomeTour} from 'app/client/ui/welcomeTour';
|
import {startWelcomeTour} from 'app/client/ui/welcomeTour';
|
||||||
|
import {IWidgetType} from 'app/client/ui/widgetTypes';
|
||||||
import {isNarrowScreen, mediaSmall, testId} from 'app/client/ui2018/cssVars';
|
import {isNarrowScreen, mediaSmall, testId} from 'app/client/ui2018/cssVars';
|
||||||
import {IconName} from 'app/client/ui2018/IconList';
|
import {IconName} from 'app/client/ui2018/IconList';
|
||||||
import {invokePrompt} from 'app/client/ui2018/modals';
|
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.
|
// If the doc has a docTour. Used also to enable the UI button to restart the tour.
|
||||||
public readonly hasDocTour: Computed<boolean>;
|
public readonly hasDocTour: Computed<boolean>;
|
||||||
|
|
||||||
|
public readonly behavioralPrompts = BehavioralPrompts.create(this, this.docPageModel.appModel);
|
||||||
|
|
||||||
private _actionLog: ActionLog;
|
private _actionLog: ActionLog;
|
||||||
private _undoStack: UndoStack;
|
private _undoStack: UndoStack;
|
||||||
private _lastOwnActionGroup: ActionGroupWithCursorPos|null = null;
|
private _lastOwnActionGroup: ActionGroupWithCursorPos|null = null;
|
||||||
@ -601,6 +605,8 @@ export class GristDoc extends DisposableWithEvents {
|
|||||||
|
|
||||||
// The newly-added section should be given focus.
|
// The newly-added section should be given focus.
|
||||||
this.viewModel.activeSectionId(res.sectionRef);
|
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);
|
await this.openDocPage(result.viewRef);
|
||||||
// The newly-added section should be given focus.
|
// The newly-added section should be given focus.
|
||||||
this.viewModel.activeSectionId(result.sectionRef);
|
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() {
|
private async _promptForName() {
|
||||||
return await invokePrompt("Table name", "Create", '', "Default table name");
|
return await invokePrompt("Table name", "Create", '', "Default table name");
|
||||||
}
|
}
|
||||||
|
@ -42,6 +42,7 @@ export class RawDataPage extends Disposable {
|
|||||||
|
|
||||||
public buildDom() {
|
public buildDom() {
|
||||||
return cssContainer(
|
return cssContainer(
|
||||||
|
dom('div', this._gristDoc.behavioralPrompts.attachTip('rawDataPage', {hideArrow: true})),
|
||||||
dom('div',
|
dom('div',
|
||||||
dom.create(DataTables, this._gristDoc),
|
dom.create(DataTables, this._gristDoc),
|
||||||
dom.create(DocumentUsage, this._gristDoc.docPageModel),
|
dom.create(DocumentUsage, this._gristDoc.docPageModel),
|
||||||
|
@ -30,12 +30,10 @@
|
|||||||
|
|
||||||
.g_record_delete_field {
|
.g_record_delete_field {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: 0;
|
top: 2px;
|
||||||
right: 0;
|
right: 2px;
|
||||||
background-color: #404040;
|
|
||||||
border: 1px solid #404040;
|
|
||||||
border-radius: 1rem;
|
border-radius: 1rem;
|
||||||
color: white;
|
color: var(--grist-theme-control-secondary-fg, #404040);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
|
|
||||||
display: none;
|
display: none;
|
||||||
|
@ -125,7 +125,7 @@ RecordLayoutEditor.prototype.buildFinishButtons = function() {
|
|||||||
|
|
||||||
RecordLayoutEditor.prototype.buildLeafDom = function() {
|
RecordLayoutEditor.prototype.buildLeafDom = function() {
|
||||||
return dom('div.layout_grabbable.g_record_layout_editing',
|
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('mousedown', (ev) => ev.stopPropagation()),
|
||||||
dom.on('click', (ev, elem) => {
|
dom.on('click', (ev, elem) => {
|
||||||
ev.preventDefault();
|
ev.preventDefault();
|
||||||
|
@ -177,7 +177,8 @@ ViewConfigTab.prototype._buildLayoutDom = function() {
|
|||||||
dom.autoDispose(layoutEditorObs),
|
dom.autoDispose(layoutEditorObs),
|
||||||
dom.on('click', () => commands.allCommands.editLayout.run()),
|
dom.on('click', () => commands.allCommands.editLayout.run()),
|
||||||
grainjsDom.hide(layoutEditorObs),
|
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 {filterBar} from 'app/client/ui/FilterBar';
|
||||||
import {viewSectionMenu} from 'app/client/ui/ViewSectionMenu';
|
import {viewSectionMenu} from 'app/client/ui/ViewSectionMenu';
|
||||||
import {buildWidgetTitle} from 'app/client/ui/WidgetTitle';
|
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 {icon} from 'app/client/ui2018/icons';
|
||||||
import {DisposableWithEvents} from 'app/common/DisposableWithEvents';
|
import {DisposableWithEvents} from 'app/common/DisposableWithEvents';
|
||||||
import {mod} from 'app/common/gutil';
|
import {mod} from 'app/common/gutil';
|
||||||
@ -323,7 +323,7 @@ export function buildViewSectionDom(options: {
|
|||||||
dom.create(viewSectionMenu, gristDoc, vs)
|
dom.create(viewSectionMenu, gristDoc, vs)
|
||||||
)
|
)
|
||||||
)),
|
)),
|
||||||
dom.create(filterBar, vs),
|
dom.create(filterBar, gristDoc, vs),
|
||||||
dom.maybe<BaseView|null>(vs.viewInstance, (viewInstance) => [
|
dom.maybe<BaseView|null>(vs.viewInstance, (viewInstance) => [
|
||||||
dom('div.view_data_pane_container.flexvbox',
|
dom('div.view_data_pane_container.flexvbox',
|
||||||
cssResizing.cls('', isResizing),
|
cssResizing.cls('', isResizing),
|
||||||
|
@ -116,6 +116,11 @@ exports.groups = [{
|
|||||||
keys: [],
|
keys: [],
|
||||||
desc: 'Shortcut to open the left panel',
|
desc: 'Shortcut to open the left panel',
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
name: 'rightPanelOpen',
|
||||||
|
keys: [],
|
||||||
|
desc: 'Shortcut to open the right panel',
|
||||||
|
},
|
||||||
{
|
{
|
||||||
name: 'videoTourToolsOpen',
|
name: 'videoTourToolsOpen',
|
||||||
keys: [],
|
keys: [],
|
||||||
|
@ -2,11 +2,14 @@ import * as commands from 'app/client/components/commands';
|
|||||||
import {GristDoc} from 'app/client/components/GristDoc';
|
import {GristDoc} from 'app/client/components/GristDoc';
|
||||||
import {FocusLayer} from 'app/client/lib/FocusLayer';
|
import {FocusLayer} from 'app/client/lib/FocusLayer';
|
||||||
import {reportSuccess} from 'app/client/models/errors';
|
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 {labeledSquareCheckbox} from 'app/client/ui2018/checkbox';
|
||||||
import {testId, theme} from 'app/client/ui2018/cssVars';
|
import {testId, theme, vars} from 'app/client/ui2018/cssVars';
|
||||||
import {modalTooltip} from 'app/client/ui2018/modals';
|
import {icon} from 'app/client/ui2018/icons';
|
||||||
import {dom, DomContents, observable, styled} from 'grainjs';
|
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
|
* 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', `
|
const cssTheme = styled('div', `
|
||||||
color: ${theme.text};
|
color: ${theme.text};
|
||||||
`);
|
`);
|
||||||
@ -147,3 +307,86 @@ const cssContainer = styled(cssTheme, `
|
|||||||
const cssWideContainer = styled(cssTheme, `
|
const cssWideContainer = styled(cssTheme, `
|
||||||
max-width: 340px;
|
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 {GristLoadConfig} from 'app/common/gristUrls';
|
||||||
import {FullUser} from 'app/common/LoginSessionAPI';
|
import {FullUser} from 'app/common/LoginSessionAPI';
|
||||||
import {LocalPlugin} from 'app/common/plugin';
|
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 {isOwner} from 'app/common/roles';
|
||||||
import {getTagManagerScript} from 'app/common/tagManager';
|
import {getTagManagerScript} from 'app/common/tagManager';
|
||||||
import {getDefaultThemePrefs, Theme, ThemeAppearance, ThemeColors, ThemePrefs,
|
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 {getUserPrefObs, getUserPrefsObs} from 'app/client/models/UserPrefs';
|
||||||
import {bundleChanges, Computed, Disposable, Observable, subscribe} from 'grainjs';
|
import {bundleChanges, Computed, Disposable, Observable, subscribe} from 'grainjs';
|
||||||
|
|
||||||
const t = makeT('models.AppModel')
|
const t = makeT('models.AppModel');
|
||||||
|
|
||||||
// Reexported for convenience.
|
// Reexported for convenience.
|
||||||
export {reportError} from 'app/client/models/errors';
|
export {reportError} from 'app/client/models/errors';
|
||||||
@ -97,6 +98,8 @@ export interface AppModel {
|
|||||||
* Deprecation messages that user has seen.
|
* Deprecation messages that user has seen.
|
||||||
*/
|
*/
|
||||||
deprecatedWarnings: Observable<DeprecationWarning[]>;
|
deprecatedWarnings: Observable<DeprecationWarning[]>;
|
||||||
|
dismissedWelcomePopups: Observable<DismissedReminder[]>;
|
||||||
|
behavioralPrompts: Observable<BehavioralPromptPrefs>;
|
||||||
|
|
||||||
pageType: Observable<PageType>;
|
pageType: Observable<PageType>;
|
||||||
|
|
||||||
@ -108,6 +111,7 @@ export interface AppModel {
|
|||||||
showNewSiteModal(): void;
|
showNewSiteModal(): void;
|
||||||
isBillingManager(): boolean; // If user is a billing manager for this org
|
isBillingManager(): boolean; // If user is a billing manager for this org
|
||||||
isSupport(): boolean; // If user is a Support user
|
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 {
|
export class TopAppModelImpl extends Disposable implements TopAppModel {
|
||||||
@ -236,11 +240,14 @@ export class AppModelImpl extends Disposable implements AppModel {
|
|||||||
}) as Observable<ThemePrefs>;
|
}) as Observable<ThemePrefs>;
|
||||||
public readonly currentTheme = this._getCurrentThemeObs();
|
public readonly currentTheme = this._getCurrentThemeObs();
|
||||||
|
|
||||||
public readonly dismissedPopups =
|
public readonly dismissedPopups = getUserPrefObs(this.userPrefsObs, 'dismissedPopups',
|
||||||
getUserPrefObs(this.userPrefsObs, 'dismissedPopups', { defaultValue: [] }) as Observable<DismissedPopup[]>;
|
{ defaultValue: [] }) as Observable<DismissedPopup[]>;
|
||||||
public readonly deprecatedWarnings = getUserPrefObs(this.userPrefsObs, 'seenDeprecatedWarnings',
|
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.
|
// Get the current PageType from the URL.
|
||||||
public readonly pageType: Observable<PageType> = Computed.create(this, urlState().state,
|
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);
|
return Boolean(this.currentOrg?.billingAccount?.isManager);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public isOwner() {
|
||||||
|
return Boolean(this.currentOrg && isOwner(this.currentOrg));
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Fetch and update the current org's usage.
|
* Fetch and update the current org's usage.
|
||||||
*/
|
*/
|
||||||
public async refreshOrgUsage() {
|
public async refreshOrgUsage() {
|
||||||
const currentOrg = this.currentOrg;
|
if (!this.isOwner()) {
|
||||||
if (!isOwner(currentOrg)) {
|
|
||||||
// Note: getOrgUsageSummary already checks for owner access; we do an early return
|
// Note: getOrgUsageSummary already checks for owner access; we do an early return
|
||||||
// here to skip making unnecessary API calls.
|
// here to skip making unnecessary API calls.
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const usage = await this.api.getOrgUsageSummary(currentOrg.id);
|
const usage = await this.api.getOrgUsageSummary(this.currentOrg!.id);
|
||||||
if (!this.isDisposed()) {
|
if (!this.isDisposed()) {
|
||||||
this.currentOrgUsage.set(usage);
|
this.currentOrgUsage.set(usage);
|
||||||
}
|
}
|
||||||
|
@ -1,3 +1,4 @@
|
|||||||
|
import { GristDoc } from "app/client/components/GristDoc";
|
||||||
import { ColumnFilter } from "app/client/models/ColumnFilter";
|
import { ColumnFilter } from "app/client/models/ColumnFilter";
|
||||||
import { FilterInfo } from "app/client/models/entities/ViewSectionRec";
|
import { FilterInfo } from "app/client/models/entities/ViewSectionRec";
|
||||||
import { CellValue } from "app/plugin/GristData";
|
import { CellValue } from "app/plugin/GristData";
|
||||||
@ -29,6 +30,7 @@ interface ColumnFilterMenuModelParams {
|
|||||||
columnFilter: ColumnFilter;
|
columnFilter: ColumnFilter;
|
||||||
filterInfo: FilterInfo;
|
filterInfo: FilterInfo;
|
||||||
valueCount: Array<[CellValue, IFilterCount]>;
|
valueCount: Array<[CellValue, IFilterCount]>;
|
||||||
|
gristDoc: GristDoc;
|
||||||
limitShow?: number;
|
limitShow?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -37,6 +39,8 @@ export class ColumnFilterMenuModel extends Disposable {
|
|||||||
|
|
||||||
public readonly filterInfo = this._params.filterInfo;
|
public readonly filterInfo = this._params.filterInfo;
|
||||||
|
|
||||||
|
public readonly gristDoc = this._params.gristDoc;
|
||||||
|
|
||||||
public readonly initialPinned = this.filterInfo.isPinned.peek();
|
public readonly initialPinned = this.filterInfo.isPinned.peek();
|
||||||
|
|
||||||
public readonly limitShown = this._params.limitShow ?? MAXIMUM_SHOWN_FILTER_ITEMS;
|
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);
|
const selectBy = gristDoc.selectBy.bind(gristDoc);
|
||||||
return [
|
return [
|
||||||
menuItem(
|
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'}),
|
{isNewPage: true, buttonLabel: 'Add Page'}),
|
||||||
menuIcon("Page"), t("AddPage"), testId('dp-add-new-page'),
|
menuIcon("Page"), t("AddPage"), testId('dp-add-new-page'),
|
||||||
dom.cls('disabled', isReadonly)
|
dom.cls('disabled', isReadonly)
|
||||||
),
|
),
|
||||||
menuItem(
|
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}),
|
{isNewPage: false, selectBy}),
|
||||||
menuIcon("Widget"), t("AddWidgetToPage"), testId('dp-add-widget-to-page'),
|
menuIcon("Widget"), t("AddWidgetToPage"), testId('dp-add-widget-to-page'),
|
||||||
// disable for readonly doc and all special views
|
// 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)
|
// changes to their filters. (True indicates unsaved changes)
|
||||||
filterSpecChanged: Computed<boolean>;
|
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.
|
// Customizable version of the JSON-stringified sort spec. It may diverge from the saved one.
|
||||||
activeSortJson: modelUtil.CustomComputed<string>;
|
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));
|
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.
|
// Save all filters of fields/columns in the section.
|
||||||
this.saveFilters = () => {
|
this.saveFilters = () => {
|
||||||
return docModel.docData.bundleActions(`Save all filters in ${this.titleDef()}`,
|
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.
|
* but on Cancel the model is reset to its initial state prior to menu closing.
|
||||||
*/
|
*/
|
||||||
import * as commands from 'app/client/components/commands';
|
import * as commands from 'app/client/components/commands';
|
||||||
|
import {GristDoc} from 'app/client/components/GristDoc';
|
||||||
import {makeT} from 'app/client/lib/localization';
|
import {makeT} from 'app/client/lib/localization';
|
||||||
import {ColumnFilter, NEW_FILTER_JSON} from 'app/client/models/ColumnFilter';
|
import {ColumnFilter, NEW_FILTER_JSON} from 'app/client/models/ColumnFilter';
|
||||||
import {ColumnFilterMenuModel, IFilterCount} from 'app/client/models/ColumnFilterMenuModel';
|
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 {
|
export function columnFilterMenu(owner: IDisposableOwner, opts: IFilterMenuOptions): HTMLElement {
|
||||||
const { model, doCancel, doSave, onClose, renderValue, valueParser, showAllFiltersButton } = opts;
|
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() || '');
|
const valueFormatter = opts.valueFormatter || ((val) => val?.toString() || '');
|
||||||
|
|
||||||
// Map to keep track of displayed checkboxes
|
// Map to keep track of displayed checkboxes
|
||||||
@ -351,6 +352,11 @@ export function columnFilterMenu(owner: IDisposableOwner, opts: IFilterMenuOptio
|
|||||||
icon('PinTilted'),
|
icon('PinTilted'),
|
||||||
cssPinButton.cls('-pinned', model.filterInfo.isPinned),
|
cssPinButton.cls('-pinned', model.filterInfo.isPinned),
|
||||||
dom.on('click', () => filterInfo.pinned(!filterInfo.pinned())),
|
dom.on('click', () => filterInfo.pinned(!filterInfo.pinned())),
|
||||||
|
gristDoc.behavioralPrompts.attachTip('filterButtons', {
|
||||||
|
popupOptions: {
|
||||||
|
attach: null,
|
||||||
|
}
|
||||||
|
}),
|
||||||
testId('pin-btn'),
|
testId('pin-btn'),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@ -604,27 +610,38 @@ function getEmptyCountMap(fieldOrColumn: ViewFieldRec|ColumnRec): Map<CellValue,
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface IColumnFilterMenuOptions {
|
export interface IColumnFilterMenuOptions {
|
||||||
// Callback for when the filter menu is closed.
|
/** If true, shows a button that opens the sort & filter widget menu. */
|
||||||
onClose?: () => void;
|
|
||||||
// If true, shows a button that opens the sort & filter widget menu.
|
|
||||||
showAllFiltersButton?: boolean;
|
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().
|
* Returns content for the newly created columnFilterMenu; for use with setPopupToCreateDom().
|
||||||
*/
|
*/
|
||||||
export function createFilterMenu(
|
export function createFilterMenu(params: ICreateFilterMenuParams) {
|
||||||
openCtl: IOpenController,
|
const {
|
||||||
sectionFilter: SectionFilter,
|
openCtl,
|
||||||
filterInfo: FilterInfo,
|
sectionFilter,
|
||||||
rowSource: RowSource,
|
filterInfo,
|
||||||
tableData: TableData,
|
rowSource,
|
||||||
options: IColumnFilterMenuOptions = {}
|
tableData,
|
||||||
) {
|
gristDoc,
|
||||||
const {onClose = noop, showAllFiltersButton} = options;
|
showAllFiltersButton,
|
||||||
|
onClose = noop
|
||||||
|
} = params;
|
||||||
|
|
||||||
// Go through all of our shown and hidden rows, and count them up by the values in this column.
|
// 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 columnType = fieldOrColumn.origCol.peek().type.peek();
|
||||||
const visibleColumnType = fieldOrColumn.visibleColModel.peek()?.type.peek() || columnType;
|
const visibleColumnType = fieldOrColumn.visibleColModel.peek()?.type.peek() || columnType;
|
||||||
const {keyMapFunc, labelMapFunc, valueMapFunc} = getMapFuncs(columnType, tableData, fieldOrColumn);
|
const {keyMapFunc, labelMapFunc, valueMapFunc} = getMapFuncs(columnType, tableData, fieldOrColumn);
|
||||||
@ -668,6 +685,7 @@ export function createFilterMenu(
|
|||||||
columnFilter,
|
columnFilter,
|
||||||
filterInfo,
|
filterInfo,
|
||||||
valueCount: valueCountsArr,
|
valueCount: valueCountsArr,
|
||||||
|
gristDoc,
|
||||||
});
|
});
|
||||||
|
|
||||||
return columnFilterMenu(openCtl, {
|
return columnFilterMenu(openCtl, {
|
||||||
@ -676,21 +694,31 @@ export function createFilterMenu(
|
|||||||
onClose: () => { openCtl.close(); onClose(); },
|
onClose: () => { openCtl.close(); onClose(); },
|
||||||
doSave: (reset: boolean = false) => {
|
doSave: (reset: boolean = false) => {
|
||||||
const spec = columnFilter.makeFilterJson();
|
const spec = columnFilter.makeFilterJson();
|
||||||
sectionFilter.viewSection.setFilter(
|
const {viewSection} = sectionFilter;
|
||||||
|
viewSection.setFilter(
|
||||||
fieldOrColumn.origCol().origColRef(),
|
fieldOrColumn.origCol().origColRef(),
|
||||||
{filter: spec}
|
{filter: spec}
|
||||||
);
|
);
|
||||||
if (reset) {
|
if (reset) {
|
||||||
sectionFilter.resetTemporaryRows();
|
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: () => {
|
doCancel: () => {
|
||||||
|
const {viewSection} = sectionFilter;
|
||||||
if (columnFilter.initialFilterJson === NEW_FILTER_JSON) {
|
if (columnFilter.initialFilterJson === NEW_FILTER_JSON) {
|
||||||
sectionFilter.viewSection.revertFilter(fieldOrColumn.origCol().origColRef());
|
viewSection.revertFilter(fieldOrColumn.origCol().origColRef());
|
||||||
} else {
|
} else {
|
||||||
const initialFilter = columnFilter.initialFilterJson;
|
const initialFilter = columnFilter.initialFilterJson;
|
||||||
columnFilter.setState(initialFilter);
|
columnFilter.setState(initialFilter);
|
||||||
sectionFilter.viewSection.setFilter(
|
viewSection.setFilter(
|
||||||
fieldOrColumn.origCol().origColRef(),
|
fieldOrColumn.origCol().origColRef(),
|
||||||
{filter: initialFilter, pinned: model.initialPinned}
|
{filter: initialFilter, pinned: model.initialPinned}
|
||||||
);
|
);
|
||||||
|
@ -4,7 +4,7 @@
|
|||||||
* Orgs, workspaces and docs are fetched asynchronously on build via the passed in API.
|
* Orgs, workspaces and docs are fetched asynchronously on build via the passed in API.
|
||||||
*/
|
*/
|
||||||
import {loadUserManager} from 'app/client/lib/imports';
|
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 {docUrl, urlState} from 'app/client/models/gristUrlState';
|
||||||
import {getTimeFromNow, HomeModel, makeLocalViewSettings, ViewSettings} from 'app/client/models/HomeModel';
|
import {getTimeFromNow, HomeModel, makeLocalViewSettings, ViewSettings} from 'app/client/models/HomeModel';
|
||||||
import {getWorkspaceInfo, workspaceName} from 'app/client/models/WorkspaceInfo';
|
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 {buildPinnedDoc, createPinnedDocs} from 'app/client/ui/PinnedDocs';
|
||||||
import {shadowScroll} from 'app/client/ui/shadowScroll';
|
import {shadowScroll} from 'app/client/ui/shadowScroll';
|
||||||
import {transition} from 'app/client/ui/transitions';
|
import {transition} from 'app/client/ui/transitions';
|
||||||
|
import {showWelcomeCoachingCall} from 'app/client/ui/WelcomeCoachingCall';
|
||||||
import {showWelcomeQuestions} from 'app/client/ui/WelcomeQuestions';
|
import {showWelcomeQuestions} from 'app/client/ui/WelcomeQuestions';
|
||||||
import {createVideoTourTextButton} from 'app/client/ui/OpenVideoTour';
|
import {createVideoTourTextButton} from 'app/client/ui/OpenVideoTour';
|
||||||
import {buttonSelect, cssButtonSelect} from 'app/client/ui2018/buttonSelect';
|
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 {SortPref, ViewPref} from 'app/common/Prefs';
|
||||||
import * as roles from 'app/common/roles';
|
import * as roles from 'app/common/roles';
|
||||||
import {Document, Workspace} from 'app/common/UserAPI';
|
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';
|
makeTestId, observable, Observable} from 'grainjs';
|
||||||
import {buildTemplateDocs} from 'app/client/ui/TemplateDocs';
|
import {buildTemplateDocs} from 'app/client/ui/TemplateDocs';
|
||||||
import {makeT} from 'app/client/lib/localization';
|
import {makeT} from 'app/client/lib/localization';
|
||||||
@ -44,20 +45,30 @@ const testId = makeTestId('test-dm-');
|
|||||||
* Usage:
|
* Usage:
|
||||||
* dom('div', createDocMenu(homeModel))
|
* dom('div', createDocMenu(homeModel))
|
||||||
*/
|
*/
|
||||||
export function createDocMenu(home: HomeModel) {
|
export function createDocMenu(home: HomeModel): DomElementArg[] {
|
||||||
return dom.domComputed(home.loading, loading => (
|
return [
|
||||||
|
attachWelcomePopups(home.app),
|
||||||
|
dom.domComputed(home.loading, loading => (
|
||||||
loading === 'slow' ? css.spinner(loadingSpinner()) :
|
loading === 'slow' ? css.spinner(loadingSpinner()) :
|
||||||
loading ? null :
|
loading ? null :
|
||||||
dom.create(createLoadedDocMenu, home)
|
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) {
|
function createLoadedDocMenu(owner: IDisposableOwner, home: HomeModel) {
|
||||||
const flashDocId = observable<string|null>(null);
|
const flashDocId = observable<string|null>(null);
|
||||||
const upgradeButton = buildUpgradeButton(owner, home.app);
|
const upgradeButton = buildUpgradeButton(owner, home.app);
|
||||||
return css.docList(
|
return css.docList(
|
||||||
showWelcomeQuestions(home.app.userPrefsObs),
|
|
||||||
css.docMenu(
|
css.docMenu(
|
||||||
dom.maybe(!home.app.currentFeatures.workspaces, () => [
|
dom.maybe(!home.app.currentFeatures.workspaces, () => [
|
||||||
css.docListHeader(t('ServiceNotAvailable')),
|
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 { NEW_FILTER_JSON } from "app/client/models/ColumnFilter";
|
||||||
import { ColumnRec, ViewSectionRec } from "app/client/models/DocModel";
|
import { ColumnRec, ViewSectionRec } from "app/client/models/DocModel";
|
||||||
import { FilterInfo } from "app/client/models/entities/ViewSectionRec";
|
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 { dom, IDisposableOwner, IDomArgs, styled } from "grainjs";
|
||||||
import { IMenuOptions, PopupControl } from "popweasel";
|
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>();
|
const popupControls = new WeakMap<ColumnRec, PopupControl>();
|
||||||
return cssFilterBar(
|
return cssFilterBar(
|
||||||
testId('filter-bar'),
|
testId('filter-bar'),
|
||||||
dom.forEach(viewSection.activeFilters, (filterInfo) => makeFilterField(filterInfo, popupControls)),
|
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),
|
makePlusButton(viewSection, popupControls),
|
||||||
cssFilterBar.cls('-hidden', use => use(viewSection.pinnedActiveFilters).length === 0),
|
cssFilterBar.cls('-hidden', use => use(viewSection.pinnedActiveFilters).length === 0),
|
||||||
);
|
);
|
||||||
|
@ -1,6 +1,8 @@
|
|||||||
import {cssLink} from 'app/client/ui2018/links';
|
import {cssLink} from 'app/client/ui2018/links';
|
||||||
import {commonUrls} from 'app/common/gristUrls';
|
import {commonUrls} from 'app/common/gristUrls';
|
||||||
|
import {BehavioralPrompt} from 'app/common/Prefs';
|
||||||
import {dom, DomContents, DomElementArg, styled} from 'grainjs';
|
import {dom, DomContents, DomElementArg, styled} from 'grainjs';
|
||||||
|
import { icon } from '../ui2018/icons';
|
||||||
|
|
||||||
const cssTooltipContent = styled('div', `
|
const cssTooltipContent = styled('div', `
|
||||||
display: flex;
|
display: flex;
|
||||||
@ -8,7 +10,20 @@ const cssTooltipContent = styled('div', `
|
|||||||
row-gap: 8px;
|
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'
|
| 'dataSize'
|
||||||
| 'setTriggerFormula'
|
| 'setTriggerFormula'
|
||||||
| 'selectBy'
|
| 'selectBy'
|
||||||
@ -19,7 +34,8 @@ type TooltipName =
|
|||||||
|
|
||||||
export type TooltipContentFunc = (...domArgs: DomElementArg[]) => DomContents;
|
export type TooltipContentFunc = (...domArgs: DomElementArg[]) => DomContents;
|
||||||
|
|
||||||
export const GristTooltips: Record<TooltipName, TooltipContentFunc> = {
|
// TODO: i18n
|
||||||
|
export const GristTooltips: Record<Tooltip, TooltipContentFunc> = {
|
||||||
dataSize: (...args: DomElementArg[]) => cssTooltipContent(
|
dataSize: (...args: DomElementArg[]) => cssTooltipContent(
|
||||||
dom('div', 'The total size of all data in this document, excluding attachments.'),
|
dom('div', 'The total size of all data in this document, excluding attachments.'),
|
||||||
dom('div', 'Updates every 5 minutes.'),
|
dom('div', 'Updates every 5 minutes.'),
|
||||||
@ -80,3 +96,96 @@ export const GristTooltips: Record<TooltipName, TooltipContentFunc> = {
|
|||||||
...args,
|
...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 {cssLinkText, cssPageEntryMain, cssPageIcon, cssPageLink} from 'app/client/ui/LeftPanelCommon';
|
||||||
import {theme} from 'app/client/ui2018/cssVars';
|
import {theme} from 'app/client/ui2018/cssVars';
|
||||||
import {icon} from 'app/client/ui2018/icons';
|
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 {commonUrls, shouldHideUiElement} from 'app/common/gristUrls';
|
||||||
import {dom, makeTestId, styled} from 'grainjs';
|
import {dom, makeTestId, styled} from 'grainjs';
|
||||||
|
|
||||||
@ -19,7 +19,7 @@ const testId = makeTestId('test-video-tour-');
|
|||||||
(ctl) => {
|
(ctl) => {
|
||||||
return [
|
return [
|
||||||
cssModal.cls(''),
|
cssModal.cls(''),
|
||||||
cssCloseButton(
|
cssModalCloseButton(
|
||||||
cssCloseIcon('CrossBig'),
|
cssCloseIcon('CrossBig'),
|
||||||
dom.on('click', () => ctl.close()),
|
dom.on('click', () => ctl.close()),
|
||||||
testId('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, `
|
const cssCloseIcon = styled(icon, `
|
||||||
padding: 12px;
|
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 {cssHideForNarrowScreen, isScreenResizing, mediaNotSmall, mediaSmall, theme} from 'app/client/ui2018/cssVars';
|
||||||
import {isNarrowScreenObs} from 'app/client/ui2018/cssVars';
|
import {isNarrowScreenObs} from 'app/client/ui2018/cssVars';
|
||||||
import {icon} from 'app/client/ui2018/icons';
|
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 noop from 'lodash/noop';
|
||||||
import once from 'lodash/once';
|
import once from 'lodash/once';
|
||||||
import {SessionObs} from 'app/client/lib/sessionObs';
|
import {SessionObs} from 'app/client/lib/sessionObs';
|
||||||
@ -26,21 +26,21 @@ export interface PageSidePanel {
|
|||||||
panelWidth: Observable<number>;
|
panelWidth: Observable<number>;
|
||||||
panelOpen: Observable<boolean>;
|
panelOpen: Observable<boolean>;
|
||||||
hideOpener?: boolean; // If true, don't show the opener handle.
|
hideOpener?: boolean; // If true, don't show the opener handle.
|
||||||
header: DomArg;
|
header: DomElementArg;
|
||||||
content: DomArg;
|
content: DomElementArg;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface PageContents {
|
export interface PageContents {
|
||||||
leftPanel: PageSidePanel;
|
leftPanel: PageSidePanel;
|
||||||
rightPanel?: PageSidePanel; // If omitted, the right panel isn't shown at all.
|
rightPanel?: PageSidePanel; // If omitted, the right panel isn't shown at all.
|
||||||
|
|
||||||
headerMain: DomArg;
|
headerMain: DomElementArg;
|
||||||
contentMain: DomArg;
|
contentMain: DomElementArg;
|
||||||
|
|
||||||
onResize?: () => void; // Callback for when either pane is opened, closed, or resized.
|
onResize?: () => void; // Callback for when either pane is opened, closed, or resized.
|
||||||
testId?: TestId;
|
testId?: TestId;
|
||||||
contentTop?: DomArg;
|
contentTop?: DomElementArg;
|
||||||
contentBottom?: DomArg;
|
contentBottom?: DomElementArg;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function pagePanels(page: PageContents) {
|
export function pagePanels(page: PageContents) {
|
||||||
@ -55,6 +55,7 @@ export function pagePanels(page: PageContents) {
|
|||||||
let lastLeftOpen = left.panelOpen.get();
|
let lastLeftOpen = left.panelOpen.get();
|
||||||
let lastRightOpen = right?.panelOpen.get() || false;
|
let lastRightOpen = right?.panelOpen.get() || false;
|
||||||
let leftPaneDom: HTMLElement;
|
let leftPaneDom: HTMLElement;
|
||||||
|
let rightPaneDom: HTMLElement;
|
||||||
let onLeftTransitionFinish = noop;
|
let onLeftTransitionFinish = noop;
|
||||||
|
|
||||||
// When switching to mobile mode, close panels; when switching to desktop, restore the
|
// 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));
|
watcher.onDispose(() => resolve(undefined));
|
||||||
left.panelOpen.set(true);
|
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);
|
}, null, true);
|
||||||
let contentWrapper: HTMLElement;
|
let contentWrapper: HTMLElement;
|
||||||
return cssPageContainer(
|
return cssPageContainer(
|
||||||
@ -262,7 +273,7 @@ export function pagePanels(page: PageContents) {
|
|||||||
dom.show(right.panelOpen),
|
dom.show(right.panelOpen),
|
||||||
cssHideForNarrowScreen.cls('')),
|
cssHideForNarrowScreen.cls('')),
|
||||||
|
|
||||||
cssRightPane(
|
rightPaneDom = cssRightPane(
|
||||||
testId('right-panel'),
|
testId('right-panel'),
|
||||||
cssRightPaneHeader(right.header),
|
cssRightPaneHeader(right.header),
|
||||||
right.content,
|
right.content,
|
||||||
|
@ -1,6 +1,8 @@
|
|||||||
|
import { BehavioralPrompts } from 'app/client/components/BehavioralPrompts';
|
||||||
|
import { GristDoc } from 'app/client/components/GristDoc';
|
||||||
import { makeT } from 'app/client/lib/localization';
|
import { makeT } from 'app/client/lib/localization';
|
||||||
import { reportError } from 'app/client/models/AppModel';
|
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 { GristTooltips } from 'app/client/ui/GristTooltips';
|
||||||
import { linkId, NoLink } from 'app/client/ui/selectBy';
|
import { linkId, NoLink } from 'app/client/ui/selectBy';
|
||||||
import { withInfoTooltip } from 'app/client/ui/tooltips';
|
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 { icon } from "app/client/ui2018/icons";
|
||||||
import { spinnerModal } from 'app/client/ui2018/modals';
|
import { spinnerModal } from 'app/client/ui2018/modals';
|
||||||
import { isLongerThan, nativeCompare } from "app/common/gutil";
|
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 { makeTestId, Observable, onKeyDown, styled} from "grainjs";
|
||||||
import without = require('lodash/without');
|
import without = require('lodash/without');
|
||||||
import Popper from 'popper.js';
|
import Popper from 'popper.js';
|
||||||
@ -99,7 +101,7 @@ export type ISaveFunc = (val: IPageWidget) => Promise<any>;
|
|||||||
const DELAY_BEFORE_SPINNER_MS = 500;
|
const DELAY_BEFORE_SPINNER_MS = 500;
|
||||||
|
|
||||||
// Attaches the page widget picker to elem to open on 'click' on the left.
|
// 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 = {}) {
|
options: IOptions = {}) {
|
||||||
// Overrides .placement, this is needed to enable the page widget to update position when user
|
// Overrides .placement, this is needed to enable the page widget to update position when user
|
||||||
// expand the `Group By` panel.
|
// 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
|
// particular listening to value.summarize to update popup position could be done directly in
|
||||||
// code).
|
// code).
|
||||||
options.placement = 'left';
|
options.placement = 'left';
|
||||||
const domCreator = (ctl: IOpenController) => buildPageWidgetPicker(ctl, docModel, onSave, options);
|
const domCreator = (ctl: IOpenController) => buildPageWidgetPicker(ctl, gristDoc, onSave, options);
|
||||||
setPopupToCreateDom(elem, domCreator, {
|
setPopupToCreateDom(elem, domCreator, {
|
||||||
placement: 'left',
|
placement: 'left',
|
||||||
trigger: ['click'],
|
trigger: ['click'],
|
||||||
@ -118,10 +120,10 @@ export function attachPageWidgetPicker(elem: HTMLElement, docModel: DocModel, on
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Open page widget widget picker on the right of element.
|
// 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 = {}) {
|
options: IOptions = {}) {
|
||||||
popupOpen(elem, (ctl) => buildPageWidgetPicker(
|
popupOpen(elem, (ctl) => buildPageWidgetPicker(
|
||||||
ctl, docModel, onSave, options
|
ctl, gristDoc, onSave, options
|
||||||
), { placement: 'right' });
|
), { placement: 'right' });
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -132,10 +134,11 @@ export function openPageWidgetPicker(elem: HTMLElement, docModel: DocModel, onSa
|
|||||||
// first time). When saving is taking time, show a modal spinner (see DELAY_BEFORE_SPINNER_MS).
|
// first time). When saving is taking time, show a modal spinner (see DELAY_BEFORE_SPINNER_MS).
|
||||||
export function buildPageWidgetPicker(
|
export function buildPageWidgetPicker(
|
||||||
ctl: IOpenController,
|
ctl: IOpenController,
|
||||||
docModel: DocModel,
|
gristDoc: GristDoc,
|
||||||
onSave: ISaveFunc,
|
onSave: ISaveFunc,
|
||||||
options: IOptions = {}) {
|
options: IOptions = {}
|
||||||
|
) {
|
||||||
|
const {behavioralPrompts, docModel} = gristDoc;
|
||||||
const tables = fromKo(docModel.visibleTables.getObservable());
|
const tables = fromKo(docModel.visibleTables.getObservable());
|
||||||
const columns = fromKo(docModel.columns.createAllRowsModel('parentPos').getObservable());
|
const columns = fromKo(docModel.columns.createAllRowsModel('parentPos').getObservable());
|
||||||
|
|
||||||
@ -204,7 +207,7 @@ export function buildPageWidgetPicker(
|
|||||||
|
|
||||||
// dom
|
// dom
|
||||||
return cssPopupWrapper(
|
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
|
// gives focus and binds keydown events
|
||||||
(elem: any) => { setTimeout(() => elem.focus(), 0); },
|
(elem: any) => { setTimeout(() => elem.focus(), 0); },
|
||||||
@ -223,7 +226,6 @@ export type IWidgetValueObs = {
|
|||||||
|
|
||||||
|
|
||||||
export interface ISelectOptions {
|
export interface ISelectOptions {
|
||||||
|
|
||||||
// the button's label
|
// the button's label
|
||||||
buttonLabel?: string;
|
buttonLabel?: string;
|
||||||
|
|
||||||
@ -274,6 +276,7 @@ export class PageWidgetSelect extends Disposable {
|
|||||||
private _tables: Observable<TableRec[]>,
|
private _tables: Observable<TableRec[]>,
|
||||||
private _columns: Observable<ColumnRec[]>,
|
private _columns: Observable<ColumnRec[]>,
|
||||||
private _onSave: () => Promise<void>,
|
private _onSave: () => Promise<void>,
|
||||||
|
private _behavioralPrompts: BehavioralPrompts,
|
||||||
private _options: ISelectOptions = {}
|
private _options: ISelectOptions = {}
|
||||||
) { super(); }
|
) { super(); }
|
||||||
|
|
||||||
@ -304,9 +307,15 @@ export class PageWidgetSelect extends Disposable {
|
|||||||
cssIcon('TypeTable'), 'New Table',
|
cssIcon('TypeTable'), 'New Table',
|
||||||
// prevent the selection of 'New Table' if it is disabled
|
// prevent the selection of 'New Table' if it is disabled
|
||||||
dom.on('click', (ev) => !this._isNewTableDisabled.get() && this._selectTable('New Table')),
|
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('-selected', (use) => use(this._value.table) === 'New Table'),
|
||||||
cssEntry.cls('-disabled', this._isNewTableDisabled),
|
cssEntry.cls('-disabled', this._isNewTableDisabled),
|
||||||
testId('table')
|
testId('table'),
|
||||||
),
|
),
|
||||||
dom.forEach(this._tables, (table) => dom('div',
|
dom.forEach(this._tables, (table) => dom('div',
|
||||||
cssEntryWrapper(
|
cssEntryWrapper(
|
||||||
@ -355,7 +364,14 @@ export class PageWidgetSelect extends Disposable {
|
|||||||
testId('selectby'))
|
testId('selectby'))
|
||||||
),
|
),
|
||||||
GristTooltips.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'}),
|
dom('div', {style: 'flex-grow: 1'}),
|
||||||
@ -427,8 +443,8 @@ export class PageWidgetSelect extends Disposable {
|
|||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function header(label: string) {
|
function header(label: string, ...args: DomElementArg[]) {
|
||||||
return cssHeader(dom('h4', label), testId('heading'));
|
return cssHeader(dom('h4', label), ...args, testId('heading'));
|
||||||
}
|
}
|
||||||
|
|
||||||
const cssContainer = styled('div', `
|
const cssContainer = styled('div', `
|
||||||
|
@ -537,7 +537,7 @@ export class RightPanel extends Disposable {
|
|||||||
const gristDoc = this._gristDoc;
|
const gristDoc = this._gristDoc;
|
||||||
const section = gristDoc.viewModel.activeSection;
|
const section = gristDoc.viewModel.activeSection;
|
||||||
const onSave = (val: IPageWidget) => gristDoc.saveViewSection(section.peek(), val);
|
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'),
|
buttonLabel: t('Save'),
|
||||||
value: () => toPageWidget(section.peek()),
|
value: () => toPageWidget(section.peek()),
|
||||||
selectBy: (val) => gristDoc.selectBy(val),
|
selectBy: (val) => gristDoc.selectBy(val),
|
||||||
|
5
app/client/ui/WelcomeCoachingCallStub.ts
Normal file
5
app/client/ui/WelcomeCoachingCallStub.ts
Normal file
@ -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');
|
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)) {
|
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 selection = choices.map(c => Observable.create(owner, false));
|
||||||
const otherText = Observable.create(owner, '');
|
const otherText = Observable.create(owner, '');
|
||||||
const showQuestions = getUserPrefObs(userPrefsObs, 'showNewUserQuestions');
|
const showQuestions = getUserPrefObs(userPrefsObs, 'showNewUserQuestions');
|
||||||
@ -54,6 +60,8 @@ export function showWelcomeQuestions(userPrefsObs: Observable<UserPrefs>) {
|
|||||||
modalArgs: cssModalCentered.cls(''),
|
modalArgs: cssModalCentered.cls(''),
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
const choices: Array<{icon: IconName, color: string, textKey: string}> = [
|
const choices: Array<{icon: IconName, color: string, textKey: string}> = [
|
||||||
|
@ -228,6 +228,8 @@ export const theme = {
|
|||||||
'black'),
|
'black'),
|
||||||
tooltipCloseButtonHoverBg: new CustomProp('theme-tooltip-close-button-hover-bg', undefined,
|
tooltipCloseButtonHoverBg: new CustomProp('theme-tooltip-close-button-hover-bg', undefined,
|
||||||
'white'),
|
'white'),
|
||||||
|
tooltipPopupHeaderFg: new CustomProp('theme-tooltip-popup-header-fg', undefined, colors.light),
|
||||||
|
tooltipPopupHeaderBg: new CustomProp('theme-tooltip-popup-header-bg', undefined, colors.lightGreen),
|
||||||
|
|
||||||
/* Modals */
|
/* Modals */
|
||||||
modalBg: new CustomProp('theme-modal-bg', undefined, 'white'),
|
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
|
// Add grist-floating-menu class to support existing browser tests
|
||||||
const defaults = { menuCssClass: menuCssClass + ' grist-floating-menu' };
|
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
|
* 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.
|
* 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:"});
|
* 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
|
* 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.
|
* forms, and that formSelect should be used for this purpose.
|
||||||
*/
|
*/
|
||||||
export function select<T>(obs: Observable<T>, optionArray: MaybeObsArray<IOption<T>>,
|
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 _menu = cssSelectMenuElem(testId('select-menu'));
|
||||||
const _btn = cssSelectBtn(testId('select-open'));
|
const _btn = cssSelectBtn(testId('select-open'));
|
||||||
|
|
||||||
const {menuCssClass: menuClass, ...otherOptions} = options;
|
const {menuCssClass: menuClass, ...otherOptions} = weaselOptions;
|
||||||
const selectOptions = {
|
const selectOptions = {
|
||||||
buttonArrow: cssInlineCollapseIcon('Collapse'),
|
buttonArrow: cssInlineCollapseIcon('Collapse'),
|
||||||
menuCssClass: _menu.className + ' ' + (menuClass || ''),
|
menuCssClass: _menu.className + ' ' + (menuClass || ''),
|
||||||
@ -141,6 +151,7 @@ export function select<T>(obs: Observable<T>, optionArray: MaybeObsArray<IOption
|
|||||||
cssOptionRow(
|
cssOptionRow(
|
||||||
op.icon ? cssOptionRowIcon(op.icon) : null,
|
op.icon ? cssOptionRowIcon(op.icon) : null,
|
||||||
cssOptionLabel(op.label),
|
cssOptionLabel(op.label),
|
||||||
|
renderOptionArgs ? renderOptionArgs(op) : null,
|
||||||
testId('select-row')
|
testId('select-row')
|
||||||
)
|
)
|
||||||
) as HTMLElement; // TODO: should be changed in weasel
|
) 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 {mediaSmall, testId, theme, vars} from 'app/client/ui2018/cssVars';
|
||||||
import {loadingSpinner} from 'app/client/ui2018/loaders';
|
import {loadingSpinner} from 'app/client/ui2018/loaders';
|
||||||
import {waitGrainObs} from 'app/common/gutil';
|
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,
|
import {Computed, Disposable, dom, DomContents, DomElementArg, input, keyframes,
|
||||||
MultiHolder, Observable, styled} from 'grainjs';
|
MultiHolder, Observable, styled} from 'grainjs';
|
||||||
import {cssMenuElem} from 'app/client/ui2018/menus';
|
import {cssMenuElem} from 'app/client/ui2018/menus';
|
||||||
@ -483,7 +483,7 @@ export function cssModalWidth(style: ModalWidth) {
|
|||||||
*/
|
*/
|
||||||
export function modalTooltip(
|
export function modalTooltip(
|
||||||
reference: Element,
|
reference: Element,
|
||||||
domCreator: IPopupDomCreator,
|
domCreator: (ctl: IOpenController) => DomElementArg,
|
||||||
options: IPopupOptions = {}
|
options: IPopupOptions = {}
|
||||||
): PopupControl {
|
): PopupControl {
|
||||||
return popupOpen(reference, (ctl: IOpenController) => {
|
return popupOpen(reference, (ctl: IOpenController) => {
|
||||||
@ -496,7 +496,7 @@ export function modalTooltip(
|
|||||||
|
|
||||||
/* CSS styled components */
|
/* CSS styled components */
|
||||||
|
|
||||||
const cssModalTooltip = styled(cssMenuElem, `
|
export const cssModalTooltip = styled(cssMenuElem, `
|
||||||
padding: 16px 24px;
|
padding: 16px 24px;
|
||||||
background: ${theme.modalBg};
|
background: ${theme.modalBg};
|
||||||
border-radius: 3px;
|
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(`
|
const cssFadeIn = keyframes(`
|
||||||
from {background-color: transparent}
|
from {background-color: transparent}
|
||||||
`);
|
`);
|
||||||
|
118
app/client/ui2018/popups.ts
Normal file
118
app/client/ui2018/popups.ts
Normal file
@ -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 _docModel: DocModel;
|
||||||
private readonly _readonly: Computed<boolean>;
|
private readonly _readonly: Computed<boolean>;
|
||||||
private readonly _comments: ko.Computed<boolean>;
|
private readonly _comments: ko.Computed<boolean>;
|
||||||
|
private readonly _showRefConfigPopup: ko.Observable<boolean>;
|
||||||
|
|
||||||
public constructor(public readonly gristDoc: GristDoc, public readonly field: ViewFieldRec,
|
public constructor(public readonly gristDoc: GristDoc, public readonly field: ViewFieldRec,
|
||||||
private _cursor: Cursor, private _options: { isPreview?: boolean } = {}) {
|
private _cursor: Cursor, private _options: { isPreview?: boolean } = {}) {
|
||||||
@ -202,6 +203,8 @@ export class FieldBuilder extends Disposable {
|
|||||||
}, this).extend({ deferred: true }));
|
}, this).extend({ deferred: true }));
|
||||||
|
|
||||||
this.diffImpl = this.autoDispose(DiffBox.create(this.field));
|
this.diffImpl = this.autoDispose(DiffBox.create(this.field));
|
||||||
|
|
||||||
|
this._showRefConfigPopup = ko.observable(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
public buildSelectWidgetDom() {
|
public buildSelectWidgetDom() {
|
||||||
@ -261,6 +264,9 @@ export class FieldBuilder extends Disposable {
|
|||||||
selectType.onWrite(newType => {
|
selectType.onWrite(newType => {
|
||||||
const sameType = newType === this._readOnlyPureType.peek();
|
const sameType = newType === this._readOnlyPureType.peek();
|
||||||
if (!sameType || commonType.get() === 'mixed') {
|
if (!sameType || commonType.get() === 'mixed') {
|
||||||
|
if (['Ref', 'RefList'].includes(newType)) {
|
||||||
|
this._showRefConfigPopup(true);
|
||||||
|
}
|
||||||
return this._setType(newType);
|
return this._setType(newType);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@ -280,7 +286,24 @@ export class FieldBuilder extends Disposable {
|
|||||||
// If we are waiting for a server response
|
// If we are waiting for a server response
|
||||||
use(this.isCallPending),
|
use(this.isCallPending),
|
||||||
menuCssClass: cssTypeSelectMenu.className,
|
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'),
|
testId('type-select'),
|
||||||
grainjsDom.cls('tour-type-selector'),
|
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 use(this.origColumn.disableModifyBase) || use(this.field.config.multiselect);
|
||||||
});
|
});
|
||||||
return [
|
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(
|
cssRow(
|
||||||
dom.autoDispose(allTables),
|
dom.autoDispose(allTables),
|
||||||
dom.autoDispose(isDisabled),
|
dom.autoDispose(isDisabled),
|
||||||
|
@ -27,6 +27,10 @@ export interface UserPrefs extends Prefs {
|
|||||||
seenDeprecatedWarnings?: DeprecationWarning[];
|
seenDeprecatedWarnings?: DeprecationWarning[];
|
||||||
// List of dismissedPopups user have seen.
|
// List of dismissedPopups user have seen.
|
||||||
dismissedPopups?: DismissedPopup[];
|
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.
|
// 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 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
|
* List of all popups that user can see and dismiss
|
||||||
*/
|
*/
|
||||||
export const DismissedPopup = StringUnion(
|
export const DismissedPopup = StringUnion(
|
||||||
'deleteRecords', // confirmation for deleting records keyboard shortcut
|
'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 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-fg": "string",
|
||||||
"tooltip-close-button-hover-fg": "string",
|
"tooltip-close-button-hover-fg": "string",
|
||||||
"tooltip-close-button-hover-bg": "string",
|
"tooltip-close-button-hover-bg": "string",
|
||||||
|
"tooltip-popup-header-fg": "string",
|
||||||
|
"tooltip-popup-header-bg": "string",
|
||||||
"modal-bg": "string",
|
"modal-bg": "string",
|
||||||
"modal-backdrop": "string",
|
"modal-backdrop": "string",
|
||||||
"modal-border": "string",
|
"modal-border": "string",
|
||||||
|
@ -87,6 +87,8 @@ export interface ThemeColors {
|
|||||||
'tooltip-close-button-fg': string;
|
'tooltip-close-button-fg': string;
|
||||||
'tooltip-close-button-hover-fg': string;
|
'tooltip-close-button-hover-fg': string;
|
||||||
'tooltip-close-button-hover-bg': string;
|
'tooltip-close-button-hover-bg': string;
|
||||||
|
'tooltip-popup-header-fg': string;
|
||||||
|
'tooltip-popup-header-bg': string;
|
||||||
|
|
||||||
/* Modals */
|
/* Modals */
|
||||||
'modal-bg': string;
|
'modal-bg': string;
|
||||||
|
@ -62,8 +62,12 @@ export const MIN_URLID_PREFIX_LENGTH = 12;
|
|||||||
export const commonUrls = {
|
export const commonUrls = {
|
||||||
help: getHelpCenterUrl(),
|
help: getHelpCenterUrl(),
|
||||||
helpAccessRules: "https://support.getgrist.com/access-rules",
|
helpAccessRules: "https://support.getgrist.com/access-rules",
|
||||||
|
helpColRefs: "https://support.getgrist.com/col-refs",
|
||||||
helpConditionalFormatting: "https://support.getgrist.com/conditional-formatting",
|
helpConditionalFormatting: "https://support.getgrist.com/conditional-formatting",
|
||||||
|
helpFilterButtons: "https://support.getgrist.com/search-sort-filter/#filter-buttons",
|
||||||
helpLinkingWidgets: "https://support.getgrist.com/linking-widgets",
|
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",
|
helpTriggerFormulas: "https://support.getgrist.com/formulas/#trigger-formulas",
|
||||||
helpTryingOutChanges: "https://support.getgrist.com/copying-docs/#trying-out-changes",
|
helpTryingOutChanges: "https://support.getgrist.com/copying-docs/#trying-out-changes",
|
||||||
plans: "https://www.getgrist.com/pricing",
|
plans: "https://www.getgrist.com/pricing",
|
||||||
|
@ -66,6 +66,8 @@ export const GristDark: ThemeColors = {
|
|||||||
'tooltip-close-button-fg': 'white',
|
'tooltip-close-button-fg': 'white',
|
||||||
'tooltip-close-button-hover-fg': 'black',
|
'tooltip-close-button-hover-fg': 'black',
|
||||||
'tooltip-close-button-hover-bg': 'white',
|
'tooltip-close-button-hover-bg': 'white',
|
||||||
|
'tooltip-popup-header-fg': '#EFEFEF',
|
||||||
|
'tooltip-popup-header-bg': '#1DA270',
|
||||||
|
|
||||||
/* Modals */
|
/* Modals */
|
||||||
'modal-bg': '#32323F',
|
'modal-bg': '#32323F',
|
||||||
|
@ -66,6 +66,8 @@ export const GristLight: ThemeColors = {
|
|||||||
'tooltip-close-button-fg': 'white',
|
'tooltip-close-button-fg': 'white',
|
||||||
'tooltip-close-button-hover-fg': 'black',
|
'tooltip-close-button-hover-fg': 'black',
|
||||||
'tooltip-close-button-hover-bg': 'white',
|
'tooltip-close-button-hover-bg': 'white',
|
||||||
|
'tooltip-popup-header-fg': 'white',
|
||||||
|
'tooltip-popup-header-bg': '#16B378',
|
||||||
|
|
||||||
/* Modals */
|
/* Modals */
|
||||||
'modal-bg': 'white',
|
'modal-bg': 'white',
|
||||||
|
1
stubs/app/client/ui/WelcomeCoachingCall.ts
Normal file
1
stubs/app/client/ui/WelcomeCoachingCall.ts
Normal file
@ -0,0 +1 @@
|
|||||||
|
export * from 'app/client/ui/WelcomeCoachingCallStub';
|
137
test/nbrowser/BehavioralPrompts.ts
Normal file
137
test/nbrowser/BehavioralPrompts.ts
Normal file
@ -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 createHomeApi = homeUtil.createHomeApi.bind(homeUtil);
|
||||||
export const simulateLogin = homeUtil.simulateLogin.bind(homeUtil);
|
export const simulateLogin = homeUtil.simulateLogin.bind(homeUtil);
|
||||||
export const removeLogin = homeUtil.removeLogin.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 setValue = homeUtil.setValue.bind(homeUtil);
|
||||||
export const isOnLoginPage = homeUtil.isOnLoginPage.bind(homeUtil);
|
export const isOnLoginPage = homeUtil.isOnLoginPage.bind(homeUtil);
|
||||||
export const isOnGristLoginPage = homeUtil.isOnLoginPage.bind(homeUtil);
|
export const isOnGristLoginPage = homeUtil.isOnLoginPage.bind(homeUtil);
|
||||||
@ -1008,9 +1010,19 @@ export async function addNewTable(name?: string) {
|
|||||||
|
|
||||||
export interface PageWidgetPickerOptions {
|
export interface PageWidgetPickerOptions {
|
||||||
tableName?: string;
|
tableName?: string;
|
||||||
selectBy?: RegExp|string; // Optional pattern of SELECT BY option to pick.
|
/** Optional pattern of SELECT BY option to pick. */
|
||||||
summarize?: (RegExp|string)[]; // Optional list of patterns to match Group By columns.
|
selectBy?: RegExp|string;
|
||||||
dontAdd?: boolean; // If true, configure the widget selection without actually adding to the page
|
/** 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.
|
// 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(
|
export async function selectWidget(
|
||||||
typeRe: RegExp|string,
|
typeRe: RegExp|string,
|
||||||
tableRe: RegExp|string = '',
|
tableRe: RegExp|string = '',
|
||||||
options: PageWidgetPickerOptions = {}) {
|
options: PageWidgetPickerOptions = {}
|
||||||
|
) {
|
||||||
|
if (options.dismissTips) { await dismissBehavioralPrompts(); }
|
||||||
|
|
||||||
// select right type
|
// select right type
|
||||||
await driver.findContent('.test-wselect-type', typeRe).doClick();
|
await driver.findContent('.test-wselect-type', typeRe).doClick();
|
||||||
|
|
||||||
|
if (options.dismissTips) { await dismissBehavioralPrompts(); }
|
||||||
|
|
||||||
if (tableRe) {
|
if (tableRe) {
|
||||||
const tableEl = driver.findContent('.test-wselect-table', tableRe);
|
const tableEl = driver.findContent('.test-wselect-table', tableRe);
|
||||||
|
|
||||||
@ -1067,6 +1083,8 @@ export async function selectWidget(
|
|||||||
// let's select table
|
// let's select table
|
||||||
await tableEl.click();
|
await tableEl.click();
|
||||||
|
|
||||||
|
if (options.dismissTips) { await dismissBehavioralPrompts(); }
|
||||||
|
|
||||||
const pivotEl = tableEl.find('.test-wselect-pivot');
|
const pivotEl = tableEl.find('.test-wselect-pivot');
|
||||||
if (await pivotEl.isPresent()) {
|
if (await pivotEl.isPresent()) {
|
||||||
await toggleSelectable(pivotEl, Boolean(options.summarize));
|
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) {
|
export async function removeTable(tableId: string) {
|
||||||
const back = await driver.getCurrentUrl();
|
|
||||||
await driver.find(".test-tools-raw").click();
|
await driver.find(".test-tools-raw").click();
|
||||||
const tableIdList = await driver.findAll('.test-raw-data-table-id', e => e.getText());
|
const tableIdList = await driver.findAll('.test-raw-data-table-id', e => e.getText());
|
||||||
const tableIndex = tableIdList.indexOf(tableId);
|
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-raw-data-menu-remove").click();
|
||||||
await driver.find(".test-modal-confirm").click();
|
await driver.find(".test-modal-confirm").click();
|
||||||
await waitForServer();
|
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.
|
* 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 toggleSidePanel('right', 'open');
|
||||||
await driver.find('.test-right-tab-field').click();
|
await driver.find('.test-right-tab-field').click();
|
||||||
await driver.find('.test-fbuilder-type-select').click();
|
await driver.find('.test-fbuilder-type-select').click();
|
||||||
type = typeof type === 'string' ? exactMatch(type) : type;
|
type = typeof type === 'string' ? exactMatch(type) : type;
|
||||||
await driver.findContentWait('.test-select-menu .test-select-row', type, 500).click();
|
await driver.findContentWait('.test-select-menu .test-select-row', type, 500).click();
|
||||||
if (!options.skipWait || options.apply) { await waitForServer(); }
|
if (!skipWait || apply) { await waitForServer(); }
|
||||||
if (options.apply) {
|
if (apply) {
|
||||||
await driver.findWait('.test-type-transform-apply', 1000).click();
|
await driver.findWait('.test-type-transform-apply', 1000).click();
|
||||||
await waitForServer();
|
await waitForServer();
|
||||||
}
|
}
|
||||||
@ -1912,13 +1928,21 @@ export class Session {
|
|||||||
public async login(options?: {loginMethod?: UserProfile['loginMethod'],
|
public async login(options?: {loginMethod?: UserProfile['loginMethod'],
|
||||||
freshAccount?: boolean,
|
freshAccount?: boolean,
|
||||||
isFirstLogin?: boolean,
|
isFirstLogin?: boolean,
|
||||||
|
showTips?: boolean,
|
||||||
retainExistingLogin?: boolean}) {
|
retainExistingLogin?: boolean}) {
|
||||||
// Optimize testing a little bit, so if we are already logged in as the expected
|
// 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.
|
// user on the expected org, and there are no options set, we can just continue.
|
||||||
if (!options && await this.isLoggedInCorrectly()) { return this; }
|
if (!options && await this.isLoggedInCorrectly()) { return this; }
|
||||||
if (!options?.retainExistingLogin) {
|
if (!options?.retainExistingLogin) {
|
||||||
await removeLogin();
|
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,
|
await server.simulateLogin(this.settings.name, this.settings.email, this.settings.orgDomain,
|
||||||
{isFirstLogin: false, cacheCredentials: true, ...options});
|
{isFirstLogin: false, cacheCredentials: true, ...options});
|
||||||
@ -2701,6 +2725,39 @@ export async function refreshDismiss() {
|
|||||||
await waitForDocToLoad();
|
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.
|
* Confirms that anchor link was used for navigation.
|
||||||
*/
|
*/
|
||||||
|
@ -11,6 +11,7 @@ import * as path from 'path';
|
|||||||
import {WebDriver} from 'selenium-webdriver';
|
import {WebDriver} from 'selenium-webdriver';
|
||||||
|
|
||||||
import {UserProfile} from 'app/common/LoginSessionAPI';
|
import {UserProfile} from 'app/common/LoginSessionAPI';
|
||||||
|
import {BehavioralPrompt, UserPrefs, WelcomePopup} from 'app/common/Prefs';
|
||||||
import {DocWorkerAPI, UserAPI, UserAPIImpl} from 'app/common/UserAPI';
|
import {DocWorkerAPI, UserAPI, UserAPIImpl} from 'app/common/UserAPI';
|
||||||
import {HomeDBManager} from 'app/gen-server/lib/HomeDBManager';
|
import {HomeDBManager} from 'app/gen-server/lib/HomeDBManager';
|
||||||
import log from 'app/server/lib/log';
|
import log from 'app/server/lib/log';
|
||||||
@ -25,6 +26,29 @@ export interface Server {
|
|||||||
isExternalServer(): boolean;
|
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 {
|
export class HomeUtil {
|
||||||
// Cache api keys of test users. It is often convenient to have various instances
|
// Cache api keys of test users. It is often convenient to have various instances
|
||||||
// of the home api available while making browser tests.
|
// of the home api available while making browser tests.
|
||||||
@ -54,9 +78,13 @@ export class HomeUtil {
|
|||||||
freshAccount?: boolean,
|
freshAccount?: boolean,
|
||||||
isFirstLogin?: boolean,
|
isFirstLogin?: boolean,
|
||||||
showGristTour?: boolean,
|
showGristTour?: boolean,
|
||||||
|
showTips?: boolean,
|
||||||
cacheCredentials?: 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);
|
const showGristTour = options.showGristTour ?? (options.freshAccount ?? isFirstLogin);
|
||||||
|
|
||||||
// For regular tests, we can log in through a testing hook.
|
// 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 (options.freshAccount) { await this._deleteUserByEmail(email); }
|
||||||
if (isFirstLogin !== undefined) { await this._setFirstLogin(email, isFirstLogin); }
|
if (isFirstLogin !== undefined) { await this._setFirstLogin(email, isFirstLogin); }
|
||||||
if (showGristTour !== undefined) { await this._initShowGristTour(email, showGristTour); }
|
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
|
// 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.
|
// through it. Using the empty string happens to work though.
|
||||||
const testingHooks = await this.server.getTestingHooks();
|
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
|
// Check if the url looks like a welcome page. The check is weak, but good enough
|
||||||
// for testing.
|
// for testing.
|
||||||
public async isWelcomePage() {
|
public async isWelcomePage() {
|
||||||
@ -396,4 +437,30 @@ export class HomeUtil {
|
|||||||
newFormData: () => new FormData() as any, // form-data isn't quite type compatible
|
newFormData: () => new FormData() as any, // form-data isn't quite type compatible
|
||||||
logger: log});
|
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…
Reference in New Issue
Block a user