mirror of
https://github.com/gristlabs/grist-core.git
synced 2026-03-02 04:09:24 +00:00
Linkstate refactor (#609)
* Linkingstate Refactor, and displaying link info in rightpanel
Big refactor to LinkingState
Collects descriptive/user-facing labels into FilterState
Unifies/cleans up some logic
Adds LinkTypeDescription, a string enum which can be used
to easily switch/case between various cases of linking, and
codifies the logic in one place (currently only used for linkInfo)
Adds Link info to creator panel, near SelectBy dropdown
Bugfix: Disables linking from Attachment columns
Bugfix/Behavior change: changed linking with empty RefLists to better
match behavior of refs.
for context: Linking by a blank Ref filters to show records with a
blank value for that Ref. Previously this didn't work with RefLists.
Linking from a blank refList would show no records
(except in some cases involving summary tables)
Fixed this so that linking by a blank val consistently means "show
all records where the corresponding col is blank"
This commit is contained in:
@@ -16,14 +16,15 @@
|
||||
|
||||
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} from 'app/client/lib/sessionObs';
|
||||
import {createSessionObs, isBoolean, SessionObs} from 'app/client/lib/sessionObs';
|
||||
import {reportError} from 'app/client/models/AppModel';
|
||||
import {ViewSectionRec} from 'app/client/models/DocModel';
|
||||
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';
|
||||
@@ -41,6 +42,8 @@ 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 {
|
||||
@@ -60,6 +63,10 @@ import {
|
||||
} 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.
|
||||
@@ -109,6 +116,10 @@ export class RightPanel extends Disposable {
|
||||
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;
|
||||
@@ -484,6 +495,189 @@ export class RightPanel extends Disposable {
|
||||
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
|
||||
//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;
|
||||
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;
|
||||
|
||||
const cursorPosStr = lstate?.cursorPos ? `${tgtSec.tableId()}[${use(lstate.cursorPos)}]` : "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;
|
||||
@@ -570,15 +764,22 @@ export class RightPanel extends Disposable {
|
||||
),
|
||||
]),
|
||||
|
||||
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))))
|
||||
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),
|
||||
];
|
||||
}
|
||||
|
||||
@@ -597,6 +798,7 @@ export class RightPanel extends Disposable {
|
||||
private _buildSectionItem(sec: ViewSectionRec) {
|
||||
return cssListItem(
|
||||
dom.text(sec.titleDef),
|
||||
this._buildLinkInfo(sec, dom.style("border", "none")),
|
||||
testId('selector-for-entry')
|
||||
);
|
||||
}
|
||||
@@ -865,3 +1067,65 @@ const cssTextInput = styled(textInput, `
|
||||
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;
|
||||
`);
|
||||
|
||||
@@ -42,6 +42,9 @@ interface LinkNode {
|
||||
// is the table a summary table
|
||||
isSummary: boolean;
|
||||
|
||||
// does this node involve an "Attachments" column. Can be tricky if Attachments is one of groupby cols
|
||||
isAttachments: boolean;
|
||||
|
||||
// For a summary table, the set of col refs of the groupby columns of the underlying table
|
||||
groupbyColumns?: Set<number>;
|
||||
|
||||
@@ -114,6 +117,12 @@ function isValidLink(source: LinkNode, target: LinkNode) {
|
||||
return false;
|
||||
}
|
||||
|
||||
//cannot select from attachments, even though they're implemented as reflists
|
||||
if (source.isAttachments || target.isAttachments) {
|
||||
return false;
|
||||
}
|
||||
|
||||
|
||||
// cannot select from chart
|
||||
if (source.widgetType === 'chart') {
|
||||
return false;
|
||||
@@ -230,6 +239,7 @@ function fromViewSectionRec(section: ViewSectionRec): LinkNode[] {
|
||||
const mainNode: LinkNode = {
|
||||
tableId: table.primaryTableId.peek(),
|
||||
isSummary,
|
||||
isAttachments: isSummary && table.groupByColumns.peek().some(col => col.type.peek() == "Attachments"),
|
||||
groupbyColumns: isSummary ? table.summarySourceColRefs.peek() : undefined,
|
||||
widgetType: section.parentKey.peek(),
|
||||
ancestors,
|
||||
@@ -266,6 +276,8 @@ function fromPageWidget(docModel: DocModel, pageWidget: IPageWidget): LinkNode[]
|
||||
const mainNode: LinkNode = {
|
||||
tableId: table.primaryTableId.peek(),
|
||||
isSummary,
|
||||
isAttachments: false, // hmm, we should need a check here in case attachments col is on the main-node link
|
||||
// (e.g.: link from summary table with Attachments in group-by) but it seems to work fine as is
|
||||
groupbyColumns,
|
||||
widgetType: pageWidget.type,
|
||||
ancestors: new Set(),
|
||||
@@ -284,7 +296,7 @@ function fromColumns(table: TableRec, mainNode: LinkNode, tableExists: boolean =
|
||||
}
|
||||
const tableId = getReferencedTableId(column.type.peek());
|
||||
if (tableId) {
|
||||
nodes.push({...mainNode, tableId, column});
|
||||
nodes.push({...mainNode, tableId, column, isAttachments: column.type.peek() == "Attachments"});
|
||||
}
|
||||
}
|
||||
return nodes;
|
||||
|
||||
Reference in New Issue
Block a user