(core) Add behavioral and coaching call popups

Summary:
Adds a new category of popups that are shown dynamically when
certain parts of the UI are first rendered, and a free coaching
call popup that's shown to users on their site home page.

Test Plan: Browser tests.

Reviewers: jarek

Reviewed By: jarek

Differential Revision: https://phab.getgrist.com/D3706
This commit is contained in:
George Gevoian
2022-12-19 21:06:39 -05:00
parent fa75c93d67
commit e52e15591d
41 changed files with 1236 additions and 126 deletions

View File

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

View File

@@ -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')),

View File

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

View File

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

View File

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

View File

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

View File

@@ -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', `

View File

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

View File

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

View File

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