mirror of
https://github.com/gristlabs/grist-core.git
synced 2024-10-27 20:44:07 +00:00
e52e15591d
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
593 lines
20 KiB
TypeScript
593 lines
20 KiB
TypeScript
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, 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';
|
|
import { getWidgetTypes, IWidgetType } from 'app/client/ui/widgetTypes';
|
|
import { bigPrimaryButton } from "app/client/ui2018/buttons";
|
|
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, DomElementArg, fromKo, IOption, select} from "grainjs";
|
|
import { makeTestId, Observable, onKeyDown, styled} from "grainjs";
|
|
import without = require('lodash/without');
|
|
import Popper from 'popper.js';
|
|
import { IOpenController, popupOpen, setPopupToCreateDom } from 'popweasel';
|
|
|
|
const t = makeT('PageWidgetPicker');
|
|
|
|
type TableId = number|'New Table'|null;
|
|
|
|
// Describes a widget selection.
|
|
export interface IPageWidget {
|
|
|
|
// The widget type
|
|
type: IWidgetType;
|
|
|
|
// The table (one of the listed tables or 'New Table')
|
|
table: TableId;
|
|
|
|
// Whether to summarize the table (not available for "New Table").
|
|
summarize: boolean;
|
|
|
|
// some of the listed columns to use to summarize the table.
|
|
columns: number[];
|
|
|
|
// link
|
|
link: string;
|
|
|
|
// the page widget section id (should be 0 for a to-be-saved new widget)
|
|
section: number;
|
|
}
|
|
|
|
// Creates a IPageWidget from a ViewSectionRec.
|
|
export function toPageWidget(section: ViewSectionRec): IPageWidget {
|
|
const link = linkId({
|
|
srcSectionRef: section.linkSrcSectionRef.peek(),
|
|
srcColRef: section.linkSrcColRef.peek(),
|
|
targetColRef: section.linkTargetColRef.peek()
|
|
});
|
|
return {
|
|
type: section.parentKey.peek() as IWidgetType,
|
|
table: section.table.peek().summarySourceTable.peek() || section.tableRef.peek(),
|
|
summarize: Boolean(section.table.peek().summarySourceTable.peek()),
|
|
columns: section.table.peek().columns.peek().peek()
|
|
.filter((col) => col.summarySourceCol.peek())
|
|
.map((col) => col.summarySourceCol.peek()),
|
|
link, section: section.id.peek()
|
|
};
|
|
}
|
|
|
|
|
|
export interface IOptions extends ISelectOptions {
|
|
|
|
// the initial selected value, we call the function when the popup get triggered
|
|
value?: () => IPageWidget;
|
|
|
|
// placement, directly passed to the underlying Popper library.
|
|
placement?: Popper.Placement;
|
|
}
|
|
|
|
const testId = makeTestId('test-wselect-');
|
|
|
|
// The picker disables some choices that do not make much sense. This function return the list of
|
|
// compatible types given the tableId and whether user is creating a new page or not.
|
|
function getCompatibleTypes(tableId: TableId, isNewPage: boolean|undefined): IWidgetType[] {
|
|
if (tableId !== 'New Table') {
|
|
return ['record', 'single', 'detail', 'chart', 'custom'];
|
|
} else if (isNewPage) {
|
|
// New view + new table means we'll be switching to the primary view.
|
|
return ['record'];
|
|
} else {
|
|
// The type 'chart' makes little sense when creating a new table.
|
|
return ['record', 'single', 'detail'];
|
|
}
|
|
}
|
|
|
|
// Whether table and type make for a valid selection whether the user is creating a new page or not.
|
|
function isValidSelection(table: TableId, type: IWidgetType, isNewPage: boolean|undefined) {
|
|
return table !== null && getCompatibleTypes(table, isNewPage).includes(type);
|
|
}
|
|
|
|
export type ISaveFunc = (val: IPageWidget) => Promise<any>;
|
|
|
|
// Delay in milliseconds, after a user click on the save btn, before we start showing a modal
|
|
// spinner. If saving completes before this time elapses (which is likely to happen for regular
|
|
// table) we don't show the modal spinner.
|
|
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, 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.
|
|
// TODO: remove .placement from the options of this method (note: breaking buildPageWidgetPicker
|
|
// into two steps, one for model creation and the other for building UI, seems promising. In
|
|
// particular listening to value.summarize to update popup position could be done directly in
|
|
// code).
|
|
options.placement = 'left';
|
|
const domCreator = (ctl: IOpenController) => buildPageWidgetPicker(ctl, gristDoc, onSave, options);
|
|
setPopupToCreateDom(elem, domCreator, {
|
|
placement: 'left',
|
|
trigger: ['click'],
|
|
attach: 'body',
|
|
boundaries: 'viewport'
|
|
});
|
|
}
|
|
|
|
// Open page widget widget picker on the right of element.
|
|
export function openPageWidgetPicker(elem: HTMLElement, gristDoc: GristDoc, onSave: ISaveFunc,
|
|
options: IOptions = {}) {
|
|
popupOpen(elem, (ctl) => buildPageWidgetPicker(
|
|
ctl, gristDoc, onSave, options
|
|
), { placement: 'right' });
|
|
}
|
|
|
|
// Builds a picker to stick into the popup. Takes care of setting up the initial selected value and
|
|
// bind various events to the popup behaviours: close popup on save, gives focus to the picker,
|
|
// binds cancel and save to Escape and Enter keydown events. Also takes care of preventing the popup
|
|
// 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,
|
|
gristDoc: GristDoc,
|
|
onSave: ISaveFunc,
|
|
options: IOptions = {}
|
|
) {
|
|
const {behavioralPrompts, docModel} = gristDoc;
|
|
const tables = fromKo(docModel.visibleTables.getObservable());
|
|
const columns = fromKo(docModel.columns.createAllRowsModel('parentPos').getObservable());
|
|
|
|
// default value for when it is omitted
|
|
const defaultValue: IPageWidget = {
|
|
type: 'record',
|
|
table: null, // when creating a new widget, let's initially have no table selected
|
|
summarize: false,
|
|
columns: [],
|
|
link: NoLink,
|
|
section: 0,
|
|
};
|
|
|
|
// get initial value and setup state for the picker.
|
|
const initValue = options.value && options.value() || defaultValue;
|
|
const value: IWidgetValueObs = {
|
|
type: Observable.create(ctl, initValue.type),
|
|
table: Observable.create(ctl, initValue.table),
|
|
summarize: Observable.create(ctl, initValue.summarize),
|
|
columns: Observable.create(ctl, initValue.columns),
|
|
link: Observable.create(ctl, initValue.link),
|
|
section: Observable.create(ctl, initValue.section)
|
|
};
|
|
|
|
// calls onSave and closes the popup. Failure must be handled by the caller.
|
|
async function onSaveCB() {
|
|
ctl.close();
|
|
const type = value.type.get();
|
|
const savePromise = onSave({
|
|
type,
|
|
table: value.table.get(),
|
|
summarize: value.summarize.get(),
|
|
columns: sortedAs(value.columns.get(), columns.get().map((col) => col.id.peek())),
|
|
link: value.link.get(),
|
|
section: value.section.get(),
|
|
});
|
|
if (value.table.get() === 'New Table') {
|
|
// Adding empty table will show a prompt, so we don't want to wait for it.
|
|
await savePromise;
|
|
} else {
|
|
// If savePromise throws an error, before or after timeout, we let the error propagate as it
|
|
// should be handle by the caller.
|
|
if (await isLongerThan(savePromise, DELAY_BEFORE_SPINNER_MS)) {
|
|
const label = getWidgetTypes(type).label;
|
|
await spinnerModal(t('BuildingWidget', { label }), savePromise);
|
|
}
|
|
}
|
|
}
|
|
|
|
// whether the current selection is valid
|
|
function isValid() {
|
|
return isValidSelection(value.table.get(), value.type.get(), options.isNewPage);
|
|
}
|
|
|
|
// Summarizing a table causes the 'Group By' panel to expand on the right. To prevent it from
|
|
// overlaying the trigger, we bind an update of the popup to it when it is on the left of the
|
|
// trigger.
|
|
// WARN: This does not work when the picker is triggered from a menu item because the trigger
|
|
// element does not exist anymore at this time so calling update will misplace the popup. However,
|
|
// this is not a problem at the time or writing because the picker is never placed at the left of
|
|
// a menu item (currently picker is only placed at the right of a menu item and at the left of a
|
|
// basic button).
|
|
if (options.placement && options.placement === 'left') {
|
|
ctl.autoDispose(value.summarize.addListener((val, old) => val && ctl.update()));
|
|
}
|
|
|
|
// dom
|
|
return cssPopupWrapper(
|
|
dom.create(PageWidgetSelect, value, tables, columns, onSaveCB, behavioralPrompts, options),
|
|
|
|
// gives focus and binds keydown events
|
|
(elem: any) => { setTimeout(() => elem.focus(), 0); },
|
|
onKeyDown({
|
|
Escape: () => ctl.close(),
|
|
Enter: () => isValid() && onSaveCB()
|
|
})
|
|
|
|
);
|
|
}
|
|
|
|
// Same as IWidgetValue but with observable values
|
|
export type IWidgetValueObs = {
|
|
[P in keyof IPageWidget]: Observable<IPageWidget[P]>;
|
|
};
|
|
|
|
|
|
export interface ISelectOptions {
|
|
// the button's label
|
|
buttonLabel?: string;
|
|
|
|
// Indicates whether the section builder is in a new view
|
|
isNewPage?: boolean;
|
|
|
|
// A callback to provides the links that are available to a page widget. It is called any time the
|
|
// user changes in the selected page widget (type, table, summary ...) and we update the "SELECT
|
|
// BY" dropdown with the result list of options. The "SELECT BY" dropdown is hidden if omitted.
|
|
selectBy?: (val: IPageWidget) => Array<IOption<string>>;
|
|
}
|
|
|
|
// the list of widget types in the order they should be listed by the widget.
|
|
const sectionTypes: IWidgetType[] = [
|
|
'record', 'single', 'detail', 'chart', 'custom'
|
|
];
|
|
|
|
|
|
// Returns dom that let a user select a page widget. User can select a widget type (id: 'grid',
|
|
// 'card', ...), one of `tables` and optionally some of the `columns` of the selected table if she
|
|
// wants to generate a summary. Clicking the `Add ...` button trigger `onSave()`. Note: this is an
|
|
// internal method used by widgetPicker, it is only exposed for testing reason.
|
|
export class PageWidgetSelect extends Disposable {
|
|
|
|
// an observable holding the list of options of the `select by` dropdown
|
|
private _selectByOptions = this._options.selectBy ?
|
|
Computed.create(this, (use) => {
|
|
// TODO: it is unfortunate to have to convert from IWidgetValueObs to IWidgetValue. Maybe
|
|
// better to change this._value to be Observable<IWidgetValue> instead.
|
|
const val = {
|
|
type: use(this._value.type),
|
|
table: use(this._value.table),
|
|
summarize: use(this._value.summarize),
|
|
columns: use(this._value.columns),
|
|
// should not have a dependency on .link
|
|
link: this._value.link.get(),
|
|
section: use(this._value.section),
|
|
};
|
|
return this._options.selectBy!(val);
|
|
}) :
|
|
null;
|
|
|
|
private _isNewTableDisabled = Computed.create(this, this._value.type, (use, t) => !isValidSelection(
|
|
'New Table', t, this._options.isNewPage));
|
|
|
|
constructor(
|
|
private _value: IWidgetValueObs,
|
|
private _tables: Observable<TableRec[]>,
|
|
private _columns: Observable<ColumnRec[]>,
|
|
private _onSave: () => Promise<void>,
|
|
private _behavioralPrompts: BehavioralPrompts,
|
|
private _options: ISelectOptions = {}
|
|
) { super(); }
|
|
|
|
public buildDom() {
|
|
return cssContainer(
|
|
testId('container'),
|
|
cssBody(
|
|
cssPanel(
|
|
header(t('SelectWidget')),
|
|
sectionTypes.map((value) => {
|
|
const {label, icon: iconName} = getWidgetTypes(value);
|
|
const disabled = computed(this._value.table, (use, tid) => this._isTypeDisabled(value, tid));
|
|
return cssEntry(
|
|
dom.autoDispose(disabled),
|
|
cssTypeIcon(iconName),
|
|
label,
|
|
dom.on('click', () => !disabled.get() && this._selectType(value)),
|
|
cssEntry.cls('-selected', (use) => use(this._value.type) === value),
|
|
cssEntry.cls('-disabled', disabled),
|
|
testId('type'),
|
|
);
|
|
}),
|
|
),
|
|
cssPanel(
|
|
testId('data'),
|
|
header(t('SelectData')),
|
|
cssEntry(
|
|
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'),
|
|
),
|
|
dom.forEach(this._tables, (table) => dom('div',
|
|
cssEntryWrapper(
|
|
cssEntry(cssIcon('TypeTable'),
|
|
cssLabel(dom.text(use => use(table.tableNameDef) || use(table.tableId))),
|
|
dom.on('click', () => this._selectTable(table.id())),
|
|
cssEntry.cls('-selected', (use) => use(this._value.table) === table.id()),
|
|
testId('table-label')
|
|
),
|
|
cssPivot(
|
|
cssBigIcon('Pivot'),
|
|
cssEntry.cls('-selected', (use) => use(this._value.summarize) && use(this._value.table) === table.id()),
|
|
dom.on('click', (ev, el) => this._selectPivot(table.id(), el as HTMLElement)),
|
|
testId('pivot'),
|
|
),
|
|
testId('table'),
|
|
)
|
|
)),
|
|
),
|
|
cssPanel(
|
|
header(t('GroupBy')),
|
|
dom.hide((use) => !use(this._value.summarize)),
|
|
domComputed(
|
|
(use) => use(this._columns)
|
|
.filter((col) => !col.isHiddenCol() && col.parentId() === use(this._value.table)),
|
|
(cols) => cols ?
|
|
dom.forEach(cols, (col) =>
|
|
cssEntry(cssIcon('FieldColumn'), cssFieldLabel(dom.text(col.label)),
|
|
dom.on('click', () => this._toggleColumnId(col.id())),
|
|
cssEntry.cls('-selected', (use) => use(this._value.columns).includes(col.id())),
|
|
testId('column')
|
|
)
|
|
) :
|
|
null
|
|
),
|
|
),
|
|
),
|
|
cssFooter(
|
|
cssFooterContent(
|
|
// If _selectByOptions exists and has more than then "NoLinkOption", show the selector.
|
|
dom.maybe((use) => this._selectByOptions && use(this._selectByOptions).length > 1, () =>
|
|
withInfoTooltip(
|
|
cssSelectBy(
|
|
cssSmallLabel('SELECT BY'),
|
|
dom.update(cssSelect(this._value.link, this._selectByOptions!),
|
|
testId('selectby'))
|
|
),
|
|
GristTooltips.selectBy(),
|
|
{tooltipMenuOptions: {attach: null}, domArgs: [
|
|
this._behavioralPrompts.attachTip('pageWidgetPickerSelectBy', {
|
|
popupOptions: {
|
|
attach: null,
|
|
placement: 'bottom',
|
|
}
|
|
}),
|
|
]},
|
|
)
|
|
),
|
|
dom('div', {style: 'flex-grow: 1'}),
|
|
bigPrimaryButton(
|
|
// TODO: The button's label of the page widget picker should read 'Close' instead when
|
|
// there are no changes.
|
|
this._options.buttonLabel || t('AddToPage'),
|
|
dom.prop('disabled', (use) => !isValidSelection(
|
|
use(this._value.table), use(this._value.type), this._options.isNewPage)
|
|
),
|
|
dom.on('click', () => this._onSave().catch(reportError)),
|
|
testId('addBtn'),
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
private _closeSummarizePanel() {
|
|
this._value.summarize.set(false);
|
|
this._value.columns.set([]);
|
|
}
|
|
|
|
private _openSummarizePanel() {
|
|
this._value.summarize.set(true);
|
|
}
|
|
|
|
private _selectType(t: IWidgetType) {
|
|
this._value.type.set(t);
|
|
}
|
|
|
|
private _selectTable(tid: TableId) {
|
|
if (tid !== this._value.table.get()) {
|
|
this._value.link.set(NoLink);
|
|
}
|
|
this._value.table.set(tid);
|
|
this._closeSummarizePanel();
|
|
}
|
|
|
|
private _isSelected(el: HTMLElement) {
|
|
return el.classList.contains(cssEntry.className + '-selected');
|
|
}
|
|
|
|
private _selectPivot(tid: TableId, pivotEl: HTMLElement) {
|
|
if (this._isSelected(pivotEl)) {
|
|
this._closeSummarizePanel();
|
|
} else {
|
|
if (tid !== this._value.table.get()) {
|
|
this._value.columns.set([]);
|
|
this._value.table.set(tid);
|
|
this._value.link.set(NoLink);
|
|
}
|
|
this._openSummarizePanel();
|
|
}
|
|
}
|
|
|
|
private _toggleColumnId(cid: number) {
|
|
const ids = this._value.columns.get();
|
|
const newIds = ids.includes(cid) ? without(ids, cid) : [...ids, cid];
|
|
this._value.columns.set(newIds);
|
|
}
|
|
|
|
private _isTypeDisabled(type: IWidgetType, table: TableId) {
|
|
if (table === null) {
|
|
return false;
|
|
}
|
|
return !getCompatibleTypes(table, this._options.isNewPage).includes(type);
|
|
}
|
|
|
|
}
|
|
|
|
function header(label: string, ...args: DomElementArg[]) {
|
|
return cssHeader(dom('h4', label), ...args, testId('heading'));
|
|
}
|
|
|
|
const cssContainer = styled('div', `
|
|
--outline: 1px solid ${theme.widgetPickerBorder};
|
|
|
|
max-height: 386px;
|
|
box-shadow: 0 2px 20px 0 ${theme.widgetPickerShadow};
|
|
border-radius: 2px;
|
|
display: flex;
|
|
flex-direction: column;
|
|
user-select: none;
|
|
background-color: ${theme.widgetPickerPrimaryBg};
|
|
`);
|
|
|
|
const cssPopupWrapper = styled('div', `
|
|
&:focus {
|
|
outline: none;
|
|
}
|
|
`);
|
|
|
|
const cssBody = styled('div', `
|
|
display: flex;
|
|
min-height: 0;
|
|
`);
|
|
|
|
// todo: try replace min-width / max-width
|
|
const cssPanel = styled('div', `
|
|
width: 224px;
|
|
font-size: ${vars.mediumFontSize};
|
|
overflow: auto;
|
|
padding-bottom: 18px;
|
|
&:nth-of-type(2n) {
|
|
background-color: ${theme.widgetPickerSecondaryBg};
|
|
outline: var(--outline);
|
|
}
|
|
`);
|
|
|
|
const cssHeader = styled('div', `
|
|
color: ${theme.text};
|
|
margin: 24px 0 24px 24px;
|
|
font-size: ${vars.mediumFontSize};
|
|
`);
|
|
|
|
const cssEntry = styled('div', `
|
|
color: ${theme.widgetPickerItemFg};
|
|
padding: 0 0 0 24px;
|
|
height: 32px;
|
|
display: flex;
|
|
flex-direction: row;
|
|
flex: 1 1 0px;
|
|
align-items: center;
|
|
white-space: nowrap;
|
|
overflow: hidden;
|
|
cursor: pointer;
|
|
&-selected {
|
|
background-color: ${theme.widgetPickerItemSelectedBg};
|
|
}
|
|
&-disabled {
|
|
color: ${theme.widgetPickerItemDisabledBg};
|
|
cursor: default;
|
|
}
|
|
&-disabled&-selected {
|
|
background-color: inherit;
|
|
}
|
|
`);
|
|
|
|
const cssIcon = styled(icon, `
|
|
margin-right: 8px;
|
|
flex-shrink: 0;
|
|
--icon-color: ${theme.widgetPickerIcon};
|
|
.${cssEntry.className}-disabled > & {
|
|
opacity: 0.25;
|
|
}
|
|
`);
|
|
|
|
const cssTypeIcon = styled(cssIcon, `
|
|
--icon-color: ${theme.widgetPickerPrimaryIcon};
|
|
`);
|
|
|
|
const cssLabel = styled('span', `
|
|
text-overflow: ellipsis;
|
|
overflow: hidden;
|
|
`);
|
|
|
|
const cssFieldLabel = styled(cssLabel, `
|
|
padding-right: 8px;
|
|
`);
|
|
|
|
const cssEntryWrapper = styled('div', `
|
|
display: flex;
|
|
align-items: center;
|
|
`);
|
|
|
|
const cssPivot = styled(cssEntry, `
|
|
width: 48px;
|
|
padding-left: 8px;
|
|
flex: 0 0 auto;
|
|
`);
|
|
|
|
const cssBigIcon = styled(icon, `
|
|
width: 24px;
|
|
height: 24px;
|
|
background-color: ${theme.widgetPickerSummaryIcon};
|
|
`);
|
|
|
|
const cssFooter = styled('div', `
|
|
display: flex;
|
|
border-top: var(--outline);
|
|
`);
|
|
|
|
const cssFooterContent = styled('div', `
|
|
flex-grow: 1;
|
|
height: 65px;
|
|
display: flex;
|
|
flex-direction: row;
|
|
align-items: center;
|
|
padding: 0 24px 0 24px;
|
|
`);
|
|
|
|
const cssSmallLabel = styled('span', `
|
|
color: ${theme.text};
|
|
font-size: ${vars.xsmallFontSize};
|
|
margin-right: 8px;
|
|
`);
|
|
|
|
const cssSelect = styled(select, `
|
|
color: ${theme.selectButtonFg};
|
|
background-color: ${theme.selectButtonBg};
|
|
flex: 1 0 160px;
|
|
width: 160px;
|
|
`);
|
|
|
|
const cssSelectBy = styled('div', `
|
|
display: flex;
|
|
align-items: center;
|
|
`);
|
|
|
|
// Returns a copy of array with its items sorted in the same order as they appear in other.
|
|
function sortedAs(array: number[], other: number[]) {
|
|
const order: {[id: number]: number} = {};
|
|
for (const [index, item] of other.entries()) {
|
|
order[item] = index;
|
|
}
|
|
return array.slice().sort((a, b) => nativeCompare(order[a], order[b]));
|
|
}
|