/** * Builds the structure of the right-side panel containing configuration and assorted tools. * It includes the regular tabs, to configure the Page (including several sub-tabs), and Field; * and allows other tools, such as Activity Feed, to be rendered temporarily in its place. * * A single RightPanel object is created in AppUI for a document page, and attached to PagePanels. * GristDoc registers callbacks with it to create various standard tabs. These are created as * needed, and destroyed when hidden. * * In addition, tools such as "Activity Feed" may use openTool() to replace the panel header and * content. The user may dismiss this panel. * * All methods above return an object which may be disposed to close and dispose that specific * tab from the outside (e.g. when GristDoc is disposed). */ import * as commands from 'app/client/components/commands'; import {FieldModel} from 'app/client/components/Forms/Field'; import {FormView} from 'app/client/components/Forms/FormView'; import {MappedFieldsConfig} from 'app/client/components/Forms/MappedFieldsConfig'; import {GristDoc, IExtraTool, TabContent} from 'app/client/components/GristDoc'; import {EmptyFilterState} from "app/client/components/LinkingState"; import {RefSelect} from 'app/client/components/RefSelect'; import ViewConfigTab from 'app/client/components/ViewConfigTab'; import {domAsync} from 'app/client/lib/domAsync'; import * as imports from 'app/client/lib/imports'; import {makeT} from 'app/client/lib/localization'; import {createSessionObs, isBoolean, SessionObs} from 'app/client/lib/sessionObs'; import {logTelemetryEvent} from 'app/client/lib/telemetry'; import {reportError} from 'app/client/models/AppModel'; import {ColumnRec, ViewSectionRec} from 'app/client/models/DocModel'; import {CustomSectionConfig} from 'app/client/ui/CustomSectionConfig'; import {buildDescriptionConfig} from 'app/client/ui/DescriptionConfig'; import {BuildEditorOptions} from 'app/client/ui/FieldConfig'; import {GridOptions} from 'app/client/ui/GridOptions'; import {textarea} from 'app/client/ui/inputs'; import {attachPageWidgetPicker, IPageWidget, toPageWidget} from 'app/client/ui/PageWidgetPicker'; import {PredefinedCustomSectionConfig} from "app/client/ui/PredefinedCustomSectionConfig"; import {cssLabel} from 'app/client/ui/RightPanelStyles'; import {linkId, NoLink, selectBy} from 'app/client/ui/selectBy'; import {VisibleFieldsConfig} from 'app/client/ui/VisibleFieldsConfig'; import {getTelemetryWidgetTypeFromVS, getWidgetTypes} from "app/client/ui/widgetTypesMap"; import {basicButton, primaryButton} from 'app/client/ui2018/buttons'; import {buttonSelect} from 'app/client/ui2018/buttonSelect'; import {labeledSquareCheckbox} from 'app/client/ui2018/checkbox'; import {testId, theme, vars} from 'app/client/ui2018/cssVars'; import {textInput} from 'app/client/ui2018/editableLabel'; import {IconName} from 'app/client/ui2018/IconList'; import {icon} from 'app/client/ui2018/icons'; import {select} from 'app/client/ui2018/menus'; import {FieldBuilder} from 'app/client/widgets/FieldBuilder'; import {isFullReferencingType} from "app/common/gristTypes"; import {not} from 'app/common/gutil'; import {StringUnion} from 'app/common/StringUnion'; import {IWidgetType} from 'app/common/widgetTypes'; import { bundleChanges, Computed, Disposable, dom, domComputed, DomContents, DomElementArg, DomElementMethod, fromKo, IDomComponent, MultiHolder, Observable, styled, subscribe, toKo } from 'grainjs'; import * as ko from 'knockout'; // some unicode characters const BLACK_CIRCLE = '\u2022'; const ELEMENTOF = '\u2208'; //220A for small elementof const t = makeT('RightPanel'); // Represents a top tab of the right side-pane. const TopTab = StringUnion("pageWidget", "field"); // Represents a subtab of pageWidget in the right side-pane. const PageSubTab = StringUnion("widget", "sortAndFilter", "data", "submission"); // Returns the icon and label of a type, default to those associate to 'record' type. export function getFieldType(widgetType: IWidgetType|null) { // A map of widget type to the icon and label to use for a field of that widget. const fieldTypes = new Map<IWidgetType, {label: string, icon: IconName, pluralLabel: string}>([ ['record', {label: t('Columns', { count: 1 }), icon: 'TypeCell', pluralLabel: t('Columns', { count: 2 })}], ['detail', {label: t('Fields', { count: 1 }), icon: 'TypeCell', pluralLabel: t('Fields', { count: 2 })}], ['single', {label: t('Fields', { count: 1 }), icon: 'TypeCell', pluralLabel: t('Fields', { count: 2 })}], ['chart', {label: t('Series', { count: 1 }), icon: 'ChartLine', pluralLabel: t('Series', { count: 2 })}], ['custom', {label: t('Columns', { count: 1 }), icon: 'TypeCell', pluralLabel: t('Columns', { count: 2 })}], ['form', {label: t('Fields', { count: 1 }), icon: 'TypeCell', pluralLabel: t('Fields', { count: 2 })}], ]); return fieldTypes.get(widgetType || 'record') || fieldTypes.get('record')!; } export class RightPanel extends Disposable { public readonly header: DomContents; public readonly content: DomContents; // If the panel is showing a tool, such as Action Log, instead of the usual section/field // configuration, this will be set to the tool's header and content. private _extraTool: Observable<IExtraTool|null>; // Which of the two standard top tabs (page widget or field) is selected, or was last selected. private _topTab = createSessionObs(this, "rightTopTab", "pageWidget", TopTab.guard); // Which subtab is open for configuring page widget. private _subTab = createSessionObs(this, "rightPageSubTab", "widget", PageSubTab.guard); // Which type of page widget is active, e.g. "record" or "chart". This affects the names and // icons in the top tab. private _pageWidgetType = Computed.create<IWidgetType|null>(this, (use) => { const section: ViewSectionRec = use(this._gristDoc.viewModel.activeSection); return (use(section.parentKey) || null) as IWidgetType; }); private _isForm = Computed.create(this, (use) => { return use(this._pageWidgetType) === 'form'; }); private _hasActiveWidget = Computed.create(this, (use) => Boolean(use(this._pageWidgetType))); // Returns the active section if it's valid, null otherwise. private _validSection = Computed.create(this, (use) => { const sec = use(this._gristDoc.viewModel.activeSection); return sec.getRowId() ? sec : null; }); // Which subtab is open for configuring page widget. private _advLinkInfoCollapsed = createSessionObs(this, "rightPageAdvancedLinkInfoCollapsed", true, isBoolean); constructor(private _gristDoc: GristDoc, private _isOpen: Observable<boolean>) { super(); this._extraTool = _gristDoc.rightPanelTool; this.autoDispose(subscribe(this._extraTool, (_use, tool) => tool && _isOpen.set(true))); this.header = this._buildHeaderDom(); this.content = this._buildContentDom(); this.autoDispose(commands.createGroup({ fieldTabOpen: () => this._openFieldTab(), viewTabOpen: () => this._openViewTab(), viewTabFocus: () => this._viewTabFocus(), sortFilterTabOpen: () => this._openSortFilter(), dataSelectionTabOpen: () => this._openDataSelection(), }, this, true)); // When a page widget is changed, subType might not be valid anymore, so reset it. // TODO: refactor sub tabs and navigation using order of the tab. this.autoDispose(subscribe((use) => { if (!use(this._isForm) && use(this._subTab) === 'submission') { setImmediate(() => !this._subTab.isDisposed() && this._subTab.set('sortAndFilter')); } else if (use(this._isForm) && use(this._subTab) === 'sortAndFilter') { setImmediate(() => !this._subTab.isDisposed() && this._subTab.set('submission')); } })); } private _openFieldTab() { this._open('field'); } private _openViewTab() { this._open('pageWidget', 'widget'); } private _viewTabFocus() { // If the view tab is already open, focus on the first input. this._focus('pageWidget'); } private _openSortFilter() { this._open('pageWidget', 'sortAndFilter'); } private _openDataSelection() { this._open('pageWidget', 'data'); } private _open(topTab: typeof TopTab.type, subTab?: typeof PageSubTab.type) { bundleChanges(() => { this._isOpen.set(true); this._topTab.set(topTab); if (subTab) { this._subTab.set(subTab); } }); } private _focus(topTab: typeof TopTab.type) { bundleChanges(() => { if (!this._isOpen.get()) { return; } this._isOpen.set(true); this._topTab.set(topTab); }); } private _buildHeaderDom() { return dom.domComputed((use) => { if (!use(this._isOpen)) { return null; } const tool = use(this._extraTool); return tool ? this._buildToolHeader(tool) : this._buildStandardHeader(); }); } private _buildToolHeader(tool: IExtraTool) { return cssTopBarItem(cssTopBarIcon(tool.icon), tool.label, cssHoverCircle(cssHoverIcon("CrossBig"), dom.on('click', () => this._gristDoc.showTool('none')), testId('right-tool-close'), ), cssTopBarItem.cls('-selected', true) ); } private _buildStandardHeader() { return dom.maybe(this._pageWidgetType, (type) => { const widgetInfo = getWidgetTypes(type); const fieldInfo = getFieldType(type); return [ cssTopBarItem(cssTopBarIcon(widgetInfo.icon), widgetInfo.getLabel(), cssTopBarItem.cls('-selected', (use) => use(this._topTab) === 'pageWidget'), dom.on('click', () => this._topTab.set("pageWidget")), testId('right-tab-pagewidget')), cssTopBarItem(cssTopBarIcon(fieldInfo.icon), fieldInfo.label, cssTopBarItem.cls('-selected', (use) => use(this._topTab) === 'field'), dom.on('click', () => this._topTab.set("field")), testId('right-tab-field')), ]; }); } private _buildContentDom() { return dom.domComputed((use) => { if (!use(this._isOpen)) { return null; } const tool = use(this._extraTool); if (tool) { return tabContentToDom(tool.content); } const isForm = use(this._isForm); const topTab = use(this._topTab); if (topTab === 'field') { if (isForm) { return dom.create(this._buildQuestionContent.bind(this)); } else { return dom.create(this._buildFieldContent.bind(this)); } } else if (topTab === 'pageWidget') { if (isForm) { return [ dom.create(this._buildPageFormHeader.bind(this)), dom.create(this._buildPageWidgetContent.bind(this)), ]; } else if (use(this._hasActiveWidget)) { return [ dom.create(this._buildPageWidgetHeader.bind(this)), dom.create(this._buildPageWidgetContent.bind(this)), ]; } } return null; }); } private _buildFieldContent(owner: MultiHolder) { const fieldBuilder = owner.autoDispose(ko.computed(() => { const vsi = this._gristDoc.viewModel.activeSection?.().viewInstance(); return vsi && vsi.activeFieldBuilder(); })); const selectedColumns = owner.autoDispose(ko.computed(() => { const vsi = this._gristDoc.viewModel.activeSection?.().viewInstance(); if (vsi && vsi.selectedColumns) { return vsi.selectedColumns(); } const field = fieldBuilder()?.field; return field ? [field] : []; })); const isMultiSelect = owner.autoDispose(ko.pureComputed(() => { const list = selectedColumns(); return Boolean(list && list.length > 1); })); owner.autoDispose(selectedColumns.subscribe(cols => { if (owner.isDisposed() || this._gristDoc.isDisposed() || this._gristDoc.viewModel.isDisposed()) { return; } const section = this._gristDoc.viewModel.activeSection(); if (!section || section.isDisposed()) { return; } section.selectedFields(cols || []); })); this._gristDoc.viewModel.activeSection()?.selectedFields(selectedColumns.peek() || []); const docModel = this._gristDoc.docModel; const origColRef = owner.autoDispose(ko.computed(() => fieldBuilder()?.origColumn.origColRef() || 0)); const origColumn = owner.autoDispose(docModel.columns.createFloatingRowModel(origColRef)); const isColumnValid = owner.autoDispose(ko.computed(() => Boolean(origColRef()))); // Builder for the reference display column multiselect. const refSelect = RefSelect.create(owner, {docModel, origColumn, fieldBuilder}); // build cursor position observable const cursor = owner.autoDispose(ko.computed(() => { const vsi = this._gristDoc.viewModel.activeSection?.().viewInstance(); return vsi?.cursor.currentPosition() ?? {}; })); return domAsync(imports.loadViewPane().then(ViewPane => { const {buildNameConfig, buildFormulaConfig} = ViewPane.FieldConfig; return dom.maybe(isColumnValid, () => buildConfigContainer( cssSection( dom.create(buildNameConfig, origColumn, cursor, isMultiSelect), ), cssSection( dom.create(buildDescriptionConfig, origColumn.description, { cursor, "testPrefix": "column" }), ), cssSeparator(), cssSection( dom.create(buildFormulaConfig, origColumn, this._gristDoc, this._activateFormulaEditor.bind(this)), ), cssSeparator(), dom.maybe<FieldBuilder|null>(fieldBuilder, builder => [ cssLabel(t("COLUMN TYPE")), cssSection( builder.buildSelectTypeDom(), ), cssSection( builder.buildSelectWidgetDom(), ), cssSection( builder.buildConfigDom(), ), builder.buildColorConfigDom(), cssSection( builder.buildSettingOptions(), dom.maybe(isMultiSelect, () => disabledSection()) ), ]), cssSeparator(), cssSection( dom.maybe(refSelect.isForeignRefCol, () => [ cssLabel(t('Add referenced columns')), cssRow(refSelect.buildDom()), cssSeparator() ]), cssLabel(t("TRANSFORM")), dom.maybe<FieldBuilder|null>(fieldBuilder, builder => builder.buildTransformDom()), testId('panel-transform'), ), this._disableIfReadonly(), ) ); })); } // Helper to activate the side-pane formula editor over the given HTML element. private _activateFormulaEditor(options: BuildEditorOptions) { const vsi = this._gristDoc.viewModel.activeSection().viewInstance(); if (!vsi) { return; } const {refElem, editValue, canDetach, onSave, onCancel} = options; const editRow = vsi.moveEditRowToCursor(); return vsi.activeFieldBuilder.peek().openSideFormulaEditor({ editRow, refElem, canDetach, editValue, onSave, onCancel, }); } private _buildPageWidgetContent() { const content = (activeSection: ViewSectionRec, type: typeof PageSubTab.type) => { switch(type){ case 'widget': return dom.create(this._buildPageWidgetConfig.bind(this), activeSection); case 'sortAndFilter': return [ dom.create(this._buildPageSortFilterConfig.bind(this)), cssConfigContainer.cls('-disabled', activeSection.isRecordCard), ]; case 'data': return dom.create(this._buildPageDataConfig.bind(this), activeSection); case 'submission': return dom.create(this._buildPageSubmissionConfig.bind(this), activeSection); default: return null; } }; return dom.domComputed(this._subTab, (subTab) => ( dom.maybe(this._validSection, (activeSection) => ( buildConfigContainer( content(activeSection, subTab) ) )) )); } private _buildPageFormHeader(_owner: MultiHolder) { return [ cssSubTabContainer( cssSubTab(t("Configuration"), cssSubTab.cls('-selected', (use) => use(this._subTab) === 'widget'), dom.on('click', () => this._subTab.set("widget")), testId('config-widget')), cssSubTab(t("Submission"), cssSubTab.cls('-selected', (use) => use(this._subTab) === 'submission'), dom.on('click', () => this._subTab.set("submission")), testId('config-submission')), cssSubTab(t("Data"), cssSubTab.cls('-selected', (use) => use(this._subTab) === 'data'), dom.on('click', () => this._subTab.set("data")), testId('config-data')), ), ]; } private _buildPageWidgetHeader(_owner: MultiHolder) { return [ cssSubTabContainer( cssSubTab(t("Widget"), cssSubTab.cls('-selected', (use) => use(this._subTab) === 'widget'), dom.on('click', () => this._subTab.set("widget")), testId('config-widget')), cssSubTab(t("Sort & Filter"), cssSubTab.cls('-selected', (use) => use(this._subTab) === 'sortAndFilter'), dom.on('click', () => this._subTab.set("sortAndFilter")), testId('config-sortAndFilter')), cssSubTab(t("Data"), cssSubTab.cls('-selected', (use) => use(this._subTab) === 'data'), dom.on('click', () => this._subTab.set("data")), testId('config-data')), ), ]; } private _createViewConfigTab(owner: MultiHolder): Observable<null|ViewConfigTab> { const viewConfigTab = Observable.create<null|ViewConfigTab>(owner, null); const gristDoc = this._gristDoc; imports.loadViewPane() .then(ViewPane => { if (owner.isDisposed()) { return; } viewConfigTab.set(owner.autoDispose( ViewPane.ViewConfigTab.create({gristDoc, viewModel: gristDoc.viewModel}))); }) .catch(reportError); return viewConfigTab; } private _buildPageWidgetConfig(owner: MultiHolder, activeSection: ViewSectionRec) { // TODO: This uses private methods from ViewConfigTab. These methods are likely to get // refactored, but if not, should be made public. const viewConfigTab = this._createViewConfigTab(owner); const hasCustomMapping = Computed.create(owner, use => { const widgetType = use(this._pageWidgetType); const isCustom = widgetType === 'custom' || widgetType?.startsWith('custom.'); const hasColumnMapping = use(activeSection.columnsToMap); return Boolean(isCustom && hasColumnMapping); }); // build cursor position observable const cursor = owner.autoDispose(ko.computed(() => { const vsi = this._gristDoc.viewModel.activeSection?.().viewInstance(); return vsi?.cursor.currentPosition() ?? {}; })); return dom.maybe(viewConfigTab, (vct) => [ this._disableIfReadonly(), dom.maybe(use => !use(activeSection.isRecordCard), () => [ cssLabel(dom.text(use => use(activeSection.isRaw) ? t("DATA TABLE NAME") : t("WIDGET TITLE")), dom.style('margin-bottom', '14px'), ), cssRow(cssTextInput( Computed.create(owner, (use) => use(activeSection.titleDef)), val => activeSection.titleDef.saveOnly(val), dom.boolAttr('disabled', use => { const isRawTable = use(activeSection.isRaw); const isSummaryTable = use(use(activeSection.table).summarySourceTable) !== 0; return isRawTable && isSummaryTable; }), testId('right-widget-title') )), cssSection( dom.create(buildDescriptionConfig, activeSection.description, { cursor, "testPrefix": "right-widget" }), ), ]), dom.maybe( (use) => !use(activeSection.isRaw) && !use(activeSection.isRecordCard), () => cssRow( primaryButton(t("Change Widget"), this._createPageWidgetPicker()), cssRow.cls('-top-space') ), ), dom.maybe((use) => ['detail', 'single'].includes(use(this._pageWidgetType)!), () => [ cssLabel(t("Theme")), dom('div', vct._buildThemeDom(), vct._buildLayoutDom()) ]), domComputed((use) => { if (use(this._pageWidgetType) !== 'record') { return null; } return dom.create(GridOptions, activeSection); }), domComputed((use) => { if (use(this._pageWidgetType) !== 'record') { return null; } return [ cssSeparator(), cssLabel(t("ROW STYLE")), domAsync(imports.loadViewPane().then(ViewPane => dom.create(ViewPane.ConditionalStyle, t("Row Style"), activeSection, this._gristDoc) )) ]; }), dom.maybe((use) => use(this._pageWidgetType) === 'chart', () => [ cssLabel(t("CHART TYPE")), vct._buildChartConfigDom(), ]), dom.maybe((use) => use(this._pageWidgetType) === 'custom', () => { const parts = vct._buildCustomTypeItems() as any[]; return [ cssLabel(t("CUSTOM")), // If 'customViewPlugin' feature is on, show the toggle that allows switching to // plugin mode. Note that the default mode for a new 'custom' view is 'url', so that's // the only one that will be shown without the feature flag. dom.maybe((use) => use(this._gristDoc.app.features).customViewPlugin, () => dom('div', parts[0].buildDom())), dom.maybe(use => use(activeSection.customDef.mode) === 'plugin', () => dom('div', parts[2].buildDom())), // In the default url mode, allow picking a url and granting/forbidding // access to data. dom.maybe(use => use(activeSection.customDef.mode) === 'url' && use(this._pageWidgetType) === 'custom', () => dom.create(CustomSectionConfig, activeSection, this._gristDoc)), ]; }), dom.maybe((use) => use(this._pageWidgetType)?.startsWith('custom.'), () => { return [ dom.create(PredefinedCustomSectionConfig, activeSection, this._gristDoc), ]; }), dom.maybe( (use) => !( use(hasCustomMapping) || use(this._pageWidgetType) === 'chart' || use(activeSection.isRaw) ) && use(activeSection.parentKey) !== 'form', () => [ cssSeparator(), dom.create(VisibleFieldsConfig, this._gristDoc, activeSection), ]), dom.maybe(this._isForm, () => [ cssSeparator(), dom.create(MappedFieldsConfig, activeSection), ]), ]); } private _buildPageSortFilterConfig(owner: MultiHolder) { const viewConfigTab = this._createViewConfigTab(owner); return dom.maybe(viewConfigTab, (vct) => vct.buildSortFilterDom()); } private _buildLinkInfo(activeSection: ViewSectionRec, ...domArgs: DomElementArg[]) { //NOTE!: linkingState.filterState might transiently be EmptyFilterState while things load //Each case (filters-table, id cols, etc) needs to be able to handle having lfilter.filterLabels = {} const tgtSec = activeSection; return dom.domComputed((use) => { const srcSec = use(tgtSec.linkSrcSection); //might be the empty section const srcCol = use(tgtSec.linkSrcCol); const srcColId = use(use(tgtSec.linkSrcCol).colId); // if srcCol is the empty col, colId will be undefined if (srcSec.isDisposed()) { // can happen when deleting srcSection with rightpanel open return cssLinkInfoPanel(""); } //const tgtColId = use(use(tgtSec.linkTargetCol).colId); const srcTable = use(srcSec.table); const tgtTable = use(tgtSec.table); const lstate = use(tgtSec.linkingState); if(lstate == null) { return null; } // if not filter-linking, this will be incorrect, but we don't use it then const lfilter = lstate.filterState ? use(lstate.filterState): EmptyFilterState; //If it's null then no cursor-link is set, but in that case we won't show the string anyway. const cursorPos = lstate.cursorPos ? use(lstate.cursorPos) : 0; const linkedCursorStr = cursorPos ? `${use(tgtTable.tableId)}[${cursorPos}]` : ''; // Make descriptor for the link's source like: "TableName . ColName" or "${SIGMA} TableName", etc const fromTableDom = [ dom.maybe((use2) => use2(srcTable.summarySourceTable), () => cssLinkInfoIcon("Pivot")), use(srcSec.titleDef) + (srcColId ? ` ${BLACK_CIRCLE} ${use(srcCol.label)}` : ''), dom.style("white-space", "normal"), //Allow table name to wrap, reduces how often scrollbar needed ]; //Count filters for proper pluralization const hasId = lfilter.filterLabels?.hasOwnProperty("id"); const numFilters = Object.keys(lfilter.filterLabels).length - (hasId ? 1 : 0); // ================== Link-info Helpers //For each col-filter in lfilters, makes a row showing "${icon} colName = [filterVals]" //FilterVals is in a box to look like a grid cell const makeFiltersTable = (): DomContents => { return cssLinkInfoBody( dom.style("width", "100%"), //width 100 keeps table from growing outside bounds of flex parent if overfull dom("table", dom.style("margin-left", "8px"), Object.keys(lfilter.filterLabels).map( (colId) => { const vals = lfilter.filterLabels[colId]; let operationSymbol = "="; //if [filter (reflist) <- ref], op="intersects", need to convey "list has value". symbol =":" //if [filter (ref) <- reflist], op="in", vals.length>1, need to convey "ref in list" //Sometimes operation will be 'empty', but in that case "=" still works fine, i.e. "list = []" if (lfilter.operations[colId] == "intersects") { operationSymbol = ":"; } else if (vals.length > 1) { operationSymbol = ELEMENTOF; } if (colId == "id") { return dom("div", `ERROR: ID FILTER: ${colId}[${vals}]`); } else { return dom("tr", dom("td", cssLinkInfoIcon("Filter"), `${colId}`), dom("td", operationSymbol, dom.style('padding', '0 2px 0 2px')), dom("td", cssLinkInfoValuesBox( isFullReferencingType(lfilter.colTypes[colId]) ? cssLinkInfoIcon("FieldReference"): null, `${vals.join(', ')}`)), ); } }), //end of keys(filterLabels).map )); }; //Given a list of filterLabels, show them all in a box, as if a grid cell //Shows a "Reference" icon in the left side, since this should only be used for reflinks and cursor links const makeValuesBox = (valueLabels: string[]): DomContents => { return cssLinkInfoBody(( cssLinkInfoValuesBox( cssLinkInfoIcon("FieldReference"), valueLabels.join(', '), ) //TODO: join labels like "Entries[1], Entries[2]" to "Entries[[1,2]]" )); }; const linkType = lstate.linkTypeDescription(); return cssLinkInfoPanel(() => { switch (linkType) { case "Filter:Summary-Group": case "Filter:Col->Col": case "Filter:Row->Col": case "Summary": return [ dom("div", `Link applies filter${numFilters > 1 ? "s" : ""}:`), makeFiltersTable(), dom("div", `Linked from `, fromTableDom), ]; case "Show-Referenced-Records": { //filterLabels might be {} if EmptyFilterState, so filterLabels["id"] might be undefined const displayValues = lfilter.filterLabels["id"] ?? []; return [ dom("div", `Link shows record${displayValues.length > 1 ? "s" : ""}:`), makeValuesBox(displayValues), dom("div", `from `, fromTableDom), ]; } case "Cursor:Same-Table": case "Cursor:Reference": return [ dom("div", `Link sets cursor to:`), makeValuesBox([linkedCursorStr]), dom("div", `from `, fromTableDom), ]; case "Error:Invalid": default: return dom("div", `Error: Couldn't identify link state`); } }, ...domArgs ); // End of cssLinkInfoPanel }); } private _buildLinkInfoAdvanced(activeSection: ViewSectionRec) { return dom.domComputed((use): DomContents => { //TODO: if this just outputs a string, this could really be in LinkingState as a toDebugStr function // but the fact that it's all observables makes that trickier to do correctly, so let's leave it here const srcSec = use(activeSection.linkSrcSection); //might be the empty section const tgtSec = activeSection; if (srcSec.isDisposed()) { // can happen when deleting srcSection with rightpanel open return cssRow(""); } const srcCol = use(activeSection.linkSrcCol); // might be the empty column const tgtCol = use(activeSection.linkTargetCol); // columns might be the empty column // to check nullness, use `.getRowId() == 0` or `use(srcCol.colId) == undefined` const secToStr = (sec: ViewSectionRec) => (!sec || !sec.getRowId()) ? 'null' : `#${use(sec.id)} "${use(sec.titleDef)}", (table "${use(use(sec.table).tableId)}")`; const colToStr = (col: ColumnRec) => (!col || !col.getRowId()) ? 'null' : `#${use(col.id)} "${use(col.colId)}", type "${use(col.type)}")`; // linkingState can be null if the constructor throws, so for debugging we want to show link info // if either the viewSection or the linkingState claim there's a link const hasLink = use(srcSec.id) != undefined || use(tgtSec.linkingState) != null; const lstate = use(tgtSec.linkingState); const lfilter = lstate?.filterState ? use(lstate.filterState) : undefined; // Debug info for cursor linking const inPos = lstate?.incomingCursorPos ? use(lstate.incomingCursorPos) : null; const cursorPosStr = (lstate?.cursorPos ? `${use(tgtSec.tableId)}[${use(lstate.cursorPos)}]` : "N/A") + // TODO: the lastEdited and incomingCursorPos is kinda technical, to do with how bidirectional linking determines // priority for cyclical cursor links. Might be too technical even for the "advanced info" box `\n srclastEdited: T+${use(srcSec.lastCursorEdit)} \n tgtLastEdited: T+${use(tgtSec.lastCursorEdit)}` + `\n incomingCursorPos: ${inPos ? `${inPos[0]}@T+${inPos[1]}` : "N/A"}`; //Main link info as a big string, will be in a <pre></pre> block let preString = "No Incoming Link"; if (hasLink) { preString = [ `From Sec: ${secToStr(srcSec)}`, `To Sec: ${secToStr(tgtSec)}`, '', `From Col: ${colToStr(srcCol)}`, `To Col: ${colToStr(tgtCol)}`, '===========================', // Show linkstate lstate == null ? "LinkState: null" : [ `Link Type: ${use(lstate.linkTypeDescription)}`, ``, "Cursor Pos: " + cursorPosStr, !lfilter ? "Filter State: null" : ["Filter State:", ...(Object.keys(lfilter).map(key => `- ${key}: ${JSON.stringify((lfilter as any)[key])}`))].join('\n'), ].join('\n') ].join('\n'); } const collapsed: SessionObs<Boolean> = this._advLinkInfoCollapsed; return hasLink ? [ cssRow( icon('Dropdown', dom.style('transform', (use2) => use2(collapsed) ? 'rotate(-90deg)' : '')), "Advanced Link info", dom.style('font-size', `${vars.smallFontSize}`), dom.style('text-transform', 'uppercase'), dom.style('cursor', 'pointer'), dom.on('click', () => collapsed.set(!collapsed.get())), ), dom.maybe(not(collapsed), () => cssRow(cssLinkInfoPre(preString))) ] : null; }); } private _buildPageDataConfig(owner: MultiHolder, activeSection: ViewSectionRec) { const viewConfigTab = this._createViewConfigTab(owner); const viewModel = this._gristDoc.viewModel; const table = activeSection.table; const groupedBy = Computed.create(owner, (use) => use(use(table).groupByColumns)); const link = Computed.create(owner, (use) => { return linkId({ srcSectionRef: use(activeSection.linkSrcSectionRef), srcColRef: use(activeSection.linkSrcColRef), targetColRef: use(activeSection.linkTargetColRef) }); }); // This computed is not enough to make sure that the linkOptions are up to date. Indeed // the selectBy function depends on a much greater number of observables. Creating that many // dependencies does not seem a better approach. Instead, we refresh the list of // linkOptions only when the user clicks on the dropdown. Such behavior is not supported by the // weasel select function as of writing and would require a custom implementation, so we will simulate // this behavior by using temporary observable that will be changed when the user clicks on the dropdown. const refreshTrigger = Observable.create(owner, false); const linkOptions = Computed.create(owner, (use) => { void use(refreshTrigger); return selectBy( this._gristDoc.docModel, viewModel.viewSections().all(), activeSection, ); }); link.onWrite(async (val) => { const widgetType = getTelemetryWidgetTypeFromVS(activeSection); if (val !== NoLink) { logTelemetryEvent('linkedWidget', {full: {docIdDigest: this._gristDoc.docId(), widgetType}}); } else { logTelemetryEvent('unlinkedWidget', {full: {docIdDigest: this._gristDoc.docId(), widgetType}}); } await this._gristDoc.saveLink(val); }); return [ this._disableIfReadonly(), cssLabel(t("DATA TABLE")), cssRow( cssIcon('TypeTable'), cssDataLabel(t("SOURCE DATA")), cssContent(dom.text((use) => use(use(table).primaryTableId)), testId('pwc-table')) ), dom( 'div', cssRow(cssIcon('Pivot'), cssDataLabel(t("GROUPED BY"))), cssRow(domComputed(groupedBy, (cols) => cssList(cols.map((c) => ( cssListItem(dom.text(c.label), testId('pwc-groupedBy-col')) ))))), testId('pwc-groupedBy'), // hide if not a summary table dom.hide((use) => !use(use(table).summarySourceTable)), ), dom.maybe((use) => !use(activeSection.isRaw) && !use(activeSection.isRecordCard), () => cssButtonRow(primaryButton(t("Edit Data Selection"), this._createPageWidgetPicker(), testId('pwc-editDataSelection')), dom.maybe( use => Boolean(use(use(activeSection.table).summarySourceTable)), () => basicButton( t("Detach"), dom.on('click', () => this._gristDoc.docData.sendAction( ["DetachSummaryViewSection", activeSection.getRowId()])), testId('detach-button'), )), cssRow.cls('-top-space'), )), // TODO: "Advanced settings" is for "on-demand" marking of tables. This should only be shown // for raw data tables (once that's supported), should have updated UI, and should possibly // be hidden for free plans. dom.maybe(viewConfigTab, (vct) => cssRow( dom('div', vct._buildAdvancedSettingsDom()), )), dom.maybe((use) => !use(activeSection.isRaw) && !use(activeSection.isRecordCard), () => [ cssSeparator(), cssLabel(t("SELECT BY")), cssRow( dom.update( select(link, linkOptions, {defaultLabel: t("Select Widget")}), dom.on('click', () => { refreshTrigger.set(!refreshTrigger.get()); }) ), testId('right-select-by') ), ]), dom.maybe(activeSection.linkingState, () => cssRow(this._buildLinkInfo(activeSection))), domComputed((use) => { const selectorFor = use(use(activeSection.linkedSections).getObservable()); // TODO: sections should be listed following the order of appearance in the view layout (ie: // left/right - top/bottom); return selectorFor.length ? [ cssLabel(t("SELECTOR FOR"), testId('selector-for')), cssRow(cssList(selectorFor.map((sec) => [ this._buildSectionItem(sec) ]))), ] : null; }), //Advanced link info is a little too JSON-ish for general use. But it's very useful for debugging this._buildLinkInfoAdvanced(activeSection), ]; } private _createPageWidgetPicker(): DomElementMethod { const gristDoc = this._gristDoc; const section = gristDoc.viewModel.activeSection; const onSave = (val: IPageWidget) => gristDoc.saveViewSection(section.peek(), val); return (elem) => { attachPageWidgetPicker(elem, gristDoc, onSave, { buttonLabel: t("Save"), value: () => toPageWidget(section.peek()), selectBy: (val) => gristDoc.selectBy(val), }); }; } // Returns dom for a section item. private _buildSectionItem(sec: ViewSectionRec) { return cssListItem( dom.text(sec.titleDef), this._buildLinkInfo(sec, dom.style("border", "none")), testId('selector-for-entry') ); } // Returns a DomArg that disables the content of the panel by adding a transparent overlay on top // of it. private _disableIfReadonly() { if (this._gristDoc.docPageModel) { return dom.maybe(this._gristDoc.docPageModel.isReadonly, () => ( cssOverlay( testId('disable-overlay'), cssBottomText(t("You do not have edit access to this document")), ) )); } } private _buildPageSubmissionConfig(owner: MultiHolder, activeSection: ViewSectionRec) { // All of those observables are backed by the layout config. const submitButtonKo = activeSection.layoutSpecObj.prop('submitText'); const toComputed = (obs: typeof submitButtonKo) => { const result = Computed.create(owner, (use) => use(obs)); result.onWrite(val => obs.setAndSave(val)); return result; }; const submitButton = toComputed(submitButtonKo); const successText = toComputed(activeSection.layoutSpecObj.prop('successText')); const successURL = toComputed(activeSection.layoutSpecObj.prop('successURL')); const anotherResponse = toComputed(activeSection.layoutSpecObj.prop('anotherResponse')); const redirection = Observable.create(owner, Boolean(successURL.get())); owner.autoDispose(redirection.addListener(val => { if (!val) { successURL.set(null); } })); owner.autoDispose(successURL.addListener(val => { if (val) { redirection.set(true); } })); return [ cssLabel(t("Submit button label")), cssRow( cssTextInput(submitButton, (val) => submitButton.set(val), {placeholder: 'Submit'}), ), cssLabel(t("Success text")), cssRow( cssTextArea( successText, {autoGrow: true, save: (val) => successText.set(val)}, {placeholder: 'Thank you! Your response has been recorded.'} ), ), cssLabel(t("Submit another response")), cssRow( labeledSquareCheckbox(anotherResponse, [ t("Display button"), ]), ), cssLabel(t("Redirection")), cssRow( labeledSquareCheckbox(redirection, t('Redirect automatically after submission')), ), cssRow( cssTextInput(successURL, (val) => successURL.set(val), {placeholder: t('Enter redirect URL')}), dom.show(redirection), ), ]; } private _buildQuestionContent(owner: MultiHolder) { const fieldBuilder = owner.autoDispose(ko.computed(() => { const vsi = this._gristDoc.viewModel.activeSection?.().viewInstance(); return vsi && vsi.activeFieldBuilder(); })); // Sorry for the acrobatics below, but grainjs are not reentred when the active section changes. const viewInstance = owner.autoDispose(ko.computed(() => { const vsi = this._gristDoc.viewModel.activeSection?.().viewInstance(); if (!vsi || vsi.isDisposed() || !toKo(ko, this._isForm)) { return null; } return vsi; })); const formView = owner.autoDispose(ko.computed(() => { const view = viewInstance() as unknown as FormView; if (!view || !view.selectedBox) { return null; } return view; })); const selectedBox = owner.autoDispose(ko.pureComputed(() => { const view = formView(); if (!view) { return null; } const box = toKo(ko, view.selectedBox)(); return box; })); const selectedField = Computed.create(owner, (use) => { const box = use(selectedBox); if (!box) { return null; } if (box.type !== 'Field') { return null; } const fieldBox = box as FieldModel; return use(fieldBox.field); }); const selectedBoxWithOptions = Computed.create(owner, (use) => { const box = use(selectedBox); if (!box || !['Paragraph', 'Label'].includes(box.type)) { return null; } return box; }); return domAsync(imports.loadViewPane().then(() => buildConfigContainer(cssSection( // Field config. dom.maybe(selectedField, (field) => { const fieldTitle = field.widgetOptionsJson.prop('question'); return [ cssLabel(t("Field title")), cssRow( cssTextInput( fromKo(fieldTitle), (val) => fieldTitle.saveOnly(val).catch(reportError), dom.prop('readonly', use => use(field.disableModify)), dom.prop('placeholder', use => use(field.displayLabel) || use(field.colId)), testId('field-title'), ), ), cssLabel(t("Table column name")), cssRow( cssTextInput( fromKo(field.displayLabel), (val) => field.displayLabel.saveOnly(val).catch(reportError), dom.prop('readonly', use => use(field.disableModify)), testId('field-label'), ), ), dom.maybe<FieldBuilder|null>(fieldBuilder, builder => [ cssSeparator(), cssLabel(t("COLUMN TYPE")), cssSection( builder.buildSelectTypeDom(), ), cssSection( builder.buildFormConfigDom(), ), ]), ]; }), // Box config dom.maybe(selectedBoxWithOptions, (box) => [ cssLabel(dom.text(box.type)), cssRow( cssTextArea( box.prop('text'), {onInput: true, autoGrow: true}, dom.on('blur', () => box.save().catch(reportError)), {placeholder: t('Enter text')}, ), ), cssRow( buttonSelect(box.prop('alignment'), [ {value: 'left', icon: 'LeftAlign'}, {value: 'center', icon: 'CenterAlign'}, {value: 'right', icon: 'RightAlign'} ]), dom.autoDispose(box.prop('alignment').addListener(() => box.save().catch(reportError))), ) ]), // Default. dom.maybe(u => !u(selectedField) && !u(selectedBoxWithOptions), () => [ buildFormConfigPlaceholder(), ]) )))); } } function buildFormConfigPlaceholder() { return cssFormConfigPlaceholder( cssFormConfigImg(), cssFormConfigMessage( cssFormConfigMessageTitle(t('No field selected')), dom('div', t('Select a field in the form widget to configure.')), ) ); } function disabledSection() { return cssOverlay( testId('panel-disabled-section'), ); } export function buildConfigContainer(...args: DomElementArg[]): HTMLElement { return cssConfigContainer( // The `position: relative;` style is needed for the overlay for the readonly mode. Note that // we cannot set it on the cssConfigContainer directly because it conflicts with how overflow // works. `padding-top: 1px;` prevents collapsing the top margins for the container and the // first child. dom('div', {style: 'position: relative; padding-top: 1px;'}, ...args), ); } // This logic is copied from SidePane.js for building DOM from TabContent. // TODO It may not be needed after new-ui refactoring of the side-pane content. function tabContentToDom(content: Observable<TabContent[]>|TabContent[]|IDomComponent) { function buildItemDom(item: any) { return dom('div.config_item', dom.show(item.showObs || true), item.buildDom() ); } if ("buildDom" in content) { return content.buildDom(); } return cssTabContents( dom.forEach(content, itemOrHeader => { if (itemOrHeader.header) { return dom('div.config_group', dom.show(itemOrHeader.showObs || true), itemOrHeader.label ? dom('div.config_header', itemOrHeader.label) : null, dom.forEach(itemOrHeader.items, item => buildItemDom(item)), ); } else { return buildItemDom(itemOrHeader); } }) ); } const cssOverlay = styled('div', ` background-color: ${theme.rightPanelDisabledOverlay}; opacity: 0.8; position: absolute; top: 0; left: 0; right: 0; bottom: 0; z-index: 100; `); const cssBottomText = styled('span', ` color: ${theme.text}; position: absolute; bottom: -40px; padding: 4px 16px; `); const cssRow = styled('div', ` color: ${theme.text}; display: flex; margin: 8px 16px; align-items: center; &-top-space { margin-top: 24px; } &-disabled { color: ${theme.disabledText}; } `); const cssButtonRow = styled(cssRow, ` margin-left: 0; margin-right: 0; & > button { margin-left: 16px; } `); const cssIcon = styled(icon, ` flex: 0 0 auto; --icon-color: ${theme.lightText}; `); const cssTopBarItem = styled('div', ` flex: 1 1 0px; height: 100%; background-color: ${theme.rightPanelTabBg}; font-weight: ${vars.headerControlTextWeight}; color: ${theme.rightPanelTabFg}; --icon-color: ${theme.rightPanelTabIcon}; display: flex; align-items: center; cursor: default; &-selected { background-color: ${theme.rightPanelTabSelectedBg}; font-weight: initial; color: ${theme.rightPanelTabSelectedFg}; --icon-color: ${theme.rightPanelTabSelectedFg}; } &:not(&-selected):hover { background-color: ${theme.rightPanelTabHoverBg}; --icon-color: ${theme.rightPanelTabIconHover}; } `); const cssTopBarIcon = styled(icon, ` flex: none; margin: 16px; height: 16px; width: 16px; background-color: var(--icon-color); `); const cssHoverCircle = styled('div', ` margin-left: auto; margin-right: 8px; width: 32px; height: 32px; background: none; border-radius: 16px; display: flex; align-items: center; justify-content: center; &:hover { background-color: ${theme.rightPanelTabButtonHoverBg}; } `); const cssHoverIcon = styled(icon, ` height: 16px; width: 16px; background-color: var(--icon-color); `); const cssSubTabContainer = styled('div', ` height: 48px; flex: none; display: flex; align-items: center; justify-content: space-between; `); const cssSubTab = styled('div', ` color: ${theme.rightPanelSubtabFg}; flex: auto; height: 100%; display: flex; flex-direction: column; justify-content: flex-end; text-align: center; padding-bottom: 8px; border-bottom: 1px solid ${theme.pagePanelsBorder}; cursor: default; &-selected { color: ${theme.rightPanelSubtabSelectedFg}; border-bottom: 1px solid ${theme.rightPanelSubtabSelectedUnderline}; } &:not(&-selected):hover { color: ${theme.rightPanelSubtabHoverFg}; } &:hover { border-bottom: 1px solid ${theme.rightPanelSubtabHoverUnderline}; } .${cssSubTabContainer.className}:hover > &-selected:not(:hover) { border-bottom: 1px solid ${theme.pagePanelsBorder}; } `); const cssTabContents = styled('div', ` padding: 16px 8px; overflow: auto; `); const cssSeparator = styled('div', ` border-bottom: 1px solid ${theme.pagePanelsBorder}; margin-top: 16px; `); const cssConfigContainer = styled('div.test-config-container', ` overflow: auto; --color-list-item: none; --color-list-item-hover: none; &:after { content: ""; display: block; height: 40px; } & .fieldbuilder_settings { margin: 16px 0 0 0; } &-disabled { opacity: 0.4; pointer-events: none; } `); const cssDataLabel = styled('div', ` flex: 0 0 81px; color: ${theme.lightText}; font-size: ${vars.xsmallFontSize}; margin-left: 4px; margin-top: 2px; `); const cssContent = styled('div', ` flex: 0 1 auto; overflow: hidden; text-overflow: ellipsis; min-width: 1em; `); const cssList = styled('div', ` list-style: none; width: 100%; `); const cssListItem = styled('li', ` background-color: ${theme.hover}; border-radius: 2px; margin-bottom: 4px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; width: 100%; padding: 4px 8px; `); const cssTextArea = styled(textarea, ` flex: 1 0 auto; color: ${theme.inputFg}; background-color: ${theme.inputBg}; border: 1px solid ${theme.inputBorder}; border-radius: 3px; outline: none; padding: 3px 7px; /* Make space at least for two lines: size of line * 2 * line height + 2 * padding + border * 2 */ min-height: calc(2em * 1.5 + 2 * 3px + 2px); line-height: 1.5; resize: none; &:disabled { color: ${theme.inputDisabledFg}; background-color: ${theme.inputDisabledBg}; pointer-events: none; } `); const cssTextInput = styled(textInput, ` flex: 1 0 auto; color: ${theme.inputFg}; background-color: ${theme.inputBg}; &:disabled { color: ${theme.inputDisabledFg}; background-color: ${theme.inputDisabledBg}; pointer-events: none; } `); const cssSection = styled('div', ` position: relative; `); //============ LinkInfo CSS ============ //LinkInfoPanel is a flex-column //`LinkInfoPanel > table` is the table where we show linked filters, if there are any const cssLinkInfoPanel = styled('div', ` width: 100%; display: flex; flex-flow: column; align-items: start; text-align: left; font-family: ${vars.fontFamily}; border: 1px solid ${theme.pagePanelsBorder}; border-radius: 4px; padding: 6px; white-space: nowrap; overflow-x: auto; & table { border-spacing: 2px; border-collapse: separate; } `); // Center table / values box inside LinkInfoPanel const cssLinkInfoBody= styled('div', ` margin: 2px 0 2px 0; align-self: center; `); // Intended to imitate style of a grid cell // white-space: normal allows multiple values to wrap // min-height: 22px matches real field size, +2 for the borders const cssLinkInfoValuesBox = styled('div', ` border: 1px solid ${'#CCC'}; padding: 3px 3px 0px 3px; min-width: 60px; min-height: 24px; white-space: normal; `); //If inline with text, icons look better shifted up slightly //since icons are position:relative, bottom:1 should shift it without affecting layout const cssLinkInfoIcon = styled(icon, ` bottom: 1px; margin-right: 3px; background-color: ${theme.controlSecondaryFg}; `); // ============== styles for _buildLinkInfoAdvanced const cssLinkInfoPre = styled("pre", ` padding: 6px; font-size: ${vars.smallFontSize}; line-height: 1.2; `); const cssFormConfigPlaceholder = styled('div', ` display: flex; flex-direction: column; row-gap: 16px; margin-top: 32px; padding: 8px; `); const cssFormConfigImg = styled('div', ` height: 140px; width: 100%; background-size: contain; background-repeat: no-repeat; background-position: center; background-image: var(--icon-FormConfig); `); const cssFormConfigMessage = styled('div', ` display: flex; flex-direction: column; row-gap: 8px; color: ${theme.text}; text-align: center; `); const cssFormConfigMessageTitle = styled('div', ` font-size: ${vars.largeFontSize}; font-weight: 600; `);