gristlabs_grist-core/app/client/ui/RightPanel.ts
Janet Vorobyeva 29f07a8a4f
Bidirectional Linking (#622)
Allows bidirectional / cyclic linking for same-record cursor links.
This should allow multiple sections to all synchronize their cursors,
such that clicking in any one of them will move all the others.

Works even if some sections in the cycle have rows filtered out (the 
filtered-out sections might desync their cursors, but the correct cursor
position will still propagate downstream, and they'll re-sync if clicking on
a row that is present in them)

Under the hood, each cursor has a _lastEditedAt counter, updated when
a user's action changes the cursor in a section, such that we can always
tell which section was touched most recently. This is used to resolve
conflicts stably when dealing with cycles or chains of cursor-links.

Updated selectBy and recursiveMoveToCursorPos to handle cycles

Updated tests for selectBy behavior

However, main bidirectional-linking tests are not in this commit, they'll come in a subsequent PR
2023-09-25 18:48:18 -04:00

1147 lines
41 KiB
TypeScript

/**
* 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 {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 {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 {attachPageWidgetPicker, IPageWidget, toPageWidget} from 'app/client/ui/PageWidgetPicker';
import {PredefinedCustomSectionConfig} from "app/client/ui/PredefinedCustomSectionConfig";
import {cssLabel} from 'app/client/ui/RightPanelStyles';
import {linkId, selectBy} from 'app/client/ui/selectBy';
import {VisibleFieldsConfig} from 'app/client/ui/VisibleFieldsConfig';
import {widgetTypesMap} from "app/client/ui/widgetTypesMap";
import {basicButton, primaryButton} from 'app/client/ui2018/buttons';
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,
IDomComponent,
MultiHolder,
Observable,
styled,
subscribe
} 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");
// 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 })}],
]);
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;
});
// 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));
}
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 = widgetTypesMap.get(type) || {label: 'Table', icon: 'TypeTable'};
const fieldInfo = getFieldType(type);
return [
cssTopBarItem(cssTopBarIcon(widgetInfo.icon), widgetInfo.label,
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 topTab = use(this._topTab);
if (topTab === 'field') {
return dom.create(this._buildFieldContent.bind(this));
}
if (topTab === 'pageWidget' && use(this._pageWidgetType)) {
return 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(_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')),
),
dom.domComputed(this._subTab, (subTab) => (
dom.maybe(this._validSection, (activeSection) => (
buildConfigContainer(
subTab === 'widget' ? dom.create(this._buildPageWidgetConfig.bind(this), activeSection) :
subTab === 'sortAndFilter' ? dom.create(this._buildPageSortFilterConfig.bind(this)) :
subTab === 'data' ? dom.create(this._buildPageDataConfig.bind(this), activeSection) :
null
)
))
))
];
}
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(),
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),
() => cssRow(
primaryButton(t("Change Widget"), this._createPageWidgetPicker()),
cssRow.cls('-top-space')
),
),
cssSeparator(),
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)
),
() => [
cssSeparator(),
dom.create(VisibleFieldsConfig, this._gristDoc, 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((val) => 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), () =>
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()),
)),
cssSeparator(),
dom.maybe((use) => !use(activeSection.isRaw), () => [
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")),
)
));
}
}
}
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;
}
`);
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 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;
`);