gristlabs_grist-core/app/client/components/LinkingState.ts
Alex Hall f110ffdafd (core) Follow chain of same-record links for getDefaultColValues
Summary:
When two widgets are linked by same-record linking, and the source of that link is also filter-linked, then it will pick up default values from its own filter-link source, but the same-record-link target didn't. This fixes that so that default values are filled in intuitively.

Moved the logic of linkingState, linkingFilter, and getDefaultColValues from BaseView.js to LinkingState.ts and ViewSectionRec.ts. In particular getDefaultColValues is now a property of LinkingState which may be copied from the source view section for a same-record link.

Note that `ViewSectionRec.linkingFilter` no longer uses `computerBuilder` and thus doesn't ignore dependencies inside LinkingState any more. I couldn't figure out how to make `linkingFilter` a `pureComputed` (otherwise I get recursion errors) that ignores dependencies. In any case, it's now important to have a dependency on `srcSection.linkingState()` for `getDefaultColValues` to work correctly, so I think this is for the best.

Test Plan: Added a new nbrowser test and fixture.

Reviewers: georgegevoian

Reviewed By: georgegevoian

Differential Revision: https://phab.getgrist.com/D3238
2022-02-03 18:51:02 +02:00

220 lines
9.9 KiB
TypeScript

import {DataRowModel} from "app/client/models/DataRowModel";
import * as DataTableModel from "app/client/models/DataTableModel";
import {DocModel} from 'app/client/models/DocModel';
import {ColumnRec} from "app/client/models/entities/ColumnRec";
import {TableRec} from "app/client/models/entities/TableRec";
import {ViewSectionRec} from "app/client/models/entities/ViewSectionRec";
import {RowId} from "app/client/models/rowset";
import {LinkConfig} from "app/client/ui/selectBy";
import {ClientQuery, QueryOperation} from "app/common/ActiveDocAPI";
import {isList, isRefListType} from "app/common/gristTypes";
import * as gutil from "app/common/gutil";
import {encodeObject} from 'app/plugin/objtypes';
import {Disposable} from "grainjs";
import * as ko from "knockout";
import mapValues = require('lodash/mapValues');
import pickBy = require('lodash/pickBy');
import identity = require('lodash/identity');
/**
* Returns if the first table is a summary of the second. If both are summary tables, returns true
* if the second table is a more detailed summary, i.e. has additional group-by columns.
* @param summary: TableRec for the table to check for being the summary table.
* @param detail: TableRec for the table to check for being the detailed version.
* @returns {Boolean} Whether the first argument is a summarized version of the second.
*/
function isSummaryOf(summary: TableRec, detail: TableRec): boolean {
const summarySource = summary.summarySourceTable();
if (summarySource === detail.getRowId()) { return true; }
const detailSource = detail.summarySourceTable();
return (Boolean(summarySource) &&
detailSource === summarySource &&
summary.getRowId() !== detail.getRowId() &&
gutil.isSubset(summary.summarySourceColRefs(), detail.summarySourceColRefs()));
}
export type FilterColValues = Pick<ClientQuery, "filters" | "operations">;
/**
* Maintains state useful for linking sections, i.e. auto-filtering and auto-scrolling.
* Exposes .filterColValues, which is either null or a computed evaluating to a filtering object;
* and .cursorPos, which is either null or a computed that evaluates to a cursor position.
* LinkingState must be created with a valid srcSection and tgtSection.
*
* There are several modes of linking:
* (1) If tgtColId is set, tgtSection will be filtered to show rows whose values of target column
* are equal to the value of source column in srcSection at the cursor. With byAllShown set, all
* values in srcSection are used (rather than only the value in the cursor).
* (2) If srcSection is a summary of tgtSection, then tgtSection is filtered to show only those
* rows that match the row at the cursor of srcSection.
* (3) If tgtColId is null, tgtSection is scrolled to the rowId determined by the value of the
* source column at the cursor in srcSection.
*
* @param gristDoc: GristDoc instance, for getting the relevant TableData objects.
* @param srcSection: RowModel for the section that drives the target section.
* @param srcColId: Name of the column that drives the target section, or null to use rowId.
* @param tgtSection: RowModel for the section that's being driven.
* @param tgtColId: Name of the reference column to auto-filter by, or null to auto-scroll.
* @param byAllShown: For auto-filter, filter by all values in srcSection rather than only the
* value at the cursor. The user can use column filters on srcSection to control what's shown
* in the linked tgtSection.
*/
export class LinkingState extends Disposable {
// If linking affects target section's cursor, this will be a computed for the cursor rowId.
public readonly cursorPos?: ko.Computed<RowId>;
// If linking affects filtering, this is a computed for the current filtering state, as a
// {[colId]: colValues} mapping, with a dependency on srcSection.activeRowId()
public readonly filterColValues?: ko.Computed<FilterColValues>;
// Get default values for a new record so that it continues to satisfy the current linking filters
public readonly getDefaultColValues: () => any;
private _srcSection: ViewSectionRec;
private _srcTableModel: DataTableModel;
private _srcCol: ColumnRec;
private _srcColId: string | undefined;
constructor(docModel: DocModel, linkConfig: LinkConfig) {
super();
const {srcSection, srcCol, srcColId, tgtSection, tgtCol, tgtColId} = linkConfig;
this._srcSection = srcSection;
this._srcCol = srcCol;
this._srcColId = srcColId;
this._srcTableModel = docModel.dataTables[srcSection.table().tableId()];
const srcTableData = this._srcTableModel.tableData;
if (tgtColId) {
const operation = isRefListType(tgtCol.type()) ? 'intersects' : 'in';
if (srcColId) {
this.filterColValues = this._srcCellFilter(tgtColId, operation);
} else {
this.filterColValues = this._simpleFilter(tgtColId, operation, (rowId => [rowId]));
}
} else if (srcColId && isRefListType(srcCol.type())) {
this.filterColValues = this._srcCellFilter('id', 'in');
} else if (isSummaryOf(srcSection.table(), tgtSection.table())) {
// We filter summary tables when a summary section is linked to a more detailed one without
// specifying src or target column. The filtering is on the shared group-by column (i.e. all
// those in the srcSection).
// TODO: This approach doesn't help cursor-linking (the other direction). If we have the
// inverse of summary-table's 'group' column, we could implement both, and more efficiently.
const isDirectSummary = srcSection.table().summarySourceTable() === tgtSection.table().getRowId();
this.filterColValues = this.autoDispose(ko.computed(() => {
const result: FilterColValues = {filters: {}, operations: {}};
const srcRowId = srcSection.activeRowId();
for (const c of srcSection.table().groupByColumns()) {
const col = c.summarySource();
const colId = col.colId();
const srcValue = srcTableData.getValue(srcRowId as number, colId);
result.filters[colId] = [srcValue];
result.operations[colId] = 'in';
if (isDirectSummary) {
const tgtColType = col.type();
if (tgtColType === 'ChoiceList' || tgtColType.startsWith('RefList:')) {
result.operations[colId] = 'intersects';
}
}
}
return result;
}));
} else if (isSummaryOf(tgtSection.table(), srcSection.table())) {
// TODO: We should move the cursor, but don't currently it for summaries. For that, we need a
// column or map representing the inverse of summary table's "group" column.
} else {
const srcValueFunc = srcColId ? this._makeSrcCellGetter() : identity;
if (srcValueFunc) {
this.cursorPos = this.autoDispose(ko.computed(() =>
srcValueFunc(srcSection.activeRowId()) as RowId
));
}
if (!srcColId) {
// This is a same-record link: copy getDefaultColValues from the source if possible
const getDefaultColValues = srcSection.linkingState()?.getDefaultColValues;
if (getDefaultColValues) {
this.getDefaultColValues = getDefaultColValues;
}
}
}
if (!this.getDefaultColValues) {
this.getDefaultColValues = () => {
if (!this.filterColValues) {
return {};
}
const {filters, operations} = this.filterColValues.peek();
return mapValues(
pickBy(filters, (value: any[], key: string) => value.length > 0 && key !== "id"),
(value, key) => operations[key] === "intersects" ? encodeObject(value) : value[0]
);
};
}
}
/**
* Returns a boolean indicating whether editing should be disabled in the destination section.
*/
public disableEditing(): boolean {
return Boolean(this.filterColValues) && this._srcSection.activeRowId() === 'new';
}
// Value for this.filterColValues filtering based on a single column
private _simpleFilter(
colId: string, operation: QueryOperation, valuesFunc: (rowId: RowId|null) => any[]
): ko.Computed<FilterColValues> {
return this.autoDispose(ko.computed(() => {
const srcRowId = this._srcSection.activeRowId();
if (srcRowId === null) {
console.warn("_simpleFilter activeRowId is null");
return { filters: {}, operations: {}};
}
const values = valuesFunc(srcRowId);
return {filters: {[colId]: values}, operations: {[colId]: operation}} as FilterColValues;
}));
}
// Value for this.filterColValues based on the value in srcCol at the selected row
private _srcCellFilter(colId: string, operation: QueryOperation): ko.Computed<FilterColValues> | undefined {
const srcCellGetter = this._makeSrcCellGetter();
if (srcCellGetter) {
const isSrcRefList = isRefListType(this._srcCol.type());
return this._simpleFilter(colId, operation, rowId => {
const value = srcCellGetter(rowId);
if (isSrcRefList) {
if (isList(value)) {
return value.slice(1);
} else {
// The cell value is invalid, so the filter should be empty
return [];
}
} else {
return [value];
}
});
}
}
// Returns a function which returns the value of the cell
// in srcCol in the selected record of srcSection.
// Uses a row model to create a dependency on the cell's value,
// so changes to the cell value will notify observers
private _makeSrcCellGetter() {
const srcRowModel = this.autoDispose(this._srcTableModel.createFloatingRowModel()) as DataRowModel;
const srcCellObs = srcRowModel.cells[this._srcColId!];
// If no srcCellObs, linking is broken; do nothing. This shouldn't happen, but may happen
// transiently while the separate linking-related observables get updated.
if (!srcCellObs) {
return null;
}
return (rowId: RowId | null) => {
srcRowModel.assign(rowId);
if (rowId === 'new') {
return 'new';
}
return srcCellObs();
};
}
}