mirror of
https://github.com/gristlabs/grist-core.git
synced 2026-03-02 04:09:24 +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:
@@ -322,6 +322,9 @@ export class AccessRules extends Disposable {
|
||||
|
||||
public buildDom() {
|
||||
return cssOuter(
|
||||
dom('div', this._gristDoc.behavioralPrompts.attachTip('accessRules', {
|
||||
hideArrow: true,
|
||||
})),
|
||||
cssAddTableRow(
|
||||
bigBasicButton({disabled: true}, dom.hide(this._savingEnabled),
|
||||
dom.text((use) => {
|
||||
|
||||
@@ -733,8 +733,17 @@ BaseView.prototype.getLastDataRowIndex = function() {
|
||||
* Creates and opens ColumnFilterMenu for a given field/column, and returns its PopupControl.
|
||||
*/
|
||||
BaseView.prototype.createFilterMenu = function(openCtl, filterInfo, options) {
|
||||
return createFilterMenu(openCtl, this._sectionFilter, filterInfo, this._mainRowSource,
|
||||
this.tableModel.tableData, options);
|
||||
const {showAllFiltersButton, onClose} = options;
|
||||
return createFilterMenu({
|
||||
openCtl,
|
||||
sectionFilter: this._sectionFilter,
|
||||
filterInfo,
|
||||
rowSource: this._mainRowSource,
|
||||
tableData: this.tableModel.tableData,
|
||||
gristDoc: this.gristDoc,
|
||||
showAllFiltersButton,
|
||||
onClose,
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
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(
|
||||
/*************** List section **********/
|
||||
testId('list'),
|
||||
docListHeader(t('RawDataTables')),
|
||||
cssHeader(t('RawDataTables')),
|
||||
cssList(
|
||||
dom.forEach(this._tables, tableRec =>
|
||||
cssItem(
|
||||
@@ -185,6 +185,10 @@ const container = styled('div', `
|
||||
position: relative;
|
||||
`);
|
||||
|
||||
const cssHeader = styled(docListHeader, `
|
||||
display: inline-block;
|
||||
`);
|
||||
|
||||
const cssList = styled('div', `
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
import {AccessRules} from 'app/client/aclui/AccessRules';
|
||||
import {ActionLog} from 'app/client/components/ActionLog';
|
||||
import BaseView from 'app/client/components/BaseView';
|
||||
import {BehavioralPrompts} from 'app/client/components/BehavioralPrompts';
|
||||
import {isNumericLike, isNumericOnly} from 'app/client/components/ChartView';
|
||||
import {CodeEditorPanel} from 'app/client/components/CodeEditorPanel';
|
||||
import * as commands from 'app/client/components/commands';
|
||||
@@ -46,6 +47,7 @@ import {isTourActive} from "app/client/ui/OnBoardingPopups";
|
||||
import {IPageWidget, toPageWidget} from 'app/client/ui/PageWidgetPicker';
|
||||
import {linkFromId, selectBy} from 'app/client/ui/selectBy';
|
||||
import {startWelcomeTour} from 'app/client/ui/welcomeTour';
|
||||
import {IWidgetType} from 'app/client/ui/widgetTypes';
|
||||
import {isNarrowScreen, mediaSmall, testId} from 'app/client/ui2018/cssVars';
|
||||
import {IconName} from 'app/client/ui2018/IconList';
|
||||
import {invokePrompt} from 'app/client/ui2018/modals';
|
||||
@@ -164,6 +166,8 @@ export class GristDoc extends DisposableWithEvents {
|
||||
// If the doc has a docTour. Used also to enable the UI button to restart the tour.
|
||||
public readonly hasDocTour: Computed<boolean>;
|
||||
|
||||
public readonly behavioralPrompts = BehavioralPrompts.create(this, this.docPageModel.appModel);
|
||||
|
||||
private _actionLog: ActionLog;
|
||||
private _undoStack: UndoStack;
|
||||
private _lastOwnActionGroup: ActionGroupWithCursorPos|null = null;
|
||||
@@ -601,6 +605,8 @@ export class GristDoc extends DisposableWithEvents {
|
||||
|
||||
// The newly-added section should be given focus.
|
||||
this.viewModel.activeSectionId(res.sectionRef);
|
||||
|
||||
this._maybeShowEditCardLayoutTip(val.type).catch(reportError);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -641,6 +647,8 @@ export class GristDoc extends DisposableWithEvents {
|
||||
await this.openDocPage(result.viewRef);
|
||||
// The newly-added section should be given focus.
|
||||
this.viewModel.activeSectionId(result.sectionRef);
|
||||
|
||||
this._maybeShowEditCardLayoutTip(val.type).catch(reportError);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1087,6 +1095,32 @@ export class GristDoc extends DisposableWithEvents {
|
||||
}
|
||||
}
|
||||
|
||||
private async _maybeShowEditCardLayoutTip(selectedWidgetType: IWidgetType) {
|
||||
if (
|
||||
// Don't show the tip if a non-card widget was selected.
|
||||
!['single', 'detail'].includes(selectedWidgetType) ||
|
||||
// Or if we've already seen it.
|
||||
this.behavioralPrompts.hasSeenTip('editCardLayout')
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Open the right panel to the widget subtab.
|
||||
commands.allCommands.viewTabOpen.run();
|
||||
|
||||
// Wait for the right panel to finish animation if it was collapsed before.
|
||||
await commands.allCommands.rightPanelOpen.run();
|
||||
|
||||
const editLayoutButton = document.querySelector('.behavioral-prompt-edit-card-layout');
|
||||
if (!editLayoutButton) { throw new Error('GristDoc failed to find edit card layout button'); }
|
||||
|
||||
this.behavioralPrompts.showTip(editLayoutButton, 'editCardLayout', {
|
||||
popupOptions: {
|
||||
placement: 'left-start',
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private async _promptForName() {
|
||||
return await invokePrompt("Table name", "Create", '', "Default table name");
|
||||
}
|
||||
|
||||
@@ -42,6 +42,7 @@ export class RawDataPage extends Disposable {
|
||||
|
||||
public buildDom() {
|
||||
return cssContainer(
|
||||
dom('div', this._gristDoc.behavioralPrompts.attachTip('rawDataPage', {hideArrow: true})),
|
||||
dom('div',
|
||||
dom.create(DataTables, this._gristDoc),
|
||||
dom.create(DocumentUsage, this._gristDoc.docPageModel),
|
||||
|
||||
@@ -30,12 +30,10 @@
|
||||
|
||||
.g_record_delete_field {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
right: 0;
|
||||
background-color: #404040;
|
||||
border: 1px solid #404040;
|
||||
top: 2px;
|
||||
right: 2px;
|
||||
border-radius: 1rem;
|
||||
color: white;
|
||||
color: var(--grist-theme-control-secondary-fg, #404040);
|
||||
cursor: pointer;
|
||||
|
||||
display: none;
|
||||
|
||||
@@ -125,7 +125,7 @@ RecordLayoutEditor.prototype.buildFinishButtons = function() {
|
||||
|
||||
RecordLayoutEditor.prototype.buildLeafDom = function() {
|
||||
return dom('div.layout_grabbable.g_record_layout_editing',
|
||||
dom('div.g_record_delete_field.glyphicon.glyphicon-remove',
|
||||
dom('div.g_record_delete_field.glyphicon.glyphicon-eye-close',
|
||||
dom.on('mousedown', (ev) => ev.stopPropagation()),
|
||||
dom.on('click', (ev, elem) => {
|
||||
ev.preventDefault();
|
||||
|
||||
@@ -177,7 +177,8 @@ ViewConfigTab.prototype._buildLayoutDom = function() {
|
||||
dom.autoDispose(layoutEditorObs),
|
||||
dom.on('click', () => commands.allCommands.editLayout.run()),
|
||||
grainjsDom.hide(layoutEditorObs),
|
||||
testId('detail-edit-layout')
|
||||
grainjsDom.cls('behavioral-prompt-edit-card-layout'),
|
||||
testId('detail-edit-layout'),
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -15,7 +15,7 @@ import {reportError} from 'app/client/models/errors';
|
||||
import {filterBar} from 'app/client/ui/FilterBar';
|
||||
import {viewSectionMenu} from 'app/client/ui/ViewSectionMenu';
|
||||
import {buildWidgetTitle} from 'app/client/ui/WidgetTitle';
|
||||
import {isNarrowScreenObs, colors, mediaSmall, testId, theme} from 'app/client/ui2018/cssVars';
|
||||
import {colors, isNarrowScreenObs, mediaSmall, testId, theme} from 'app/client/ui2018/cssVars';
|
||||
import {icon} from 'app/client/ui2018/icons';
|
||||
import {DisposableWithEvents} from 'app/common/DisposableWithEvents';
|
||||
import {mod} from 'app/common/gutil';
|
||||
@@ -323,7 +323,7 @@ export function buildViewSectionDom(options: {
|
||||
dom.create(viewSectionMenu, gristDoc, vs)
|
||||
)
|
||||
)),
|
||||
dom.create(filterBar, vs),
|
||||
dom.create(filterBar, gristDoc, vs),
|
||||
dom.maybe<BaseView|null>(vs.viewInstance, (viewInstance) => [
|
||||
dom('div.view_data_pane_container.flexvbox',
|
||||
cssResizing.cls('', isResizing),
|
||||
|
||||
@@ -116,6 +116,11 @@ exports.groups = [{
|
||||
keys: [],
|
||||
desc: 'Shortcut to open the left panel',
|
||||
},
|
||||
{
|
||||
name: 'rightPanelOpen',
|
||||
keys: [],
|
||||
desc: 'Shortcut to open the right panel',
|
||||
},
|
||||
{
|
||||
name: 'videoTourToolsOpen',
|
||||
keys: [],
|
||||
|
||||
@@ -2,11 +2,14 @@ import * as commands from 'app/client/components/commands';
|
||||
import {GristDoc} from 'app/client/components/GristDoc';
|
||||
import {FocusLayer} from 'app/client/lib/FocusLayer';
|
||||
import {reportSuccess} from 'app/client/models/errors';
|
||||
import {basicButton, primaryButton} from 'app/client/ui2018/buttons';
|
||||
import {basicButton, bigPrimaryButton, primaryButton} from 'app/client/ui2018/buttons';
|
||||
import {labeledSquareCheckbox} from 'app/client/ui2018/checkbox';
|
||||
import {testId, theme} from 'app/client/ui2018/cssVars';
|
||||
import {modalTooltip} from 'app/client/ui2018/modals';
|
||||
import {dom, DomContents, observable, styled} from 'grainjs';
|
||||
import {testId, theme, vars} from 'app/client/ui2018/cssVars';
|
||||
import {icon} from 'app/client/ui2018/icons';
|
||||
import {cssModalTooltip, modalTooltip} from 'app/client/ui2018/modals';
|
||||
import {dom, DomContents, keyframes, observable, styled, svg} from 'grainjs';
|
||||
import {IPopupOptions} from 'popweasel';
|
||||
import merge = require('lodash/merge');
|
||||
|
||||
/**
|
||||
* This is a file for all custom and pre-configured popups, modals, toasts and tooltips, used
|
||||
@@ -131,6 +134,163 @@ export function reportUndo(
|
||||
}
|
||||
}
|
||||
|
||||
export interface ShowBehavioralPromptOptions {
|
||||
onClose: (dontShowTips: boolean) => void;
|
||||
/** Defaults to false. */
|
||||
hideArrow?: boolean;
|
||||
popupOptions?: IPopupOptions;
|
||||
}
|
||||
|
||||
export function showBehavioralPrompt(
|
||||
refElement: Element,
|
||||
title: string,
|
||||
content: DomContents,
|
||||
options: ShowBehavioralPromptOptions
|
||||
) {
|
||||
const {onClose, hideArrow, popupOptions} = options;
|
||||
const arrow = hideArrow ? null : buildArrow();
|
||||
const dontShowTips = observable(false);
|
||||
const tooltip = modalTooltip(refElement,
|
||||
(ctl) => [
|
||||
cssBehavioralPromptModal.cls(''),
|
||||
arrow,
|
||||
cssBehavioralPromptContainer(
|
||||
dom.autoDispose(dontShowTips),
|
||||
testId('behavioral-prompt'),
|
||||
elem => { FocusLayer.create(ctl, {defaultFocusElem: elem, pauseMousetrap: true}); },
|
||||
dom.onKeyDown({
|
||||
Escape: () => ctl.close(),
|
||||
Enter: () => { onClose(dontShowTips.get()); ctl.close(); },
|
||||
}),
|
||||
cssBehavioralPromptHeader(
|
||||
cssHeaderIconAndText(
|
||||
icon('Idea'),
|
||||
cssHeaderText('TIP'),
|
||||
),
|
||||
),
|
||||
cssBehavioralPromptBody(
|
||||
cssBehavioralPromptTitle(title, testId('behavioral-prompt-title')),
|
||||
content,
|
||||
cssButtons(
|
||||
dom.style('margin-top', '12px'),
|
||||
dom.style('justify-content', 'space-between'),
|
||||
dom.style('align-items', 'center'),
|
||||
dom('div',
|
||||
cssSkipTipsCheckbox(dontShowTips,
|
||||
cssSkipTipsCheckboxLabel("Don't show tips"),
|
||||
testId('behavioral-prompt-dont-show-tips')
|
||||
),
|
||||
),
|
||||
cssDismissPromptButton('Got it', testId('behavioral-prompt-dismiss'),
|
||||
dom.on('click', () => { onClose(dontShowTips.get()); ctl.close(); })
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
merge(popupOptions, {
|
||||
modifiers: {
|
||||
...(arrow ? {arrow: {element: arrow}}: {}),
|
||||
offset: {
|
||||
offset: '0,12',
|
||||
},
|
||||
}
|
||||
})
|
||||
);
|
||||
dom.onDisposeElem(refElement, () => {
|
||||
if (!tooltip.isDisposed()) {
|
||||
tooltip.close();
|
||||
}
|
||||
});
|
||||
return tooltip;
|
||||
}
|
||||
|
||||
function buildArrow() {
|
||||
return cssArrowContainer(
|
||||
svg('svg',
|
||||
{style: 'width: 13px; height: 18px;'},
|
||||
svg('path', {'d': 'M 0 0 h 13 v 18 Z'}),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
function sideSelectorChunk(side: 'top'|'bottom'|'left'|'right') {
|
||||
return `.${cssModalTooltip.className}[x-placement^=${side}]`;
|
||||
}
|
||||
|
||||
function fadeInFromSide(side: 'top'|'bottom'|'left'|'right') {
|
||||
let startPosition: string;
|
||||
switch(side) {
|
||||
case 'top': {
|
||||
startPosition = '0px -25px';
|
||||
break;
|
||||
}
|
||||
case 'bottom': {
|
||||
startPosition = '0px 25px';
|
||||
break;
|
||||
}
|
||||
case'left': {
|
||||
startPosition = '-25px 0px';
|
||||
break;
|
||||
}
|
||||
case 'right': {
|
||||
startPosition = '25px 0px';
|
||||
break;
|
||||
}
|
||||
}
|
||||
return keyframes(`
|
||||
from {translate: ${startPosition}; opacity: 0;}
|
||||
to {translate: 0px 0px; opacity: 1;}
|
||||
`);
|
||||
}
|
||||
|
||||
const HEADER_HEIGHT_PX = 30;
|
||||
|
||||
const cssArrowContainer = styled('div', `
|
||||
position: absolute;
|
||||
|
||||
& path {
|
||||
stroke: ${theme.popupBg};
|
||||
stroke-width: 2px;
|
||||
fill: ${theme.popupBg};
|
||||
}
|
||||
|
||||
${sideSelectorChunk('bottom')} > & path {
|
||||
stroke: ${theme.controlPrimaryBg};
|
||||
fill: ${theme.controlPrimaryBg};
|
||||
}
|
||||
|
||||
${sideSelectorChunk('top')} > & {
|
||||
bottom: -17px;
|
||||
}
|
||||
|
||||
${sideSelectorChunk('bottom')} > & {
|
||||
top: -14px;
|
||||
}
|
||||
|
||||
${sideSelectorChunk('right')} > & {
|
||||
left: -12px;
|
||||
margin: ${HEADER_HEIGHT_PX}px 0px ${HEADER_HEIGHT_PX}px 0px;
|
||||
}
|
||||
|
||||
${sideSelectorChunk('left')} > & {
|
||||
right: -12px;
|
||||
margin: ${HEADER_HEIGHT_PX}px 0px ${HEADER_HEIGHT_PX}px 0px;
|
||||
}
|
||||
|
||||
${sideSelectorChunk('top')} svg {
|
||||
transform: rotate(-90deg);
|
||||
}
|
||||
|
||||
${sideSelectorChunk('bottom')} svg {
|
||||
transform: rotate(90deg);
|
||||
}
|
||||
|
||||
${sideSelectorChunk('left')} svg {
|
||||
transform: scalex(-1);
|
||||
}
|
||||
`);
|
||||
|
||||
const cssTheme = styled('div', `
|
||||
color: ${theme.text};
|
||||
`);
|
||||
@@ -147,3 +307,86 @@ const cssContainer = styled(cssTheme, `
|
||||
const cssWideContainer = styled(cssTheme, `
|
||||
max-width: 340px;
|
||||
`);
|
||||
|
||||
const cssFadeInFromTop = fadeInFromSide('top');
|
||||
|
||||
const cssFadeInFromBottom = fadeInFromSide('bottom');
|
||||
|
||||
const cssFadeInFromLeft = fadeInFromSide('left');
|
||||
|
||||
const cssFadeInFromRight = fadeInFromSide('right');
|
||||
|
||||
const cssBehavioralPromptModal = styled('div', `
|
||||
margin: 0px;
|
||||
padding: 0px;
|
||||
width: 400px;
|
||||
border-radius: 4px;
|
||||
|
||||
animation-duration: 0.4s;
|
||||
position: relative;
|
||||
|
||||
&[x-placement^=top] {
|
||||
animation-name: ${cssFadeInFromTop};
|
||||
}
|
||||
|
||||
&[x-placement^=bottom] {
|
||||
animation-name: ${cssFadeInFromBottom};
|
||||
}
|
||||
|
||||
&[x-placement^=left] {
|
||||
animation-name: ${cssFadeInFromLeft};
|
||||
}
|
||||
|
||||
&[x-placement^=right] {
|
||||
animation-name: ${cssFadeInFromRight};
|
||||
}
|
||||
`);
|
||||
|
||||
const cssBehavioralPromptContainer = styled(cssTheme, `
|
||||
line-height: 18px;
|
||||
`);
|
||||
|
||||
const cssBehavioralPromptHeader = styled('div', `
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
background-color: ${theme.controlPrimaryBg};
|
||||
color: ${theme.controlPrimaryFg};
|
||||
--icon-color: ${theme.controlPrimaryFg};
|
||||
border-radius: 4px 4px 0px 0px;
|
||||
line-height: ${HEADER_HEIGHT_PX}px;
|
||||
`);
|
||||
|
||||
const cssBehavioralPromptBody = styled('div', `
|
||||
padding: 16px;
|
||||
`);
|
||||
|
||||
const cssHeaderIconAndText = styled('div', `
|
||||
display: flex;
|
||||
align-items: center;
|
||||
column-gap: 8px;
|
||||
`);
|
||||
|
||||
const cssHeaderText = styled('div', `
|
||||
font-weight: 600;
|
||||
`);
|
||||
|
||||
const cssDismissPromptButton = styled(bigPrimaryButton, `
|
||||
margin-right: 8px;
|
||||
`);
|
||||
|
||||
const cssBehavioralPromptTitle = styled('div', `
|
||||
font-size: ${vars.xxxlargeFontSize};
|
||||
font-weight: ${vars.headerControlTextWeight};
|
||||
color: ${theme.text};
|
||||
margin: 0 0 16px 0;
|
||||
line-height: 32px;
|
||||
`);
|
||||
|
||||
const cssSkipTipsCheckbox = styled(labeledSquareCheckbox, `
|
||||
line-height: normal;
|
||||
`);
|
||||
|
||||
|
||||
const cssSkipTipsCheckboxLabel = styled('span', `
|
||||
color: ${theme.lightText};
|
||||
`);
|
||||
|
||||
@@ -12,7 +12,8 @@ import {Features, isLegacyPlan, Product} from 'app/common/Features';
|
||||
import {GristLoadConfig} from 'app/common/gristUrls';
|
||||
import {FullUser} from 'app/common/LoginSessionAPI';
|
||||
import {LocalPlugin} from 'app/common/plugin';
|
||||
import {DeprecationWarning, DismissedPopup, UserPrefs} from 'app/common/Prefs';
|
||||
import {BehavioralPromptPrefs, DeprecationWarning, DismissedPopup, DismissedReminder,
|
||||
UserPrefs} from 'app/common/Prefs';
|
||||
import {isOwner} from 'app/common/roles';
|
||||
import {getTagManagerScript} from 'app/common/tagManager';
|
||||
import {getDefaultThemePrefs, Theme, ThemeAppearance, ThemeColors, ThemePrefs,
|
||||
@@ -23,7 +24,7 @@ import {getOrgName, Organization, OrgError, UserAPI, UserAPIImpl} from 'app/comm
|
||||
import {getUserPrefObs, getUserPrefsObs} from 'app/client/models/UserPrefs';
|
||||
import {bundleChanges, Computed, Disposable, Observable, subscribe} from 'grainjs';
|
||||
|
||||
const t = makeT('models.AppModel')
|
||||
const t = makeT('models.AppModel');
|
||||
|
||||
// Reexported for convenience.
|
||||
export {reportError} from 'app/client/models/errors';
|
||||
@@ -97,6 +98,8 @@ export interface AppModel {
|
||||
* Deprecation messages that user has seen.
|
||||
*/
|
||||
deprecatedWarnings: Observable<DeprecationWarning[]>;
|
||||
dismissedWelcomePopups: Observable<DismissedReminder[]>;
|
||||
behavioralPrompts: Observable<BehavioralPromptPrefs>;
|
||||
|
||||
pageType: Observable<PageType>;
|
||||
|
||||
@@ -108,6 +111,7 @@ export interface AppModel {
|
||||
showNewSiteModal(): void;
|
||||
isBillingManager(): boolean; // If user is a billing manager for this org
|
||||
isSupport(): boolean; // If user is a Support user
|
||||
isOwner(): boolean; // If user is an owner of this org
|
||||
}
|
||||
|
||||
export class TopAppModelImpl extends Disposable implements TopAppModel {
|
||||
@@ -236,11 +240,14 @@ export class AppModelImpl extends Disposable implements AppModel {
|
||||
}) as Observable<ThemePrefs>;
|
||||
public readonly currentTheme = this._getCurrentThemeObs();
|
||||
|
||||
public readonly dismissedPopups =
|
||||
getUserPrefObs(this.userPrefsObs, 'dismissedPopups', { defaultValue: [] }) as Observable<DismissedPopup[]>;
|
||||
public readonly dismissedPopups = getUserPrefObs(this.userPrefsObs, 'dismissedPopups',
|
||||
{ defaultValue: [] }) as Observable<DismissedPopup[]>;
|
||||
public readonly deprecatedWarnings = getUserPrefObs(this.userPrefsObs, 'seenDeprecatedWarnings',
|
||||
{ defaultValue: []}) as Observable<DeprecationWarning[]>;
|
||||
|
||||
{ defaultValue: [] }) as Observable<DeprecationWarning[]>;
|
||||
public readonly dismissedWelcomePopups = getUserPrefObs(this.userPrefsObs, 'dismissedWelcomePopups',
|
||||
{ defaultValue: [] }) as Observable<DismissedReminder[]>;
|
||||
public readonly behavioralPrompts = getUserPrefObs(this.userPrefsObs, 'behavioralPrompts',
|
||||
{ defaultValue: { dontShowTips: false, dismissedTips: [] } }) as Observable<BehavioralPromptPrefs>;
|
||||
|
||||
// Get the current PageType from the URL.
|
||||
public readonly pageType: Observable<PageType> = Computed.create(this, urlState().state,
|
||||
@@ -303,18 +310,21 @@ export class AppModelImpl extends Disposable implements AppModel {
|
||||
return Boolean(this.currentOrg?.billingAccount?.isManager);
|
||||
}
|
||||
|
||||
public isOwner() {
|
||||
return Boolean(this.currentOrg && isOwner(this.currentOrg));
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch and update the current org's usage.
|
||||
*/
|
||||
public async refreshOrgUsage() {
|
||||
const currentOrg = this.currentOrg;
|
||||
if (!isOwner(currentOrg)) {
|
||||
if (!this.isOwner()) {
|
||||
// Note: getOrgUsageSummary already checks for owner access; we do an early return
|
||||
// here to skip making unnecessary API calls.
|
||||
return;
|
||||
}
|
||||
|
||||
const usage = await this.api.getOrgUsageSummary(currentOrg.id);
|
||||
const usage = await this.api.getOrgUsageSummary(this.currentOrg!.id);
|
||||
if (!this.isDisposed()) {
|
||||
this.currentOrgUsage.set(usage);
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { GristDoc } from "app/client/components/GristDoc";
|
||||
import { ColumnFilter } from "app/client/models/ColumnFilter";
|
||||
import { FilterInfo } from "app/client/models/entities/ViewSectionRec";
|
||||
import { CellValue } from "app/plugin/GristData";
|
||||
@@ -29,6 +30,7 @@ interface ColumnFilterMenuModelParams {
|
||||
columnFilter: ColumnFilter;
|
||||
filterInfo: FilterInfo;
|
||||
valueCount: Array<[CellValue, IFilterCount]>;
|
||||
gristDoc: GristDoc;
|
||||
limitShow?: number;
|
||||
}
|
||||
|
||||
@@ -37,6 +39,8 @@ export class ColumnFilterMenuModel extends Disposable {
|
||||
|
||||
public readonly filterInfo = this._params.filterInfo;
|
||||
|
||||
public readonly gristDoc = this._params.gristDoc;
|
||||
|
||||
public readonly initialPinned = this.filterInfo.isPinned.peek();
|
||||
|
||||
public readonly limitShown = this._params.limitShow ?? MAXIMUM_SHOWN_FILTER_ITEMS;
|
||||
|
||||
@@ -336,13 +336,13 @@ function addMenu(importSources: ImportSource[], gristDoc: GristDoc, isReadonly:
|
||||
const selectBy = gristDoc.selectBy.bind(gristDoc);
|
||||
return [
|
||||
menuItem(
|
||||
(elem) => openPageWidgetPicker(elem, gristDoc.docModel, (val) => gristDoc.addNewPage(val).catch(reportError),
|
||||
(elem) => openPageWidgetPicker(elem, gristDoc, (val) => gristDoc.addNewPage(val).catch(reportError),
|
||||
{isNewPage: true, buttonLabel: 'Add Page'}),
|
||||
menuIcon("Page"), t("AddPage"), testId('dp-add-new-page'),
|
||||
dom.cls('disabled', isReadonly)
|
||||
),
|
||||
menuItem(
|
||||
(elem) => openPageWidgetPicker(elem, gristDoc.docModel, (val) => gristDoc.addWidgetToPage(val).catch(reportError),
|
||||
(elem) => openPageWidgetPicker(elem, gristDoc, (val) => gristDoc.addWidgetToPage(val).catch(reportError),
|
||||
{isNewPage: false, selectBy}),
|
||||
menuIcon("Widget"), t("AddWidgetToPage"), testId('dp-add-widget-to-page'),
|
||||
// disable for readonly doc and all special views
|
||||
|
||||
@@ -94,6 +94,10 @@ export interface ViewSectionRec extends IRowModel<"_grist_Views_section">, RuleO
|
||||
// changes to their filters. (True indicates unsaved changes)
|
||||
filterSpecChanged: Computed<boolean>;
|
||||
|
||||
// Set to true when a second pinned filter is added, to trigger a behavioral prompt. Note that
|
||||
// the popup is only shown once, even if this observable is set to true again in the future.
|
||||
showNestedFilteringPopup: Observable<boolean>;
|
||||
|
||||
// Customizable version of the JSON-stringified sort spec. It may diverge from the saved one.
|
||||
activeSortJson: modelUtil.CustomComputed<string>;
|
||||
|
||||
@@ -432,6 +436,8 @@ export function createViewSectionRec(this: ViewSectionRec, docModel: DocModel):
|
||||
return use(this.filters).some(col => !use(col.filter.isSaved) || !use(col.pinned.isSaved));
|
||||
});
|
||||
|
||||
this.showNestedFilteringPopup = Observable.create(this, false);
|
||||
|
||||
// Save all filters of fields/columns in the section.
|
||||
this.saveFilters = () => {
|
||||
return docModel.docData.bundleActions(`Save all filters in ${this.titleDef()}`,
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
* but on Cancel the model is reset to its initial state prior to menu closing.
|
||||
*/
|
||||
import * as commands from 'app/client/components/commands';
|
||||
import {GristDoc} from 'app/client/components/GristDoc';
|
||||
import {makeT} from 'app/client/lib/localization';
|
||||
import {ColumnFilter, NEW_FILTER_JSON} from 'app/client/models/ColumnFilter';
|
||||
import {ColumnFilterMenuModel, IFilterCount} from 'app/client/models/ColumnFilterMenuModel';
|
||||
@@ -72,7 +73,7 @@ export type IColumnFilterViewType = 'listView'|'calendarView';
|
||||
*/
|
||||
export function columnFilterMenu(owner: IDisposableOwner, opts: IFilterMenuOptions): HTMLElement {
|
||||
const { model, doCancel, doSave, onClose, renderValue, valueParser, showAllFiltersButton } = opts;
|
||||
const { columnFilter, filterInfo } = model;
|
||||
const { columnFilter, filterInfo, gristDoc } = model;
|
||||
const valueFormatter = opts.valueFormatter || ((val) => val?.toString() || '');
|
||||
|
||||
// Map to keep track of displayed checkboxes
|
||||
@@ -351,6 +352,11 @@ export function columnFilterMenu(owner: IDisposableOwner, opts: IFilterMenuOptio
|
||||
icon('PinTilted'),
|
||||
cssPinButton.cls('-pinned', model.filterInfo.isPinned),
|
||||
dom.on('click', () => filterInfo.pinned(!filterInfo.pinned())),
|
||||
gristDoc.behavioralPrompts.attachTip('filterButtons', {
|
||||
popupOptions: {
|
||||
attach: null,
|
||||
}
|
||||
}),
|
||||
testId('pin-btn'),
|
||||
),
|
||||
),
|
||||
@@ -604,27 +610,38 @@ function getEmptyCountMap(fieldOrColumn: ViewFieldRec|ColumnRec): Map<CellValue,
|
||||
}
|
||||
|
||||
export interface IColumnFilterMenuOptions {
|
||||
// Callback for when the filter menu is closed.
|
||||
onClose?: () => void;
|
||||
// If true, shows a button that opens the sort & filter widget menu.
|
||||
/** If true, shows a button that opens the sort & filter widget menu. */
|
||||
showAllFiltersButton?: boolean;
|
||||
/** Callback for when the filter menu is closed. */
|
||||
onClose?: () => void;
|
||||
}
|
||||
|
||||
export interface ICreateFilterMenuParams extends IColumnFilterMenuOptions {
|
||||
openCtl: IOpenController;
|
||||
sectionFilter: SectionFilter;
|
||||
filterInfo: FilterInfo;
|
||||
rowSource: RowSource;
|
||||
tableData: TableData;
|
||||
gristDoc: GristDoc;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns content for the newly created columnFilterMenu; for use with setPopupToCreateDom().
|
||||
*/
|
||||
export function createFilterMenu(
|
||||
openCtl: IOpenController,
|
||||
sectionFilter: SectionFilter,
|
||||
filterInfo: FilterInfo,
|
||||
rowSource: RowSource,
|
||||
tableData: TableData,
|
||||
options: IColumnFilterMenuOptions = {}
|
||||
) {
|
||||
const {onClose = noop, showAllFiltersButton} = options;
|
||||
export function createFilterMenu(params: ICreateFilterMenuParams) {
|
||||
const {
|
||||
openCtl,
|
||||
sectionFilter,
|
||||
filterInfo,
|
||||
rowSource,
|
||||
tableData,
|
||||
gristDoc,
|
||||
showAllFiltersButton,
|
||||
onClose = noop
|
||||
} = params;
|
||||
|
||||
// Go through all of our shown and hidden rows, and count them up by the values in this column.
|
||||
const {fieldOrColumn, filter} = filterInfo;
|
||||
const {fieldOrColumn, filter, isPinned} = filterInfo;
|
||||
const columnType = fieldOrColumn.origCol.peek().type.peek();
|
||||
const visibleColumnType = fieldOrColumn.visibleColModel.peek()?.type.peek() || columnType;
|
||||
const {keyMapFunc, labelMapFunc, valueMapFunc} = getMapFuncs(columnType, tableData, fieldOrColumn);
|
||||
@@ -668,6 +685,7 @@ export function createFilterMenu(
|
||||
columnFilter,
|
||||
filterInfo,
|
||||
valueCount: valueCountsArr,
|
||||
gristDoc,
|
||||
});
|
||||
|
||||
return columnFilterMenu(openCtl, {
|
||||
@@ -676,21 +694,31 @@ export function createFilterMenu(
|
||||
onClose: () => { openCtl.close(); onClose(); },
|
||||
doSave: (reset: boolean = false) => {
|
||||
const spec = columnFilter.makeFilterJson();
|
||||
sectionFilter.viewSection.setFilter(
|
||||
const {viewSection} = sectionFilter;
|
||||
viewSection.setFilter(
|
||||
fieldOrColumn.origCol().origColRef(),
|
||||
{filter: spec}
|
||||
);
|
||||
if (reset) {
|
||||
sectionFilter.resetTemporaryRows();
|
||||
}
|
||||
|
||||
// Check if the save was for a new filter, and if that new filter was pinned. If it was, and
|
||||
// it is the second pinned filter in the section, trigger a tip that explains how multiple
|
||||
// filters in the same section work.
|
||||
const isNewPinnedFilter = columnFilter.initialFilterJson === NEW_FILTER_JSON && isPinned();
|
||||
if (isNewPinnedFilter && viewSection.pinnedActiveFilters.get().length === 2) {
|
||||
viewSection.showNestedFilteringPopup.set(true);
|
||||
}
|
||||
},
|
||||
doCancel: () => {
|
||||
const {viewSection} = sectionFilter;
|
||||
if (columnFilter.initialFilterJson === NEW_FILTER_JSON) {
|
||||
sectionFilter.viewSection.revertFilter(fieldOrColumn.origCol().origColRef());
|
||||
viewSection.revertFilter(fieldOrColumn.origCol().origColRef());
|
||||
} else {
|
||||
const initialFilter = columnFilter.initialFilterJson;
|
||||
columnFilter.setState(initialFilter);
|
||||
sectionFilter.viewSection.setFilter(
|
||||
viewSection.setFilter(
|
||||
fieldOrColumn.origCol().origColRef(),
|
||||
{filter: initialFilter, pinned: model.initialPinned}
|
||||
);
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
* Orgs, workspaces and docs are fetched asynchronously on build via the passed in API.
|
||||
*/
|
||||
import {loadUserManager} from 'app/client/lib/imports';
|
||||
import {reportError} from 'app/client/models/AppModel';
|
||||
import {AppModel, reportError} from 'app/client/models/AppModel';
|
||||
import {docUrl, urlState} from 'app/client/models/gristUrlState';
|
||||
import {getTimeFromNow, HomeModel, makeLocalViewSettings, ViewSettings} from 'app/client/models/HomeModel';
|
||||
import {getWorkspaceInfo, workspaceName} from 'app/client/models/WorkspaceInfo';
|
||||
@@ -14,6 +14,7 @@ import {buildUpgradeButton} from 'app/client/ui/ProductUpgrades';
|
||||
import {buildPinnedDoc, createPinnedDocs} from 'app/client/ui/PinnedDocs';
|
||||
import {shadowScroll} from 'app/client/ui/shadowScroll';
|
||||
import {transition} from 'app/client/ui/transitions';
|
||||
import {showWelcomeCoachingCall} from 'app/client/ui/WelcomeCoachingCall';
|
||||
import {showWelcomeQuestions} from 'app/client/ui/WelcomeQuestions';
|
||||
import {createVideoTourTextButton} from 'app/client/ui/OpenVideoTour';
|
||||
import {buttonSelect, cssButtonSelect} from 'app/client/ui2018/buttonSelect';
|
||||
@@ -26,7 +27,7 @@ import {IHomePage} from 'app/common/gristUrls';
|
||||
import {SortPref, ViewPref} from 'app/common/Prefs';
|
||||
import * as roles from 'app/common/roles';
|
||||
import {Document, Workspace} from 'app/common/UserAPI';
|
||||
import {computed, Computed, dom, DomArg, DomContents, IDisposableOwner,
|
||||
import {computed, Computed, dom, DomArg, DomContents, DomElementArg, IDisposableOwner,
|
||||
makeTestId, observable, Observable} from 'grainjs';
|
||||
import {buildTemplateDocs} from 'app/client/ui/TemplateDocs';
|
||||
import {makeT} from 'app/client/lib/localization';
|
||||
@@ -44,20 +45,30 @@ const testId = makeTestId('test-dm-');
|
||||
* Usage:
|
||||
* dom('div', createDocMenu(homeModel))
|
||||
*/
|
||||
export function createDocMenu(home: HomeModel) {
|
||||
return dom.domComputed(home.loading, loading => (
|
||||
loading === 'slow' ? css.spinner(loadingSpinner()) :
|
||||
loading ? null :
|
||||
dom.create(createLoadedDocMenu, home)
|
||||
));
|
||||
export function createDocMenu(home: HomeModel): DomElementArg[] {
|
||||
return [
|
||||
attachWelcomePopups(home.app),
|
||||
dom.domComputed(home.loading, loading => (
|
||||
loading === 'slow' ? css.spinner(loadingSpinner()) :
|
||||
loading ? null :
|
||||
dom.create(createLoadedDocMenu, home)
|
||||
))
|
||||
];
|
||||
}
|
||||
|
||||
function attachWelcomePopups(app: AppModel): (el: Element) => void {
|
||||
return (element: Element) => {
|
||||
const isShowingPopup = showWelcomeQuestions(app.userPrefsObs);
|
||||
if (isShowingPopup) { return; }
|
||||
|
||||
showWelcomeCoachingCall(element, app);
|
||||
};
|
||||
}
|
||||
|
||||
function createLoadedDocMenu(owner: IDisposableOwner, home: HomeModel) {
|
||||
const flashDocId = observable<string|null>(null);
|
||||
const upgradeButton = buildUpgradeButton(owner, home.app);
|
||||
return css.docList(
|
||||
showWelcomeQuestions(home.app.userPrefsObs),
|
||||
css.docMenu(
|
||||
dom.maybe(!home.app.currentFeatures.workspaces, () => [
|
||||
css.docListHeader(t('ServiceNotAvailable')),
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { GristDoc } from "app/client/components/GristDoc";
|
||||
import { NEW_FILTER_JSON } from "app/client/models/ColumnFilter";
|
||||
import { ColumnRec, ViewSectionRec } from "app/client/models/DocModel";
|
||||
import { FilterInfo } from "app/client/models/entities/ViewSectionRec";
|
||||
@@ -9,11 +10,22 @@ import { menu, menuItemAsync } from "app/client/ui2018/menus";
|
||||
import { dom, IDisposableOwner, IDomArgs, styled } from "grainjs";
|
||||
import { IMenuOptions, PopupControl } from "popweasel";
|
||||
|
||||
export function filterBar(_owner: IDisposableOwner, viewSection: ViewSectionRec) {
|
||||
export function filterBar(
|
||||
_owner: IDisposableOwner,
|
||||
gristDoc: GristDoc,
|
||||
viewSection: ViewSectionRec
|
||||
) {
|
||||
const popupControls = new WeakMap<ColumnRec, PopupControl>();
|
||||
return cssFilterBar(
|
||||
testId('filter-bar'),
|
||||
dom.forEach(viewSection.activeFilters, (filterInfo) => makeFilterField(filterInfo, popupControls)),
|
||||
dom.maybe(viewSection.showNestedFilteringPopup, () => {
|
||||
return dom('div',
|
||||
gristDoc.behavioralPrompts.attachTip('nestedFiltering', {
|
||||
onDispose: () => viewSection.showNestedFilteringPopup.set(false),
|
||||
}),
|
||||
);
|
||||
}),
|
||||
makePlusButton(viewSection, popupControls),
|
||||
cssFilterBar.cls('-hidden', use => use(viewSection.pinnedActiveFilters).length === 0),
|
||||
);
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import {cssLink} from 'app/client/ui2018/links';
|
||||
import {commonUrls} from 'app/common/gristUrls';
|
||||
import {BehavioralPrompt} from 'app/common/Prefs';
|
||||
import {dom, DomContents, DomElementArg, styled} from 'grainjs';
|
||||
import { icon } from '../ui2018/icons';
|
||||
|
||||
const cssTooltipContent = styled('div', `
|
||||
display: flex;
|
||||
@@ -8,7 +10,20 @@ const cssTooltipContent = styled('div', `
|
||||
row-gap: 8px;
|
||||
`);
|
||||
|
||||
type TooltipName =
|
||||
const cssBoldText = styled('span', `
|
||||
font-weight: 600;
|
||||
`);
|
||||
|
||||
const cssItalicizedText = styled('span', `
|
||||
font-style: italic;
|
||||
`);
|
||||
|
||||
const cssIcon = styled(icon, `
|
||||
height: 18px;
|
||||
width: 18px;
|
||||
`);
|
||||
|
||||
export type Tooltip =
|
||||
| 'dataSize'
|
||||
| 'setTriggerFormula'
|
||||
| 'selectBy'
|
||||
@@ -19,7 +34,8 @@ type TooltipName =
|
||||
|
||||
export type TooltipContentFunc = (...domArgs: DomElementArg[]) => DomContents;
|
||||
|
||||
export const GristTooltips: Record<TooltipName, TooltipContentFunc> = {
|
||||
// TODO: i18n
|
||||
export const GristTooltips: Record<Tooltip, TooltipContentFunc> = {
|
||||
dataSize: (...args: DomElementArg[]) => cssTooltipContent(
|
||||
dom('div', 'The total size of all data in this document, excluding attachments.'),
|
||||
dom('div', 'Updates every 5 minutes.'),
|
||||
@@ -80,3 +96,96 @@ export const GristTooltips: Record<TooltipName, TooltipContentFunc> = {
|
||||
...args,
|
||||
),
|
||||
};
|
||||
|
||||
export interface BehavioralPromptContent {
|
||||
title: string;
|
||||
content: (...domArgs: DomElementArg[]) => DomContents;
|
||||
}
|
||||
|
||||
// TODO: i18n
|
||||
export const GristBehavioralPrompts: Record<BehavioralPrompt, BehavioralPromptContent> = {
|
||||
referenceColumns: {
|
||||
title: 'Reference Columns',
|
||||
content: (...args: DomElementArg[]) => cssTooltipContent(
|
||||
dom('div', 'Reference columns are the key to ', cssBoldText('relational'), ' data in Grist.'),
|
||||
dom('div', 'They allow for one record to point (or refer) to another.'),
|
||||
dom('div',
|
||||
cssLink({href: commonUrls.helpColRefs, target: '_blank'}, 'Learn more.'),
|
||||
),
|
||||
...args,
|
||||
),
|
||||
},
|
||||
referenceColumnsConfig: {
|
||||
title: 'Reference Columns',
|
||||
content: (...args: DomElementArg[]) => cssTooltipContent(
|
||||
dom('div', 'Select the table to link to.'),
|
||||
dom('div', 'Cells in a reference column always identify an ', cssItalicizedText('entire'),
|
||||
' record in that table, but you may select which column from that record to show.'),
|
||||
dom('div',
|
||||
cssLink({href: commonUrls.helpUnderstandingReferenceColumns, target: '_blank'}, 'Learn more.'),
|
||||
),
|
||||
...args,
|
||||
),
|
||||
},
|
||||
rawDataPage: {
|
||||
title: 'Raw Data page',
|
||||
content: (...args: DomElementArg[]) => cssTooltipContent(
|
||||
dom('div', 'The Raw Data page lists all data tables in your document, '
|
||||
+ 'including summary tables and tables not included in page layouts.'),
|
||||
dom('div', cssLink({href: commonUrls.helpRawData, target: '_blank'}, 'Learn more.')),
|
||||
...args,
|
||||
),
|
||||
},
|
||||
accessRules: {
|
||||
title: 'Access Rules',
|
||||
content: (...args: DomElementArg[]) => cssTooltipContent(
|
||||
dom('div', 'Access rules give you the power to create nuanced rules '
|
||||
+ 'to determine who can see or edit which parts of your document.'),
|
||||
dom('div', cssLink({href: commonUrls.helpAccessRules, target: '_blank'}, 'Learn more.')),
|
||||
...args,
|
||||
),
|
||||
},
|
||||
filterButtons: {
|
||||
title: 'Filter Buttons',
|
||||
content: (...args: DomElementArg[]) => cssTooltipContent(
|
||||
dom('div', 'Pinned filters are displayed as buttons above the widget.'),
|
||||
dom('div', 'Unpin to hide the the button while keeping the filter.'),
|
||||
dom('div', cssLink({href: commonUrls.helpFilterButtons, target: '_blank'}, 'Learn more.')),
|
||||
...args,
|
||||
),
|
||||
},
|
||||
nestedFiltering: {
|
||||
title: 'Nested Filtering',
|
||||
content: (...args: DomElementArg[]) => cssTooltipContent(
|
||||
dom('div', 'You can filter by more than one column.'),
|
||||
dom('div', 'Only those rows will appear which match all of the filters.'),
|
||||
...args,
|
||||
),
|
||||
},
|
||||
pageWidgetPicker: {
|
||||
title: 'Selecting Data',
|
||||
content: (...args: DomElementArg[]) => cssTooltipContent(
|
||||
dom('div', 'Select the table containing the data to show.'),
|
||||
dom('div', 'Use the 𝚺 icon to create summary (or pivot) tables, for totals or subtotals.'),
|
||||
...args,
|
||||
),
|
||||
},
|
||||
pageWidgetPickerSelectBy: {
|
||||
title: 'Linking Widgets',
|
||||
content: (...args: DomElementArg[]) => cssTooltipContent(
|
||||
dom('div', 'Link your new widget to an existing widget on this page.'),
|
||||
dom('div', `This is the secret to Grist's dynamic and productive layouts.`),
|
||||
dom('div', cssLink({href: commonUrls.helpLinkingWidgets, target: '_blank'}, 'Learn more.')),
|
||||
...args,
|
||||
),
|
||||
},
|
||||
editCardLayout: {
|
||||
title: 'Editing Card Layout',
|
||||
content: (...args: DomElementArg[]) => cssTooltipContent(
|
||||
dom('div', 'Rearrange the fields in your card by dragging and resizing cells.'),
|
||||
dom('div', 'Clicking ', cssIcon('EyeHide'),
|
||||
' in each cell hides the field from this view without deleting it.'),
|
||||
...args,
|
||||
),
|
||||
},
|
||||
};
|
||||
|
||||
@@ -3,7 +3,7 @@ import {makeT} from 'app/client/lib/localization';
|
||||
import {cssLinkText, cssPageEntryMain, cssPageIcon, cssPageLink} from 'app/client/ui/LeftPanelCommon';
|
||||
import {theme} from 'app/client/ui2018/cssVars';
|
||||
import {icon} from 'app/client/ui2018/icons';
|
||||
import {modal} from 'app/client/ui2018/modals';
|
||||
import {cssModalCloseButton, modal} from 'app/client/ui2018/modals';
|
||||
import {commonUrls, shouldHideUiElement} from 'app/common/gristUrls';
|
||||
import {dom, makeTestId, styled} from 'grainjs';
|
||||
|
||||
@@ -19,7 +19,7 @@ const testId = makeTestId('test-video-tour-');
|
||||
(ctl) => {
|
||||
return [
|
||||
cssModal.cls(''),
|
||||
cssCloseButton(
|
||||
cssModalCloseButton(
|
||||
cssCloseIcon('CrossBig'),
|
||||
dom.on('click', () => ctl.close()),
|
||||
testId('close'),
|
||||
@@ -127,19 +127,6 @@ const cssVideoIcon = styled(icon, `
|
||||
}
|
||||
`);
|
||||
|
||||
const cssCloseButton = styled('div', `
|
||||
align-self: flex-end;
|
||||
margin: -8px;
|
||||
padding: 4px;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
--icon-color: ${theme.modalCloseButtonFg};
|
||||
|
||||
&:hover {
|
||||
background-color: ${theme.hover};
|
||||
}
|
||||
`);
|
||||
|
||||
const cssCloseIcon = styled(icon, `
|
||||
padding: 12px;
|
||||
`);
|
||||
|
||||
@@ -9,7 +9,7 @@ import {transition, TransitionWatcher} from 'app/client/ui/transitions';
|
||||
import {cssHideForNarrowScreen, isScreenResizing, mediaNotSmall, mediaSmall, theme} from 'app/client/ui2018/cssVars';
|
||||
import {isNarrowScreenObs} from 'app/client/ui2018/cssVars';
|
||||
import {icon} from 'app/client/ui2018/icons';
|
||||
import {dom, DomArg, MultiHolder, noTestId, Observable, styled, subscribe, TestId} from "grainjs";
|
||||
import {dom, DomElementArg, MultiHolder, noTestId, Observable, styled, subscribe, TestId} from "grainjs";
|
||||
import noop from 'lodash/noop';
|
||||
import once from 'lodash/once';
|
||||
import {SessionObs} from 'app/client/lib/sessionObs';
|
||||
@@ -26,21 +26,21 @@ export interface PageSidePanel {
|
||||
panelWidth: Observable<number>;
|
||||
panelOpen: Observable<boolean>;
|
||||
hideOpener?: boolean; // If true, don't show the opener handle.
|
||||
header: DomArg;
|
||||
content: DomArg;
|
||||
header: DomElementArg;
|
||||
content: DomElementArg;
|
||||
}
|
||||
|
||||
export interface PageContents {
|
||||
leftPanel: PageSidePanel;
|
||||
rightPanel?: PageSidePanel; // If omitted, the right panel isn't shown at all.
|
||||
|
||||
headerMain: DomArg;
|
||||
contentMain: DomArg;
|
||||
headerMain: DomElementArg;
|
||||
contentMain: DomElementArg;
|
||||
|
||||
onResize?: () => void; // Callback for when either pane is opened, closed, or resized.
|
||||
testId?: TestId;
|
||||
contentTop?: DomArg;
|
||||
contentBottom?: DomArg;
|
||||
contentTop?: DomElementArg;
|
||||
contentBottom?: DomElementArg;
|
||||
}
|
||||
|
||||
export function pagePanels(page: PageContents) {
|
||||
@@ -55,6 +55,7 @@ export function pagePanels(page: PageContents) {
|
||||
let lastLeftOpen = left.panelOpen.get();
|
||||
let lastRightOpen = right?.panelOpen.get() || false;
|
||||
let leftPaneDom: HTMLElement;
|
||||
let rightPaneDom: HTMLElement;
|
||||
let onLeftTransitionFinish = noop;
|
||||
|
||||
// When switching to mobile mode, close panels; when switching to desktop, restore the
|
||||
@@ -89,6 +90,16 @@ export function pagePanels(page: PageContents) {
|
||||
watcher.onDispose(() => resolve(undefined));
|
||||
left.panelOpen.set(true);
|
||||
}),
|
||||
rightPanelOpen: () => new Promise((resolve, reject) => {
|
||||
if (!right) {
|
||||
reject(new Error('PagePanels rightPanelOpen called while right panel is undefined'));
|
||||
return;
|
||||
}
|
||||
|
||||
const watcher = new TransitionWatcher(rightPaneDom);
|
||||
watcher.onDispose(() => resolve(undefined));
|
||||
right.panelOpen.set(true);
|
||||
}),
|
||||
}, null, true);
|
||||
let contentWrapper: HTMLElement;
|
||||
return cssPageContainer(
|
||||
@@ -262,7 +273,7 @@ export function pagePanels(page: PageContents) {
|
||||
dom.show(right.panelOpen),
|
||||
cssHideForNarrowScreen.cls('')),
|
||||
|
||||
cssRightPane(
|
||||
rightPaneDom = cssRightPane(
|
||||
testId('right-panel'),
|
||||
cssRightPaneHeader(right.header),
|
||||
right.content,
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import {makeT} from 'app/client/lib/localization';
|
||||
import { BehavioralPrompts } from 'app/client/components/BehavioralPrompts';
|
||||
import { GristDoc } from 'app/client/components/GristDoc';
|
||||
import { makeT } from 'app/client/lib/localization';
|
||||
import { reportError } from 'app/client/models/AppModel';
|
||||
import { ColumnRec, DocModel, TableRec, ViewSectionRec } from 'app/client/models/DocModel';
|
||||
import { ColumnRec, TableRec, ViewSectionRec } from 'app/client/models/DocModel';
|
||||
import { GristTooltips } from 'app/client/ui/GristTooltips';
|
||||
import { linkId, NoLink } from 'app/client/ui/selectBy';
|
||||
import { withInfoTooltip } from 'app/client/ui/tooltips';
|
||||
@@ -10,7 +12,7 @@ import { theme, vars } from "app/client/ui2018/cssVars";
|
||||
import { icon } from "app/client/ui2018/icons";
|
||||
import { spinnerModal } from 'app/client/ui2018/modals';
|
||||
import { isLongerThan, nativeCompare } from "app/common/gutil";
|
||||
import { computed, Computed, Disposable, dom, domComputed, fromKo, IOption, select} from "grainjs";
|
||||
import { computed, Computed, Disposable, dom, domComputed, DomElementArg, fromKo, IOption, select} from "grainjs";
|
||||
import { makeTestId, Observable, onKeyDown, styled} from "grainjs";
|
||||
import without = require('lodash/without');
|
||||
import Popper from 'popper.js';
|
||||
@@ -99,7 +101,7 @@ export type ISaveFunc = (val: IPageWidget) => Promise<any>;
|
||||
const DELAY_BEFORE_SPINNER_MS = 500;
|
||||
|
||||
// Attaches the page widget picker to elem to open on 'click' on the left.
|
||||
export function attachPageWidgetPicker(elem: HTMLElement, docModel: DocModel, onSave: ISaveFunc,
|
||||
export function attachPageWidgetPicker(elem: HTMLElement, gristDoc: GristDoc, onSave: ISaveFunc,
|
||||
options: IOptions = {}) {
|
||||
// Overrides .placement, this is needed to enable the page widget to update position when user
|
||||
// expand the `Group By` panel.
|
||||
@@ -108,7 +110,7 @@ export function attachPageWidgetPicker(elem: HTMLElement, docModel: DocModel, on
|
||||
// particular listening to value.summarize to update popup position could be done directly in
|
||||
// code).
|
||||
options.placement = 'left';
|
||||
const domCreator = (ctl: IOpenController) => buildPageWidgetPicker(ctl, docModel, onSave, options);
|
||||
const domCreator = (ctl: IOpenController) => buildPageWidgetPicker(ctl, gristDoc, onSave, options);
|
||||
setPopupToCreateDom(elem, domCreator, {
|
||||
placement: 'left',
|
||||
trigger: ['click'],
|
||||
@@ -118,10 +120,10 @@ export function attachPageWidgetPicker(elem: HTMLElement, docModel: DocModel, on
|
||||
}
|
||||
|
||||
// Open page widget widget picker on the right of element.
|
||||
export function openPageWidgetPicker(elem: HTMLElement, docModel: DocModel, onSave: ISaveFunc,
|
||||
export function openPageWidgetPicker(elem: HTMLElement, gristDoc: GristDoc, onSave: ISaveFunc,
|
||||
options: IOptions = {}) {
|
||||
popupOpen(elem, (ctl) => buildPageWidgetPicker(
|
||||
ctl, docModel, onSave, options
|
||||
ctl, gristDoc, onSave, options
|
||||
), { placement: 'right' });
|
||||
}
|
||||
|
||||
@@ -131,11 +133,12 @@ export function openPageWidgetPicker(elem: HTMLElement, docModel: DocModel, onSa
|
||||
// to overlay the trigger element (which could happen when the 'Group By' panel is expanded for the
|
||||
// first time). When saving is taking time, show a modal spinner (see DELAY_BEFORE_SPINNER_MS).
|
||||
export function buildPageWidgetPicker(
|
||||
ctl: IOpenController,
|
||||
docModel: DocModel,
|
||||
onSave: ISaveFunc,
|
||||
options: IOptions = {}) {
|
||||
|
||||
ctl: IOpenController,
|
||||
gristDoc: GristDoc,
|
||||
onSave: ISaveFunc,
|
||||
options: IOptions = {}
|
||||
) {
|
||||
const {behavioralPrompts, docModel} = gristDoc;
|
||||
const tables = fromKo(docModel.visibleTables.getObservable());
|
||||
const columns = fromKo(docModel.columns.createAllRowsModel('parentPos').getObservable());
|
||||
|
||||
@@ -204,7 +207,7 @@ export function buildPageWidgetPicker(
|
||||
|
||||
// dom
|
||||
return cssPopupWrapper(
|
||||
dom.create(PageWidgetSelect, value, tables, columns, onSaveCB, options),
|
||||
dom.create(PageWidgetSelect, value, tables, columns, onSaveCB, behavioralPrompts, options),
|
||||
|
||||
// gives focus and binds keydown events
|
||||
(elem: any) => { setTimeout(() => elem.focus(), 0); },
|
||||
@@ -223,7 +226,6 @@ export type IWidgetValueObs = {
|
||||
|
||||
|
||||
export interface ISelectOptions {
|
||||
|
||||
// the button's label
|
||||
buttonLabel?: string;
|
||||
|
||||
@@ -274,6 +276,7 @@ export class PageWidgetSelect extends Disposable {
|
||||
private _tables: Observable<TableRec[]>,
|
||||
private _columns: Observable<ColumnRec[]>,
|
||||
private _onSave: () => Promise<void>,
|
||||
private _behavioralPrompts: BehavioralPrompts,
|
||||
private _options: ISelectOptions = {}
|
||||
) { super(); }
|
||||
|
||||
@@ -304,9 +307,15 @@ export class PageWidgetSelect extends Disposable {
|
||||
cssIcon('TypeTable'), 'New Table',
|
||||
// prevent the selection of 'New Table' if it is disabled
|
||||
dom.on('click', (ev) => !this._isNewTableDisabled.get() && this._selectTable('New Table')),
|
||||
this._behavioralPrompts.attachTip('pageWidgetPicker', {
|
||||
popupOptions: {
|
||||
attach: null,
|
||||
placement: 'right-start',
|
||||
}
|
||||
}),
|
||||
cssEntry.cls('-selected', (use) => use(this._value.table) === 'New Table'),
|
||||
cssEntry.cls('-disabled', this._isNewTableDisabled),
|
||||
testId('table')
|
||||
testId('table'),
|
||||
),
|
||||
dom.forEach(this._tables, (table) => dom('div',
|
||||
cssEntryWrapper(
|
||||
@@ -355,7 +364,14 @@ export class PageWidgetSelect extends Disposable {
|
||||
testId('selectby'))
|
||||
),
|
||||
GristTooltips.selectBy(),
|
||||
{tooltipMenuOptions: {attach: null}},
|
||||
{tooltipMenuOptions: {attach: null}, domArgs: [
|
||||
this._behavioralPrompts.attachTip('pageWidgetPickerSelectBy', {
|
||||
popupOptions: {
|
||||
attach: null,
|
||||
placement: 'bottom',
|
||||
}
|
||||
}),
|
||||
]},
|
||||
)
|
||||
),
|
||||
dom('div', {style: 'flex-grow: 1'}),
|
||||
@@ -427,8 +443,8 @@ export class PageWidgetSelect extends Disposable {
|
||||
|
||||
}
|
||||
|
||||
function header(label: string) {
|
||||
return cssHeader(dom('h4', label), testId('heading'));
|
||||
function header(label: string, ...args: DomElementArg[]) {
|
||||
return cssHeader(dom('h4', label), ...args, testId('heading'));
|
||||
}
|
||||
|
||||
const cssContainer = styled('div', `
|
||||
|
||||
@@ -537,7 +537,7 @@ export class RightPanel extends Disposable {
|
||||
const gristDoc = this._gristDoc;
|
||||
const section = gristDoc.viewModel.activeSection;
|
||||
const onSave = (val: IPageWidget) => gristDoc.saveViewSection(section.peek(), val);
|
||||
return (elem) => { attachPageWidgetPicker(elem, gristDoc.docModel, onSave, {
|
||||
return (elem) => { attachPageWidgetPicker(elem, gristDoc, onSave, {
|
||||
buttonLabel: t('Save'),
|
||||
value: () => toPageWidget(section.peek()),
|
||||
selectBy: (val) => gristDoc.selectBy(val),
|
||||
|
||||
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');
|
||||
|
||||
export function showWelcomeQuestions(userPrefsObs: Observable<UserPrefs>) {
|
||||
/**
|
||||
* Shows a modal with welcome questions if surveying is enabled and the user hasn't
|
||||
* dismissed the modal before.
|
||||
*
|
||||
* Returns a boolean indicating whether the modal was shown or not.
|
||||
*/
|
||||
export function showWelcomeQuestions(userPrefsObs: Observable<UserPrefs>): boolean {
|
||||
if (!(getGristConfig().survey && userPrefsObs.get()?.showNewUserQuestions)) {
|
||||
return null;
|
||||
return false;
|
||||
}
|
||||
|
||||
return saveModal((ctl, owner): ISaveModalOptions => {
|
||||
saveModal((ctl, owner): ISaveModalOptions => {
|
||||
const selection = choices.map(c => Observable.create(owner, false));
|
||||
const otherText = Observable.create(owner, '');
|
||||
const showQuestions = getUserPrefObs(userPrefsObs, 'showNewUserQuestions');
|
||||
@@ -54,6 +60,8 @@ export function showWelcomeQuestions(userPrefsObs: Observable<UserPrefs>) {
|
||||
modalArgs: cssModalCentered.cls(''),
|
||||
};
|
||||
});
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
const choices: Array<{icon: IconName, color: string, textKey: string}> = [
|
||||
|
||||
@@ -228,6 +228,8 @@ export const theme = {
|
||||
'black'),
|
||||
tooltipCloseButtonHoverBg: new CustomProp('theme-tooltip-close-button-hover-bg', undefined,
|
||||
'white'),
|
||||
tooltipPopupHeaderFg: new CustomProp('theme-tooltip-popup-header-fg', undefined, colors.light),
|
||||
tooltipPopupHeaderBg: new CustomProp('theme-tooltip-popup-header-bg', undefined, colors.lightGreen),
|
||||
|
||||
/* Modals */
|
||||
modalBg: new CustomProp('theme-modal-bg', undefined, 'white'),
|
||||
|
||||
@@ -98,6 +98,11 @@ export const menuCssClass = cssMenuElem.className;
|
||||
// Add grist-floating-menu class to support existing browser tests
|
||||
const defaults = { menuCssClass: menuCssClass + ' grist-floating-menu' };
|
||||
|
||||
export interface SelectOptions<T> extends weasel.ISelectUserOptions {
|
||||
/** Additional DOM element args to pass to each select option. */
|
||||
renderOptionArgs?: (option: IOptionFull<T | null>) => DomElementArg;
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a select dropdown widget. The observable `obs` reflects the value of the selected
|
||||
* option, and `optionArray` is an array (regular or observable) of option values and labels.
|
||||
@@ -121,15 +126,20 @@ const defaults = { menuCssClass: menuCssClass + ' grist-floating-menu' };
|
||||
* ]);
|
||||
* select(employee, allEmployees, {defLabel: "Select employee:"});
|
||||
*
|
||||
* const name = observable("alice");
|
||||
* const names = ["alice", "bob", "carol"];
|
||||
* select(name, names, {renderOptionArgs: (op) => console.log(`Rendered option ${op.value}`)});
|
||||
*
|
||||
* Note that this select element is not compatible with browser address autofill for usage in
|
||||
* forms, and that formSelect should be used for this purpose.
|
||||
*/
|
||||
export function select<T>(obs: Observable<T>, optionArray: MaybeObsArray<IOption<T>>,
|
||||
options: weasel.ISelectUserOptions = {}) {
|
||||
options: SelectOptions<T> = {}) {
|
||||
const {renderOptionArgs, ...weaselOptions} = options;
|
||||
const _menu = cssSelectMenuElem(testId('select-menu'));
|
||||
const _btn = cssSelectBtn(testId('select-open'));
|
||||
|
||||
const {menuCssClass: menuClass, ...otherOptions} = options;
|
||||
const {menuCssClass: menuClass, ...otherOptions} = weaselOptions;
|
||||
const selectOptions = {
|
||||
buttonArrow: cssInlineCollapseIcon('Collapse'),
|
||||
menuCssClass: _menu.className + ' ' + (menuClass || ''),
|
||||
@@ -141,6 +151,7 @@ export function select<T>(obs: Observable<T>, optionArray: MaybeObsArray<IOption
|
||||
cssOptionRow(
|
||||
op.icon ? cssOptionRowIcon(op.icon) : null,
|
||||
cssOptionLabel(op.label),
|
||||
renderOptionArgs ? renderOptionArgs(op) : null,
|
||||
testId('select-row')
|
||||
)
|
||||
) as HTMLElement; // TODO: should be changed in weasel
|
||||
|
||||
@@ -7,7 +7,7 @@ import {bigBasicButton, bigPrimaryButton, cssButton} from 'app/client/ui2018/but
|
||||
import {mediaSmall, testId, theme, vars} from 'app/client/ui2018/cssVars';
|
||||
import {loadingSpinner} from 'app/client/ui2018/loaders';
|
||||
import {waitGrainObs} from 'app/common/gutil';
|
||||
import {IOpenController, IPopupDomCreator, IPopupOptions, PopupControl, popupOpen} from 'popweasel';
|
||||
import {IOpenController, IPopupOptions, PopupControl, popupOpen} from 'popweasel';
|
||||
import {Computed, Disposable, dom, DomContents, DomElementArg, input, keyframes,
|
||||
MultiHolder, Observable, styled} from 'grainjs';
|
||||
import {cssMenuElem} from 'app/client/ui2018/menus';
|
||||
@@ -483,7 +483,7 @@ export function cssModalWidth(style: ModalWidth) {
|
||||
*/
|
||||
export function modalTooltip(
|
||||
reference: Element,
|
||||
domCreator: IPopupDomCreator,
|
||||
domCreator: (ctl: IOpenController) => DomElementArg,
|
||||
options: IPopupOptions = {}
|
||||
): PopupControl {
|
||||
return popupOpen(reference, (ctl: IOpenController) => {
|
||||
@@ -496,7 +496,7 @@ export function modalTooltip(
|
||||
|
||||
/* CSS styled components */
|
||||
|
||||
const cssModalTooltip = styled(cssMenuElem, `
|
||||
export const cssModalTooltip = styled(cssMenuElem, `
|
||||
padding: 16px 24px;
|
||||
background: ${theme.modalBg};
|
||||
border-radius: 3px;
|
||||
@@ -562,6 +562,19 @@ export const cssModalButtons = styled('div', `
|
||||
}
|
||||
`);
|
||||
|
||||
export const cssModalCloseButton = styled('div', `
|
||||
align-self: flex-end;
|
||||
margin: -8px;
|
||||
padding: 4px;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
--icon-color: ${theme.modalCloseButtonFg};
|
||||
|
||||
&:hover {
|
||||
background-color: ${theme.hover};
|
||||
}
|
||||
`);
|
||||
|
||||
const cssFadeIn = keyframes(`
|
||||
from {background-color: transparent}
|
||||
`);
|
||||
|
||||
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 _readonly: Computed<boolean>;
|
||||
private readonly _comments: ko.Computed<boolean>;
|
||||
private readonly _showRefConfigPopup: ko.Observable<boolean>;
|
||||
|
||||
public constructor(public readonly gristDoc: GristDoc, public readonly field: ViewFieldRec,
|
||||
private _cursor: Cursor, private _options: { isPreview?: boolean } = {}) {
|
||||
@@ -202,6 +203,8 @@ export class FieldBuilder extends Disposable {
|
||||
}, this).extend({ deferred: true }));
|
||||
|
||||
this.diffImpl = this.autoDispose(DiffBox.create(this.field));
|
||||
|
||||
this._showRefConfigPopup = ko.observable(false);
|
||||
}
|
||||
|
||||
public buildSelectWidgetDom() {
|
||||
@@ -261,6 +264,9 @@ export class FieldBuilder extends Disposable {
|
||||
selectType.onWrite(newType => {
|
||||
const sameType = newType === this._readOnlyPureType.peek();
|
||||
if (!sameType || commonType.get() === 'mixed') {
|
||||
if (['Ref', 'RefList'].includes(newType)) {
|
||||
this._showRefConfigPopup(true);
|
||||
}
|
||||
return this._setType(newType);
|
||||
}
|
||||
});
|
||||
@@ -280,7 +286,24 @@ export class FieldBuilder extends Disposable {
|
||||
// If we are waiting for a server response
|
||||
use(this.isCallPending),
|
||||
menuCssClass: cssTypeSelectMenu.className,
|
||||
defaultLabel: 'Mixed types'
|
||||
defaultLabel: 'Mixed types',
|
||||
renderOptionArgs: (op) => {
|
||||
if (['Ref', 'RefList'].includes(selectType.get())) {
|
||||
// Don't show tip if a reference column type is already selected.
|
||||
return;
|
||||
}
|
||||
|
||||
if (op.label === 'Reference') {
|
||||
return this.gristDoc.behavioralPrompts.attachTip('referenceColumns', {
|
||||
popupOptions: {
|
||||
attach: `.${cssTypeSelectMenu.className}`,
|
||||
placement: 'left-start',
|
||||
}
|
||||
});
|
||||
} else {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}),
|
||||
testId('type-select'),
|
||||
grainjsDom.cls('tour-type-selector'),
|
||||
@@ -346,7 +369,17 @@ export class FieldBuilder extends Disposable {
|
||||
return use(this.origColumn.disableModifyBase) || use(this.field.config.multiselect);
|
||||
});
|
||||
return [
|
||||
cssLabel('DATA FROM TABLE'),
|
||||
cssLabel('DATA FROM TABLE',
|
||||
!this._showRefConfigPopup.peek() ? null : this.gristDoc.behavioralPrompts.attachTip(
|
||||
'referenceColumnsConfig',
|
||||
{
|
||||
onDispose: () => this._showRefConfigPopup(false),
|
||||
popupOptions: {
|
||||
placement: 'left-start',
|
||||
},
|
||||
}
|
||||
),
|
||||
),
|
||||
cssRow(
|
||||
dom.autoDispose(allTables),
|
||||
dom.autoDispose(isDisabled),
|
||||
|
||||
@@ -27,6 +27,10 @@ export interface UserPrefs extends Prefs {
|
||||
seenDeprecatedWarnings?: DeprecationWarning[];
|
||||
// List of dismissedPopups user have seen.
|
||||
dismissedPopups?: DismissedPopup[];
|
||||
// Behavioral prompt preferences.
|
||||
behavioralPrompts?: BehavioralPromptPrefs;
|
||||
// Welcome popups a user has dismissed.
|
||||
dismissedWelcomePopups?: DismissedReminder[];
|
||||
}
|
||||
|
||||
// A collection of preferences related to a combination of user and org.
|
||||
@@ -63,11 +67,47 @@ export const DeprecationWarning = StringUnion(
|
||||
);
|
||||
export type DeprecationWarning = typeof DeprecationWarning.type;
|
||||
|
||||
export const BehavioralPrompt = StringUnion(
|
||||
'referenceColumns',
|
||||
'referenceColumnsConfig',
|
||||
'rawDataPage',
|
||||
'accessRules',
|
||||
'filterButtons',
|
||||
'nestedFiltering',
|
||||
'pageWidgetPicker',
|
||||
'pageWidgetPickerSelectBy',
|
||||
'editCardLayout',
|
||||
);
|
||||
export type BehavioralPrompt = typeof BehavioralPrompt.type;
|
||||
|
||||
export interface BehavioralPromptPrefs {
|
||||
/** Defaults to false. */
|
||||
dontShowTips: boolean;
|
||||
/** List of tips that have been dismissed. */
|
||||
dismissedTips: BehavioralPrompt[];
|
||||
}
|
||||
|
||||
/**
|
||||
* List of all popups that user can see and dismiss
|
||||
*/
|
||||
export const DismissedPopup = StringUnion(
|
||||
'deleteRecords', // confirmation for deleting records keyboard shortcut
|
||||
'deleteFields' // confirmation for deleting columns keyboard shortcut
|
||||
'deleteFields', // confirmation for deleting columns keyboard shortcut
|
||||
);
|
||||
export type DismissedPopup = typeof DismissedPopup.type;
|
||||
|
||||
export const WelcomePopup = StringUnion(
|
||||
'coachingCall',
|
||||
);
|
||||
export type WelcomePopup = typeof WelcomePopup.type;
|
||||
|
||||
export interface DismissedReminder {
|
||||
/** The name of the popup. */
|
||||
id: WelcomePopup;
|
||||
/** Unix timestamp in ms when the popup was last dismissed. */
|
||||
lastDismissedAt: number;
|
||||
/** If non-null, Unix timestamp in ms when the popup will reappear. */
|
||||
nextAppearanceAt: number | null;
|
||||
/** The number of times this popup has been dismissed. */
|
||||
timesDismissed: number;
|
||||
}
|
||||
|
||||
@@ -75,6 +75,8 @@ export const ThemeColors = t.iface([], {
|
||||
"tooltip-close-button-fg": "string",
|
||||
"tooltip-close-button-hover-fg": "string",
|
||||
"tooltip-close-button-hover-bg": "string",
|
||||
"tooltip-popup-header-fg": "string",
|
||||
"tooltip-popup-header-bg": "string",
|
||||
"modal-bg": "string",
|
||||
"modal-backdrop": "string",
|
||||
"modal-border": "string",
|
||||
|
||||
@@ -87,6 +87,8 @@ export interface ThemeColors {
|
||||
'tooltip-close-button-fg': string;
|
||||
'tooltip-close-button-hover-fg': string;
|
||||
'tooltip-close-button-hover-bg': string;
|
||||
'tooltip-popup-header-fg': string;
|
||||
'tooltip-popup-header-bg': string;
|
||||
|
||||
/* Modals */
|
||||
'modal-bg': string;
|
||||
|
||||
@@ -62,8 +62,12 @@ export const MIN_URLID_PREFIX_LENGTH = 12;
|
||||
export const commonUrls = {
|
||||
help: getHelpCenterUrl(),
|
||||
helpAccessRules: "https://support.getgrist.com/access-rules",
|
||||
helpColRefs: "https://support.getgrist.com/col-refs",
|
||||
helpConditionalFormatting: "https://support.getgrist.com/conditional-formatting",
|
||||
helpFilterButtons: "https://support.getgrist.com/search-sort-filter/#filter-buttons",
|
||||
helpLinkingWidgets: "https://support.getgrist.com/linking-widgets",
|
||||
helpRawData: "https://support.getgrist.com/raw-data",
|
||||
helpUnderstandingReferenceColumns: "https://support.getgrist.com/col-refs/#understanding-reference-columns",
|
||||
helpTriggerFormulas: "https://support.getgrist.com/formulas/#trigger-formulas",
|
||||
helpTryingOutChanges: "https://support.getgrist.com/copying-docs/#trying-out-changes",
|
||||
plans: "https://www.getgrist.com/pricing",
|
||||
|
||||
@@ -66,6 +66,8 @@ export const GristDark: ThemeColors = {
|
||||
'tooltip-close-button-fg': 'white',
|
||||
'tooltip-close-button-hover-fg': 'black',
|
||||
'tooltip-close-button-hover-bg': 'white',
|
||||
'tooltip-popup-header-fg': '#EFEFEF',
|
||||
'tooltip-popup-header-bg': '#1DA270',
|
||||
|
||||
/* Modals */
|
||||
'modal-bg': '#32323F',
|
||||
|
||||
@@ -66,6 +66,8 @@ export const GristLight: ThemeColors = {
|
||||
'tooltip-close-button-fg': 'white',
|
||||
'tooltip-close-button-hover-fg': 'black',
|
||||
'tooltip-close-button-hover-bg': 'white',
|
||||
'tooltip-popup-header-fg': 'white',
|
||||
'tooltip-popup-header-bg': '#16B378',
|
||||
|
||||
/* Modals */
|
||||
'modal-bg': 'white',
|
||||
|
||||
Reference in New Issue
Block a user