import {BehavioralPromptsManager} from 'app/client/components/BehavioralPromptsManager'; 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 {PERMITTED_CUSTOM_WIDGETS} from "app/client/models/features"; import {linkId, NoLink} from 'app/client/ui/selectBy'; import {overflowTooltip, withInfoTooltip} from 'app/client/ui/tooltips'; import {getWidgetTypes} from "app/client/ui/widgetTypesMap"; 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 {IAttachedCustomWidget, IWidgetType} from 'app/common/widgetTypes'; import { computed, Computed, Disposable, dom, domComputed, DomElementArg, fromKo, IOption, makeTestId, Observable, onKeyDown, select, styled } from "grainjs"; import Popper from 'popper.js'; import {IOpenController, popupOpen, setPopupToCreateDom} from 'popweasel'; import without = require('lodash/without'); const t = makeT('PageWidgetPicker'); type TableRef = 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: TableRef; // 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; } export const DefaultPageWidget: () => IPageWidget = () => ({ type: 'record', table: null, summarize: false, columns: [], link: NoLink, section: 0, }); // 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: TableRef, isNewPage: boolean|undefined): IWidgetType[] { if (tableId !== 'New Table') { return ['record', 'single', 'detail', 'chart', 'custom', 'custom.calendar', 'form']; } else if (isNewPage) { // New view + new table means we'll be switching to the primary view. return ['record', 'form']; } else { // The type 'chart' makes little sense when creating a new table. return ['record', 'single', 'detail', 'form']; } } // Whether table and type make for a valid selection whether the user is creating a new page or not. function isValidSelection(table: TableRef, type: IWidgetType, isNewPage: boolean|undefined) { return table !== null && getCompatibleTypes(table, isNewPage).includes(type); } export type ISaveFunc = (val: IPageWidget) => Promise; // 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 {behavioralPromptsManager, 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).getLabel(); await spinnerModal(t("Building {{- label}} widget", { 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, behavioralPromptsManager, 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; }; 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>; } const registeredCustomWidgets: IAttachedCustomWidget[] = ['custom.calendar']; const permittedCustomWidgets: IAttachedCustomWidget[] = PERMITTED_CUSTOM_WIDGETS().get().map((widget) => widget as IAttachedCustomWidget)??[]; // the list of widget types in the order they should be listed by the widget. const finalListOfCustomWidgetToShow = permittedCustomWidgets.filter(a=> registeredCustomWidgets.includes(a)); const sectionTypes: IWidgetType[] = [ 'record', 'single', 'detail', 'form', 'chart', ...finalListOfCustomWidgetToShow, '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 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, type) => !isValidSelection( 'New Table', type, this._options.isNewPage)); constructor( private _value: IWidgetValueObs, private _tables: Observable, private _columns: Observable, private _onSave: () => Promise, private _behavioralPromptsManager: BehavioralPromptsManager, private _options: ISelectOptions = {} ) { super(); } public buildDom() { return cssContainer( testId('container'), cssBody( cssPanel( header(t("Select Widget")), sectionTypes.map((value) => { const widgetInfo = getWidgetTypes(value); const disabled = computed(this._value.table, (use, tid) => this._isTypeDisabled(value, tid)); return cssEntry( dom.autoDispose(disabled), cssTypeIcon(widgetInfo.icon), widgetInfo.getLabel(), 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("Select Data")), 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._behavioralPromptsManager.attachPopup('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(table.tableNameDef), overflowTooltip()), 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("Group by")), 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')) ), 'selectBy', {popupOptions: {attach: null}, domArgs: [ this._behavioralPromptsManager.attachPopup('pageWidgetPickerSelectBy', { popupOptions: { attach: null, placement: 'bottom-start', } }), ]}, ) ), 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("Add to Page"), 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(type: IWidgetType) { this._value.type.set(type); } private _selectTable(tid: TableRef) { 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: TableRef, 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: TableRef) { 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])); }