diff --git a/app/client/components/BaseView.js b/app/client/components/BaseView.js index 6a328317..9aeaadc4 100644 --- a/app/client/components/BaseView.js +++ b/app/client/components/BaseView.js @@ -14,8 +14,8 @@ var Base = require('./Base'); var {Cursor} = require('./Cursor'); var FieldBuilder = require('../widgets/FieldBuilder'); var commands = require('./commands'); -var LinkingState = require('./LinkingState'); var BackboneEvents = require('backbone').Events; +const {LinkingState} = require('./LinkingState'); const {ClientColumnGetters} = require('app/client/models/ClientColumnGetters'); const {reportError, UserError} = require('app/client/models/errors'); const {urlState} = require('app/client/models/gristUrlState'); @@ -131,13 +131,12 @@ function BaseView(gristDoc, viewSectionModel, options) { this._linkingState = this.autoDispose(koUtil.computedBuilder(() => { let v = this.viewSection; let src = v.linkSrcSection(); - const filterByAllShown = v.optionsObj.prop('filterByAllShown'); if (!src.getRowId()) { return null; } try { const config = new LinkConfig(v); - return LinkingState.create.bind(LinkingState, this.gristDoc, config, filterByAllShown()); + return LinkingState.create.bind(LinkingState, null, this.gristDoc, config); } catch (err) { console.warn(`Can't create LinkingState: ${err.message}`); return null; diff --git a/app/client/components/LinkingState.js b/app/client/components/LinkingState.js deleted file mode 100644 index 01c3bda8..00000000 --- a/app/client/components/LinkingState.js +++ /dev/null @@ -1,145 +0,0 @@ -const _ = require('underscore'); -const ko = require('knockout'); -const dispose = require('../lib/dispose'); -const gutil = require('app/common/gutil'); -const {isRefListType} = require("app/common/gristTypes"); - -/** - * 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 {MetaRowModel} summary: RowModel for the table to check for being the summary table. - * @param {MetaRowModel} detail: RowModel 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, detail) { - let summarySource = summary.summarySourceTable(); - if (summarySource === detail.getRowId()) { return true; } - let detailSource = detail.summarySourceTable(); - return (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. - */ -function LinkingState(gristDoc, linkConfig, byAllShown) { - const {srcSection, srcColId, tgtSection, tgtCol, tgtColId} = linkConfig; - this._srcSection = srcSection; - - let srcTableModel = gristDoc.getTableModel(srcSection.table().tableId()); - let srcTableData = srcTableModel.tableData; - - // Function from srcRowId (i.e. srcSection.activeRowId()) to the source value. It is used for - // filtering or for cursor positioning, depending on the setting of tgtCol. - let srcValueFunc = srcColId ? srcTableData.getRowPropFunc(srcColId) : _.identity; - - // If linking affects target section's cursor, this will be a computed for the cursor rowId. - this.cursorPos = null; - - // If linking affects filtering, this is a computed for the current filtering state, as a - // {[colId]: colValues} mapping, with a dependency on srcSection.activeRowId(). Otherwise, null. - this.filterColValues = null; - - // A computed that evaluates to a filter function to use, or null if not filtering. If - // filtering, depends on srcSection.activeRowId(). - if (tgtColId) { - const operations = {[tgtColId]: isRefListType(tgtCol.type()) ? 'intersects' : 'in'}; - if (byAllShown) { - // (This is legacy code that isn't currently reachable) - // Include all values present in srcSection. - this.filterColValues = this.autoDispose(ko.computed(() => { - const srcValues = new Set(); - const viewInstance = srcSection.viewInstance(); - if (viewInstance) { - for (const srcRowId of viewInstance.sortedRows.getKoArray().all()) { - if (srcRowId !== 'new') { - srcValues.add(srcValueFunc(srcRowId)); - } - } - } - return {filters: {[tgtColId]: Array.from(srcValues)}}; - })); - } else if (srcColId) { - let srcRowModel = this.autoDispose(srcTableModel.createFloatingRowModel()); - let srcCell = srcRowModel.cells[srcColId]; - // If no srcCell, linking is broken; do nothing. This shouldn't happen, but may happen - // transiently while the separate linking-related observables get updated. - if (srcCell) { - this.filterColValues = this.autoDispose(ko.computed(() => { - const srcRowId = srcSection.activeRowId(); - srcRowModel.assign(srcRowId); - return {filters: {[tgtColId]: [srcCell()]}, operations}; - })); - } - } else { - this.filterColValues = this.autoDispose(ko.computed(() => { - const srcRowId = srcSection.activeRowId(); - return {filters: {[tgtColId]: [srcRowId]}, operations}; - })); - } - } 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 srcRowId = srcSection.activeRowId(); - const filters = {}; - const operations = {}; - for (const c of srcSection.table().groupByColumns()) { - const col = c.summarySource(); - const colId = col.colId(); - const srcValue = srcTableData.getValue(srcRowId, colId); - filters[colId] = [srcValue]; - if (isDirectSummary) { - const tgtColType = col.type(); - if (tgtColType === 'ChoiceList' || tgtColType.startsWith('RefList:')) { - operations[colId] = 'intersects'; - } - } - } - return {filters, operations}; - })); - } 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 { - this.cursorPos = this.autoDispose(ko.computed(() => srcValueFunc(srcSection.activeRowId()))); - } -} -dispose.makeDisposable(LinkingState); - -/** - * Returns a boolean indicating whether editing should be disabled in the destination section. - */ -LinkingState.prototype.disableEditing = function() { - return this.filterColValues && this._srcSection.activeRowId() === 'new'; -}; - -module.exports = LinkingState; diff --git a/app/client/components/LinkingState.ts b/app/client/components/LinkingState.ts new file mode 100644 index 00000000..f11546d1 --- /dev/null +++ b/app/client/components/LinkingState.ts @@ -0,0 +1,145 @@ +import {GristDoc} from "app/client/components/GristDoc"; +import {DataRowModel} from "app/client/models/DataRowModel"; +import {TableRec} from "app/client/models/entities/TableRec"; +import {ViewSectionRec} from "app/client/models/entities/ViewSectionRec"; +import {LinkConfig} from "app/client/ui/selectBy"; +import {ClientQuery, QueryOperation} from "app/common/ActiveDocAPI"; +import {isRefListType} from "app/common/gristTypes"; +import * as gutil from "app/common/gutil"; +import {Disposable} from "grainjs"; +import * as ko from "knockout"; +import * as _ from "underscore"; + + +/** + * 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())); +} + +type FilterColValues = Pick; + +/** + * 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 { + public readonly cursorPos: ko.Computed | null; + public readonly filterColValues: ko.Computed | null; + private _srcSection: ViewSectionRec; + + constructor(gristDoc: GristDoc, linkConfig: LinkConfig) { + super(); + const {srcSection, srcColId, tgtSection, tgtCol, tgtColId} = linkConfig; + this._srcSection = srcSection; + + const srcTableModel = gristDoc.getTableModel(srcSection.table().tableId()); + const srcTableData = srcTableModel.tableData; + + // Function from srcRowId (i.e. srcSection.activeRowId()) to the source value. It is used for + // filtering or for cursor positioning, depending on the setting of tgtCol. + const srcValueFunc = srcColId ? srcTableData.getRowPropFunc(srcColId)! : _.identity; + + // If linking affects target section's cursor, this will be a computed for the cursor rowId. + this.cursorPos = null; + + // If linking affects filtering, this is a computed for the current filtering state, as a + // {[colId]: colValues} mapping, with a dependency on srcSection.activeRowId(). Otherwise, null. + this.filterColValues = null; + + // A computed that evaluates to a filter function to use, or null if not filtering. If + // filtering, depends on srcSection.activeRowId(). + if (tgtColId) { + const operations = {[tgtColId]: isRefListType(tgtCol.type()) ? 'intersects' : 'in' as QueryOperation}; + if (srcColId) { + const srcRowModel = this.autoDispose(srcTableModel.createFloatingRowModel()) as DataRowModel; + const srcCell = srcRowModel.cells[srcColId]; + // If no srcCell, linking is broken; do nothing. This shouldn't happen, but may happen + // transiently while the separate linking-related observables get updated. + if (srcCell) { + this.filterColValues = this.autoDispose(ko.computed(() => { + const srcRowId = srcSection.activeRowId(); + srcRowModel.assign(srcRowId); + return {filters: {[tgtColId]: [srcCell()]}, operations} as FilterColValues; + })); + } + } else { + this.filterColValues = this.autoDispose(ko.computed(() => { + const srcRowId = srcSection.activeRowId(); + return {filters: {[tgtColId]: [srcRowId]}, operations} as FilterColValues; + })); + } + } 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]; + 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 { + this.cursorPos = this.autoDispose(ko.computed(() => + srcValueFunc( + srcSection.activeRowId() as number + ) as number + )); + } + } + + /** + * Returns a boolean indicating whether editing should be disabled in the destination section. + */ + public disableEditing(): boolean { + return Boolean(this.filterColValues) && this._srcSection.activeRowId() === 'new'; + } +} diff --git a/app/client/declarations.d.ts b/app/client/declarations.d.ts index 1f3ac164..c8d89115 100644 --- a/app/client/declarations.d.ts +++ b/app/client/declarations.d.ts @@ -308,7 +308,7 @@ declare module "app/client/models/DataTableModel" { constructor(docModel: DocModel, tableData: TableData, tableMetaRow: TableRec); public createLazyRowsModel(sortedRowSet: SortedRowSet, optRowModelClass: any): DataTableModel.LazyArrayModel; - public createFloatingRowModel(optRowModelClass: any): BaseRowModel; + public createFloatingRowModel(optRowModelClass?: any): BaseRowModel; } export = DataTableModel; } diff --git a/app/client/models/DataTableModelWithDiff.ts b/app/client/models/DataTableModelWithDiff.ts index 74b839c4..5c9efa94 100644 --- a/app/client/models/DataTableModelWithDiff.ts +++ b/app/client/models/DataTableModelWithDiff.ts @@ -120,7 +120,7 @@ export class DataTableModelWithDiff extends DisposableWithEvents implements Data return this._wrappedModel.createLazyRowsModel(sortedRowSet, optRowModelClass); } - public createFloatingRowModel(optRowModelClass: any): BaseRowModel { + public createFloatingRowModel(optRowModelClass?: any): BaseRowModel { return this._wrappedModel.createFloatingRowModel(optRowModelClass); }