gristlabs_grist-core/app/client/ui/selectBy.ts
Alex Hall b878395c21 (core) Allow linking summary tables based on ref/reflist columns (except group)
Summary:
Relax the restriction in `selectBy.isValidLink` so that summary tables can be linked by a column like other tables, except the `group` column. See the discussion on https://grist.slack.com/archives/C0234CPPXPA/p1651773623256959 (the replies are on the following message) for more info on this decision.

Tweaked `LinkingState.ts` since linking with summary tables can now involve a column.

Test Plan: Added a new nbrowser test and fixture checking the options to select by given a summary table with a few ref/reflist columns. Manually tested the behaviour of each option.

Reviewers: jarek

Reviewed By: jarek

Differential Revision: https://phab.getgrist.com/D3416
2022-05-12 15:59:12 +02:00

284 lines
9.4 KiB
TypeScript

import { ColumnRec, DocModel, TableRec, ViewSectionRec } from 'app/client/models/DocModel';
import { IPageWidget } from 'app/client/ui/PageWidgetPicker';
import { getReferencedTableId } from 'app/common/gristTypes';
import { IOptionFull } from 'grainjs';
import * as assert from 'assert';
// some unicode characters
const BLACK_CIRCLE = '\u2022';
const RIGHT_ARROW = '\u2192';
// Describes a link
export interface IPageWidgetLink {
// The source section id
srcSectionRef: number;
// The source column id
srcColRef: number;
// The target col id
targetColRef: number;
}
export const NoLink = linkId({
srcSectionRef: 0,
srcColRef: 0,
targetColRef: 0
});
const NoLinkOption: IOptionFull<string> = {
label: "Select Widget",
value: NoLink
};
interface LinkNode {
// the tableId
tableId: string;
// is the table a summary table
isSummary: boolean;
// list of ids of the sections that are ancestors to this section according to the linked section
// relationship
ancestors: Set<number>;
// the section record. Must be the empty record sections that are to be created.
section: ViewSectionRec;
// the column record or undefined for the main section node (ie: the node that does not connect to
// any particular column)
column?: ColumnRec;
// the widget type
widgetType: string;
}
// Returns true is the link from `source` to `target` is valid, false otherwise.
function isValidLink(source: LinkNode, target: LinkNode) {
// section must not be the same
if (source.section.getRowId() === target.section.getRowId()) {
return false;
}
// table must match
if (source.tableId !== target.tableId) {
return false;
}
// cannot link to the somewhat special 'group' reflist column of summary tables
// because in most cases it's equivalent to the usual summary table linking but potentially slower,
// and in other cases it may cause confusion with overwhelming options.
if (
(source.isSummary && source.column?.colId.peek() === "group") ||
(target.isSummary && target.column?.colId.peek() === "group")
) {
return false;
}
// cannot select from chart
if (source.widgetType === 'chart') {
return false;
}
if (source.widgetType === 'custom') {
// custom widget do not support linking by columns
if (source.tableId !== source.section.table.peek().primaryTableId.peek()) {
return false;
}
// custom widget must allow select by
if (!source.section.allowSelectBy.get()) {
return false;
}
}
// The link must not create a cycle
if (source.ancestors.has(target.section.getRowId())) {
return false;
}
return true;
}
// Represents the differents way to reference to a section for linking
type MaybeSection = ViewSectionRec|IPageWidget;
// Returns a list of options with all links that link one of the `source` section to the `target`
// section. Each `opt.value` is a unique identifier (see: linkId() and linkFromId() for more
// detail), and `opt.label` is a human readable representation of the form
// `<section_name>[.<source-col-name>][ -> <target-col-name>]` where the <source-col-name> appears
// only when linking from a reference column, as opposed to linking from the table directly. And the
// <target-col-name> shows only when both <section_name>[.<source-col-name>] is ambiguous.
export function selectBy(docModel: DocModel, sources: ViewSectionRec[],
target: MaybeSection): Array<IOptionFull<string>> {
const sourceNodes = createNodes(docModel, sources);
const targetNodes = createNodes(docModel, [target]);
const options = [NoLinkOption];
for (const srcNode of sourceNodes) {
const validTargets = targetNodes.filter((tgt) => isValidLink(srcNode, tgt));
const hasMany = validTargets.length > 1;
for (const tgtNode of validTargets) {
// a unique identifier for this link
const value = linkId({
srcSectionRef: srcNode.section.getRowId(),
srcColRef: srcNode.column ? srcNode.column.getRowId() : 0,
targetColRef: tgtNode.column ? tgtNode.column.getRowId() : 0,
});
// a human readable description
let label = srcNode.section.titleDef();
// add the source node col name or nothing for table node
label += srcNode.column ? ` ${BLACK_CIRCLE} ${srcNode.column.label.peek()}` : '';
// add the target column name only if target has multiple valid nodes
label += hasMany && tgtNode.column ? ` ${RIGHT_ARROW} ${tgtNode.column.label.peek()}` : '';
// add the new option
options.push({ label, value });
}
}
return options;
}
function isViewSectionRec(section: MaybeSection): section is ViewSectionRec {
return Boolean((section as ViewSectionRec).getRowId);
}
// Create all nodes for sections.
function createNodes(docModel: DocModel, sections: MaybeSection[]) {
const nodes = [];
for (const section of sections) {
if (isViewSectionRec(section)) {
nodes.push(...fromViewSectionRec(section));
} else {
nodes.push(...fromPageWidget(docModel, section));
}
}
return nodes;
}
// Creates an array of LinkNode from a view section record.
function fromViewSectionRec(section: ViewSectionRec): LinkNode[] {
if (section.isDisposed()) {
return [];
}
const table = section.table.peek();
const ancestors = new Set<number>();
for (let sec = section; sec.getRowId(); sec = sec.linkSrcSection.peek()) {
if (ancestors.has(sec.getRowId())) {
// tslint:disable-next-line:no-console
console.warn(`Links should not create a cycle - section ids: ${Array.from(ancestors)}`);
break;
}
ancestors.add(sec.getRowId());
}
const mainNode = {
tableId: table.primaryTableId.peek(),
isSummary: table.primaryTableId.peek() !== table.tableId.peek(),
widgetType: section.parentKey.peek(),
ancestors,
section,
};
return fromColumns(table, mainNode);
}
// Creates an array of LinkNode from a page widget.
function fromPageWidget(docModel: DocModel, pageWidget: IPageWidget): LinkNode[] {
if (typeof pageWidget.table !== 'number') { return []; }
const table = docModel.tables.getRowModel(pageWidget.table);
const mainNode: LinkNode = {
tableId: table.primaryTableId.peek(),
isSummary: pageWidget.summarize,
widgetType: pageWidget.type,
ancestors: new Set(),
section: docModel.viewSections.getRowModel(pageWidget.section),
};
return fromColumns(table, mainNode);
}
function fromColumns(table: TableRec, mainNode: LinkNode): LinkNode[] {
const nodes = [mainNode];
const columns = table.columns.peek().peek();
for (const column of columns) {
const tableId = getReferencedTableId(column.type.peek());
if (tableId) {
nodes.push({...mainNode, tableId, column});
}
}
return nodes;
}
// Returns an identifier to uniquely identify a link. Here we adopt a simple approach where
// {srcSectionRef: 2, srcColRef: 3, targetColRef: 3} is turned into "[2, 3, 3]".
export function linkId(link: IPageWidgetLink) {
return JSON.stringify([link.srcSectionRef, link.srcColRef, link.targetColRef]);
}
// Returns link's properties from its identifier.
export function linkFromId(linkid: string): IPageWidgetLink {
const [srcSectionRef, srcColRef, targetColRef] = JSON.parse(linkid);
return {srcSectionRef, srcColRef, targetColRef};
}
export class LinkConfig {
public readonly srcSection: ViewSectionRec;
public readonly tgtSection: ViewSectionRec;
// Note that srcCol and tgtCol may be the empty column records if that column is not used.
public readonly srcCol: ColumnRec;
public readonly tgtCol: ColumnRec;
public readonly srcColId: string|undefined;
public readonly tgtColId: string|undefined;
// The constructor throws an exception if settings are invalid. When used from inside a knockout
// computed, the constructor subscribes to all parts relevant for linking.
constructor(tgtSection: ViewSectionRec) {
this.tgtCol = tgtSection.linkTargetCol();
this.srcCol = tgtSection.linkSrcCol();
this.srcSection = tgtSection.linkSrcSection();
this.tgtSection = tgtSection;
this.srcColId = this.srcCol.colId();
this.tgtColId = this.tgtCol.colId();
this._assertValid();
}
// Check if section-linking configuration is valid, and throw exception if not.
private _assertValid(): void {
// Use null for unset cols (rather than an empty ColumnRec) for easier comparisons below.
const srcCol = this.srcCol?.getRowId() ? this.srcCol : null;
const tgtCol = this.tgtCol?.getRowId() ? this.tgtCol : null;
const srcTableId = (srcCol ? getReferencedTableId(srcCol.type()) :
this.srcSection.table().primaryTableId());
const tgtTableId = (tgtCol ? getReferencedTableId(tgtCol.type()) :
this.tgtSection.table().primaryTableId());
try {
assert(Boolean(this.srcSection.getRowId()), "srcSection was disposed");
assert(!tgtCol || tgtCol.parentId() === this.tgtSection.tableRef(), "tgtCol belongs to wrong table");
assert(!srcCol || srcCol.parentId() === this.srcSection.tableRef(), "srcCol belongs to wrong table");
assert(this.srcSection.getRowId() !== this.tgtSection.getRowId(), "srcSection links to itself");
assert(tgtTableId, "tgtCol not a valid reference");
assert(srcTableId, "srcCol not a valid reference");
assert(srcTableId === tgtTableId, "mismatched tableIds");
} catch (e) {
throw new Error(`LinkConfig invalid: ` +
`${this.srcSection.getRowId()}:${this.srcCol?.getRowId()}[${srcTableId}] -> ` +
`${this.tgtSection.getRowId()}:${this.tgtCol?.getRowId()}[${tgtTableId}]: ${e}`);
}
}
}