gristlabs_grist-core/app/client/components/LinkingState.ts
Alex Hall bc54a6646e (core) Filter rows based on linked widgets when exporting view
Summary:
Fixes a problem reported here: https://community.getgrist.com/t/exporting-the-records-in-a-linked-view/2556/4

The download CSV/Excel link now contains an additional `linkingFilter` URL parameter containing JSON-encoded `filters` and `operations`. This object is originally created in the frontend in `LinkingState`, and previously it was only used internally in the frontend. It would make its way via `QuerySetManager` to `QuerySet.getFilterFunc` where the actual filtering logic happened. Now most of that logic has been moved to a similar function in `common`. The new function works with a new interface `ColumnGettersByColId` which abstract over the different ways data is accessed in the client and server in this context. There's no significant new logic in the diff, just refactoring and wiring.

Test Plan: Expanded two `nbrowser/SelectBy*.ts` test suites to also check the contents of a downloaded CSV in different linking scenarios.

Reviewers: paulfitz

Reviewed By: paulfitz

Differential Revision: https://phab.getgrist.com/D3961
2023-07-26 21:49:52 +02:00

236 lines
11 KiB
TypeScript

import {DataRowModel} from "app/client/models/DataRowModel";
import 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 {UIRowId} from "app/common/TableData";
import {LinkConfig} from "app/client/ui/selectBy";
import {FilterColValues, QueryOperation} from "app/common/ActiveDocAPI";
import {isList, isListType, isRefListType} from "app/common/gristTypes";
import * as gutil from "app/common/gutil";
import {encodeObject} from 'app/plugin/objtypes';
import {Disposable, toKo} from "grainjs";
import * as ko from "knockout";
import identity = require('lodash/identity');
import mapValues = require('lodash/mapValues');
import pickBy = require('lodash/pickBy');
/**
* 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()));
}
/**
* 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<UIRowId>;
// 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 (srcSection.parentKey() === 'custom') {
this.filterColValues = this._srcCustomFilter(tgtColId, operation);
} else 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 (!srcColId && 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();
const _filterColValues = ko.observable<FilterColValues>();
this.filterColValues = this.autoDispose(ko.computed(() => _filterColValues()));
// source data table could still be loading (this could happen after changing the group by
// columns of a linked summary table for instance), hence the below listener.
this.autoDispose(srcTableData.dataLoadedEmitter.addListener(_update));
_update();
function _update() {
const result: FilterColValues = {filters: {}, operations: {}};
if (srcSection.isDisposed()) {
return result;
}
const srcRowId = srcSection.activeRowId();
for (const c of srcSection.table().groupByColumns()) {
const colId = c.colId();
const srcValue = srcTableData.getValue(srcRowId as number, colId);
result.filters[colId] = [srcValue];
result.operations[colId] = 'in';
if (isDirectSummary && isListType(c.summarySource().type())) {
// If the source groupby column is a ChoiceList or RefList, then null or '' in the summary table
// should match against an empty list in the source table.
result.operations[colId] = srcValue ? 'intersects' : 'empty';
}
}
_filterColValues(result);
}
} else if (srcSection.parentKey() === 'custom') {
this.filterColValues = this._srcCustomFilter('id', 'in');
} else {
const srcValueFunc = srcColId ? this._makeSrcCellGetter() : identity;
if (srcValueFunc) {
this.cursorPos = this.autoDispose(ko.computed(() =>
srcValueFunc(srcSection.activeRowId()) as UIRowId
));
}
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: UIRowId|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];
}
});
}
}
// Value for this.filterColValues based on the values in srcSection.selectedRows
private _srcCustomFilter(colId: string, operation: QueryOperation): ko.Computed<FilterColValues> | undefined {
return this.autoDispose(ko.computed(() => {
const values = toKo(ko, this._srcSection.selectedRows)();
return {filters: {[colId]: values}, operations: {[colId]: operation}} as FilterColValues;
}));
}
// 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: UIRowId | null) => {
srcRowModel.assign(rowId);
if (rowId === 'new') {
return 'new';
}
return srcCellObs();
};
}
}