(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
This commit is contained in:
Alex Hall 2022-01-27 19:51:37 +02:00
parent 22006754af
commit f110ffdafd
5 changed files with 91 additions and 60 deletions

View File

@ -4,7 +4,6 @@ var moment = require('moment-timezone');
var {getSelectionDesc} = require('app/common/DocActions'); var {getSelectionDesc} = require('app/common/DocActions');
var {nativeCompare, roundDownToMultiple, waitObs} = require('app/common/gutil'); var {nativeCompare, roundDownToMultiple, waitObs} = require('app/common/gutil');
var gristTypes = require('app/common/gristTypes'); var gristTypes = require('app/common/gristTypes');
var koUtil = require('../lib/koUtil');
var tableUtil = require('../lib/tableUtil'); var tableUtil = require('../lib/tableUtil');
var {DataRowModel} = require('../models/DataRowModel'); var {DataRowModel} = require('../models/DataRowModel');
var {DynamicQuerySet} = require('../models/QuerySet'); var {DynamicQuerySet} = require('../models/QuerySet');
@ -15,7 +14,6 @@ var {Cursor} = require('./Cursor');
var FieldBuilder = require('../widgets/FieldBuilder'); var FieldBuilder = require('../widgets/FieldBuilder');
var commands = require('./commands'); var commands = require('./commands');
var BackboneEvents = require('backbone').Events; var BackboneEvents = require('backbone').Events;
const {LinkingState} = require('./LinkingState');
const {ClientColumnGetters} = require('app/client/models/ClientColumnGetters'); const {ClientColumnGetters} = require('app/client/models/ClientColumnGetters');
const {reportError, reportSuccess} = require('app/client/models/errors'); const {reportError, reportSuccess} = require('app/client/models/errors');
const {urlState} = require('app/client/models/gristUrlState'); const {urlState} = require('app/client/models/gristUrlState');
@ -24,8 +22,6 @@ const {copyToClipboard} = require('app/client/lib/copyToClipboard');
const {setTestState} = require('app/client/lib/testState'); const {setTestState} = require('app/client/lib/testState');
const {ExtraRows} = require('app/client/models/DataTableModelWithDiff'); const {ExtraRows} = require('app/client/models/DataTableModelWithDiff');
const {createFilterMenu} = require('app/client/ui/ColumnFilterMenu'); const {createFilterMenu} = require('app/client/ui/ColumnFilterMenu');
const {LinkConfig} = require('app/client/ui/selectBy');
const {encodeObject} = require("app/plugin/objtypes");
/** /**
* BaseView forms the basis for ViewSection classes. * BaseView forms the basis for ViewSection classes.
@ -127,36 +123,9 @@ function BaseView(gristDoc, viewSectionModel, options) {
//-------------------------------------------------- //--------------------------------------------------
// Prepare logic for linking with other sections. // Prepare logic for linking with other sections.
// Linking state maintains .filterFunc and .cursorPos observables which we use for
// auto-scrolling and filtering.
this._linkingState = this.autoDispose(koUtil.computedBuilder(() => {
let v = this.viewSection;
let src = v.linkSrcSection();
if (!src.getRowId()) {
return null;
}
try {
const config = new LinkConfig(v);
return LinkingState.create.bind(LinkingState, null, this.gristDoc, config);
} catch (err) {
console.warn(`Can't create LinkingState: ${err.message}`);
return null;
}
}));
this._linkingFilter = this.autoDispose(ko.computed(() => {
const linking = this._linkingState();
const result = linking && linking.filterColValues ? linking.filterColValues() : {filters: {}};
result.operations = result.operations || {};
for (const key in result.filters) {
result.operations[key] = result.operations[key] || 'in';
}
return result;
}));
// A computed for the rowId of the row selected by section linking. // A computed for the rowId of the row selected by section linking.
this.linkedRowId = this.autoDispose(ko.computed(() => { this.linkedRowId = this.autoDispose(ko.computed(() => {
let linking = this._linkingState(); let linking = this.viewSection.linkingState();
return linking && linking.cursorPos ? linking.cursorPos() : null; return linking && linking.cursorPos ? linking.cursorPos() : null;
}).extend({deferred: true})); }).extend({deferred: true}));
@ -165,7 +134,7 @@ function BaseView(gristDoc, viewSectionModel, options) {
// Indicated whether editing the section should be disabled given the current linking state. // Indicated whether editing the section should be disabled given the current linking state.
this.disableEditing = this.autoDispose(ko.computed(() => { this.disableEditing = this.autoDispose(ko.computed(() => {
const linking = this._linkingState(); const linking = this.viewSection.linkingState();
return linking && linking.disableEditing(); return linking && linking.disableEditing();
})); }));
@ -219,7 +188,7 @@ function BaseView(gristDoc, viewSectionModel, options) {
// dependency changes. // dependency changes.
this.autoDispose(ko.computed(() => { this.autoDispose(ko.computed(() => {
this._isLoading(true); this._isLoading(true);
const linkingFilter = this._linkingFilter(); const linkingFilter = this.viewSection.linkingFilter();
this._queryRowSource.makeQuery(linkingFilter.filters, linkingFilter.operations, (err) => { this._queryRowSource.makeQuery(linkingFilter.filters, linkingFilter.operations, (err) => {
if (this.isDisposed()) { return; } if (this.isDisposed()) { return; }
if (err) { reportError(err); } if (err) { reportError(err); }
@ -228,7 +197,7 @@ function BaseView(gristDoc, viewSectionModel, options) {
})); }));
// Reset cursor to the first row when filtering changes. // Reset cursor to the first row when filtering changes.
this.autoDispose(this._linkingFilter.subscribe((x) => this.onLinkFilterChange())); this.autoDispose(this.viewSection.linkingFilter.subscribe((x) => this.onLinkFilterChange()));
// When sorting changes, reset the cursor to the first row. (The alternative of moving the // When sorting changes, reset the cursor to the first row. (The alternative of moving the
// cursor to stay at the same record is sometimes better, but sometimes more annoying.) // cursor to stay at the same record is sometimes better, but sometimes more annoying.)
@ -408,11 +377,11 @@ BaseView.prototype._parsePasteForView = function(data, fields) {
}; };
BaseView.prototype._getDefaultColValues = function() { BaseView.prototype._getDefaultColValues = function() {
const {filters, operations} = this._linkingFilter.peek(); const linkingState = this.viewSection.linkingState.peek();
return _.mapObject( if (!linkingState) {
_.pick(filters, (value, key) => value.length > 0 && key !== "id"), return {};
(value, key) => operations[key] === "intersects" ? encodeObject(value) : value[0] }
); return linkingState.getDefaultColValues();
}; };
/** /**

View File

@ -321,7 +321,7 @@ DetailView.prototype.buildTitleControls = function() {
// Note that the controls should still be visible with a filter link. // Note that the controls should still be visible with a filter link.
const showControls = ko.computed(() => { const showControls = ko.computed(() => {
if (!this._isSingle || this.recordLayout.layoutEditor()) { return false; } if (!this._isSingle || this.recordLayout.layoutEditor()) { return false; }
const linkingState = this._linkingState(); const linkingState = this.viewSection.linkingState();
return !(linkingState && Boolean(linkingState.cursorPos)); return !(linkingState && Boolean(linkingState.cursorPos));
}); });
return dom('div', return dom('div',

View File

@ -1,6 +1,6 @@
import {GristDoc} from "app/client/components/GristDoc";
import {DataRowModel} from "app/client/models/DataRowModel"; import {DataRowModel} from "app/client/models/DataRowModel";
import * as DataTableModel from "app/client/models/DataTableModel"; import * as DataTableModel from "app/client/models/DataTableModel";
import {DocModel} from 'app/client/models/DocModel';
import {ColumnRec} from "app/client/models/entities/ColumnRec"; import {ColumnRec} from "app/client/models/entities/ColumnRec";
import {TableRec} from "app/client/models/entities/TableRec"; import {TableRec} from "app/client/models/entities/TableRec";
import {ViewSectionRec} from "app/client/models/entities/ViewSectionRec"; import {ViewSectionRec} from "app/client/models/entities/ViewSectionRec";
@ -9,9 +9,12 @@ import {LinkConfig} from "app/client/ui/selectBy";
import {ClientQuery, QueryOperation} from "app/common/ActiveDocAPI"; import {ClientQuery, QueryOperation} from "app/common/ActiveDocAPI";
import {isList, isRefListType} from "app/common/gristTypes"; import {isList, isRefListType} from "app/common/gristTypes";
import * as gutil from "app/common/gutil"; import * as gutil from "app/common/gutil";
import {encodeObject} from 'app/plugin/objtypes';
import {Disposable} from "grainjs"; import {Disposable} from "grainjs";
import * as ko from "knockout"; import * as ko from "knockout";
import * as _ from "underscore"; import mapValues = require('lodash/mapValues');
import pickBy = require('lodash/pickBy');
import identity = require('lodash/identity');
/** /**
@ -31,7 +34,7 @@ function isSummaryOf(summary: TableRec, detail: TableRec): boolean {
gutil.isSubset(summary.summarySourceColRefs(), detail.summarySourceColRefs())); gutil.isSubset(summary.summarySourceColRefs(), detail.summarySourceColRefs()));
} }
type FilterColValues = Pick<ClientQuery, "filters" | "operations">; export type FilterColValues = Pick<ClientQuery, "filters" | "operations">;
/** /**
* Maintains state useful for linking sections, i.e. auto-filtering and auto-scrolling. * Maintains state useful for linking sections, i.e. auto-filtering and auto-scrolling.
@ -65,18 +68,21 @@ export class LinkingState extends Disposable {
// {[colId]: colValues} mapping, with a dependency on srcSection.activeRowId() // {[colId]: colValues} mapping, with a dependency on srcSection.activeRowId()
public readonly filterColValues?: ko.Computed<FilterColValues>; 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 _srcSection: ViewSectionRec;
private _srcTableModel: DataTableModel; private _srcTableModel: DataTableModel;
private _srcCol: ColumnRec; private _srcCol: ColumnRec;
private _srcColId: string | undefined; private _srcColId: string | undefined;
constructor(gristDoc: GristDoc, linkConfig: LinkConfig) { constructor(docModel: DocModel, linkConfig: LinkConfig) {
super(); super();
const {srcSection, srcCol, srcColId, tgtSection, tgtCol, tgtColId} = linkConfig; const {srcSection, srcCol, srcColId, tgtSection, tgtCol, tgtColId} = linkConfig;
this._srcSection = srcSection; this._srcSection = srcSection;
this._srcCol = srcCol; this._srcCol = srcCol;
this._srcColId = srcColId; this._srcColId = srcColId;
this._srcTableModel = gristDoc.getTableModel(srcSection.table().tableId()); this._srcTableModel = docModel.dataTables[srcSection.table().tableId()];
const srcTableData = this._srcTableModel.tableData; const srcTableData = this._srcTableModel.tableData;
if (tgtColId) { if (tgtColId) {
@ -103,10 +109,11 @@ export class LinkingState extends Disposable {
const colId = col.colId(); const colId = col.colId();
const srcValue = srcTableData.getValue(srcRowId as number, colId); const srcValue = srcTableData.getValue(srcRowId as number, colId);
result.filters[colId] = [srcValue]; result.filters[colId] = [srcValue];
result.operations[colId] = 'in';
if (isDirectSummary) { if (isDirectSummary) {
const tgtColType = col.type(); const tgtColType = col.type();
if (tgtColType === 'ChoiceList' || tgtColType.startsWith('RefList:')) { if (tgtColType === 'ChoiceList' || tgtColType.startsWith('RefList:')) {
result.operations![colId] = 'intersects'; result.operations[colId] = 'intersects';
} }
} }
} }
@ -116,12 +123,33 @@ export class LinkingState extends Disposable {
// TODO: We should move the cursor, but don't currently it for summaries. For that, we need a // 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. // column or map representing the inverse of summary table's "group" column.
} else { } else {
const srcValueFunc = srcColId ? this._makeSrcCellGetter() : _.identity; const srcValueFunc = srcColId ? this._makeSrcCellGetter() : identity;
if (srcValueFunc) { if (srcValueFunc) {
this.cursorPos = this.autoDispose(ko.computed(() => this.cursorPos = this.autoDispose(ko.computed(() =>
srcValueFunc(srcSection.activeRowId()) as RowId 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]
);
};
} }
} }

View File

@ -1,16 +1,27 @@
import * as BaseView from 'app/client/components/BaseView'; import * as BaseView from 'app/client/components/BaseView';
import { ColumnRec, FilterRec, TableRec, ViewFieldRec, ViewRec } from 'app/client/models/DocModel'; import {CursorPos} from 'app/client/components/Cursor';
import {FilterColValues, LinkingState} from 'app/client/components/LinkingState';
import {KoArray} from 'app/client/lib/koArray';
import {
ColumnRec,
DocModel,
FilterRec,
IRowModel,
recordSet,
refRecord,
TableRec,
ViewFieldRec,
ViewRec
} from 'app/client/models/DocModel';
import * as modelUtil from 'app/client/models/modelUtil'; import * as modelUtil from 'app/client/models/modelUtil';
import {RowId} from 'app/client/models/rowset';
import {LinkConfig} from 'app/client/ui/selectBy';
import {getWidgetTypes} from 'app/client/ui/widgetTypes';
import {AccessLevel, ICustomWidget} from 'app/common/CustomWidget'; import {AccessLevel, ICustomWidget} from 'app/common/CustomWidget';
import {arrayRepeat} from 'app/common/gutil';
import {Sort} from 'app/common/SortSpec';
import {Computed} from 'grainjs';
import * as ko from 'knockout'; import * as ko from 'knockout';
import { CursorPos, } from 'app/client/components/Cursor';
import { KoArray, } from 'app/client/lib/koArray';
import { DocModel, IRowModel, recordSet, refRecord, } from 'app/client/models/DocModel';
import { RowId, } from 'app/client/models/rowset';
import { getWidgetTypes, } from 'app/client/ui/widgetTypes';
import { arrayRepeat, } from 'app/common/gutil';
import { Sort, } from 'app/common/SortSpec';
import { Computed, } from 'grainjs';
import defaults = require('lodash/defaults'); import defaults = require('lodash/defaults');
// Represents a section of user views, now also known as a "page widget" (e.g. a view may contain // Represents a section of user views, now also known as a "page widget" (e.g. a view may contain
@ -98,6 +109,12 @@ export interface ViewSectionRec extends IRowModel<"_grist_Views_section"> {
linkSrcCol: ko.Computed<ColumnRec>; linkSrcCol: ko.Computed<ColumnRec>;
linkTargetCol: ko.Computed<ColumnRec>; linkTargetCol: ko.Computed<ColumnRec>;
// Linking state maintains .filterFunc and .cursorPos observables which we use for
// auto-scrolling and filtering.
linkingState: ko.Computed<LinkingState | null>;
linkingFilter: ko.Computed<FilterColValues>;
activeRowId: ko.Observable<RowId | null>; // May be null when there are no rows. activeRowId: ko.Observable<RowId | null>; // May be null when there are no rows.
// If the view instance for section is instantiated, it will be accessible here. // If the view instance for section is instantiated, it will be accessible here.
@ -425,6 +442,23 @@ export function createViewSectionRec(this: ViewSectionRec, docModel: DocModel):
this.activeRowId = ko.observable(null); this.activeRowId = ko.observable(null);
this.linkingState = this.autoDispose(ko.pureComputed(() => {
if (!this.linkSrcSection().getRowId()) {
return null;
}
try {
const config = new LinkConfig(this);
return new LinkingState(docModel, config);
} catch (err) {
console.warn(`Can't create LinkingState: ${err.message}`);
return null;
}
}));
this.linkingFilter = this.autoDispose(ko.pureComputed(() => {
return this.linkingState()?.filterColValues?.() || {filters: {}, operations: {}};
}));
// If the view instance for this section is instantiated, it will be accessible here. // If the view instance for this section is instantiated, it will be accessible here.
this.viewInstance = ko.observable(null); this.viewInstance = ko.observable(null);

View File

@ -99,7 +99,7 @@ interface BaseQuery {
* Allows filtering with more complex operations. * Allows filtering with more complex operations.
*/ */
export interface ClientQuery extends BaseQuery { export interface ClientQuery extends BaseQuery {
operations?: { operations: {
[colId: string]: QueryOperation; [colId: string]: QueryOperation;
}; };
} }