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:
@@ -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}> = [
|
||||
|
||||
Reference in New Issue
Block a user