import {SequenceNEVER, SequenceNum} from "app/client/components/Cursor"; 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 {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 {UIRowId} from 'app/plugin/GristAPI'; import {CellValue} from "app/plugin/GristData"; import {encodeObject} from 'app/plugin/objtypes'; import {Disposable, Holder, MultiHolder} from "grainjs"; import * as ko from "knockout"; import merge = require('lodash/merge'); import mapValues = require('lodash/mapValues'); import pick = require('lodash/pick'); import pickBy = require('lodash/pickBy'); // Descriptive string enum for each case of linking // Currently used for rendering user-facing link info // TODO JV: Eventually, switching the main block of linking logic in LinkingState constructor to be a big // switch(linkType){} would make things cleaner. // TODO JV: also should add "Custom-widget-linked" to this, but holding off until Jarek's changes land type LinkType = "Filter:Summary-Group" | "Filter:Col->Col"| "Filter:Row->Col"| "Summary"| "Show-Referenced-Records"| "Cursor:Same-Table"| "Cursor:Reference"| "Error:Invalid"; // If this LinkingState represents a filter link, it will set its filterState to this object // The filterColValues portion is just the data needed for filtering (same as manual filtering), and is passed // to the backend in some cases (CSV export) // The filterState includes extra info to display filter state to the user type FilterState = FilterColValues & { filterLabels: { [colId: string]: string[] }; //formatted and displayCol-ed values to show to user colTypes: {[colId: string]: string;} }; function FilterStateToColValues(fs: FilterState) { return pick(fs, ['filters', 'operations']); } //Since we're not making full objects for these, need to define sensible "empty" values here export const EmptyFilterState: FilterState = {filters: {}, filterLabels: {}, operations: {}, colTypes: {}}; export const EmptyFilterColValues: FilterColValues = FilterStateToColValues(EmptyFilterState); export class LinkingState extends Disposable { // If linking affects target section's cursor, this will be a computed for the cursor rowId. // Is undefined if not cursor-linked public readonly cursorPos?: ko.Computed; // Cursor-links can be cyclic, need to keep track of both rowId and the lastCursorEdit that it came from to // resolve it correctly, (use just one observable so they update at the same time) //NOTE: observables don't do deep-equality check, so need to replace the whole array when updating public readonly incomingCursorPos: ko.Computed<[UIRowId|null, SequenceNum]>; // If linking affects filtering, this is a computed for the current filtering state, including user-facing // labels for filter values and types of the filtered columns // with a dependency on srcSection.activeRowId() // Is undefined if not link-filtered public readonly filterState?: ko.Computed; // filterColValues is a subset of the current filterState needed for filtering (subset of ClientQuery) // {[colId]: colValues, [colId]: operations} mapping, public readonly filterColValues?: ko.Computed; // Get default values for a new record so that it continues to satisfy the current linking filters public readonly getDefaultColValues: () => any; // Which case of linking we've got, this is a descriptive string-enum. public readonly linkTypeDescription: ko.Computed; private _docModel: DocModel; private _srcSection: ViewSectionRec; private _srcTableModel: DataTableModel; private _srcColId: string | undefined; constructor(docModel: DocModel, linkConfig: LinkConfig) { super(); const {srcSection, srcCol, srcColId, tgtSection, tgtCol, tgtColId} = linkConfig; this._docModel = docModel; this._srcSection = srcSection; this._srcColId = srcColId; this._srcTableModel = docModel.dataTables[srcSection.table().tableId()]; const srcTableData = this._srcTableModel.tableData; // === IMPORTANT NOTE! (this applies throughout this file) // srcCol and tgtCol can be the "empty column" // - emptyCol.getRowId() === 0 // - emptyCol.colId() === undefined // The typical pattern to deal with this is to use `srcColId = col?.colId()`, and test for `if (srcColId) {...}` this.linkTypeDescription = this.autoDispose(ko.computed((): LinkType => { if (srcSection.isDisposed()) { //srcSection disposed can happen transiently. Can happen when deleting tables and then undoing? //nbrowser tests: LinkingErrors and RawData seem to hit this case console.warn("srcSection disposed in linkingState: linkTypeDescription"); return "Error:Invalid"; } if (srcSection.table().summarySourceTable() && srcColId === "group") { return "Filter:Summary-Group"; //implemented as col->col, but special-cased in select-by } else if (srcColId && tgtColId) { return "Filter:Col->Col"; } else if (!srcColId && tgtColId) { return "Filter:Row->Col"; } else if (srcColId && !tgtColId) { // Col->Row, i.e. show a ref if (isRefListType(srcCol.type())) // TODO: fix this once ref-links are unified, both could be show-ref-rec { return "Show-Referenced-Records"; } else { return "Cursor:Reference"; } } else if (!srcColId && !tgtColId) { //Either same-table cursor link OR summary link if (isSummaryOf(srcSection.table(), tgtSection.table())) { return "Summary"; } else { return "Cursor:Same-Table"; } } else { // This case shouldn't happen, but just check to be safe return "Error:Invalid"; } })); if (srcSection.selectedRowsActive()) { // old, special-cased custom filter const operation = (tgtColId && isRefListType(tgtCol.type())) ? 'intersects' : 'in'; this.filterState = this._srcCustomFilter(tgtCol, operation); // works whether tgtCol is the empty col or not } else if (tgtColId) { // Standard filter link // If srcCol is the empty col, is a row->col filter (i.e. id -> tgtCol) // else is a col->col filter (srcCol -> tgtCol) // MakeFilterObs handles it either way this.filterState = this._makeFilterObs(srcCol, tgtCol); } else if (srcColId && isRefListType(srcCol.type())) { // "Show Referenced Records" link // tgtCol is the emptycol (i.e. the id col) // srcCol must be a reference to the tgt table // Link will filter tgt section to show exactly the set of rowIds referenced by the srcCol // (NOTE: currently we only do this for reflists, single refs handled as cursor links for now) this.filterState = this._makeFilterObs(srcCol, undefined); } else if (!srcColId && isSummaryOf(srcSection.table(), tgtSection.table())) { //Summary linking // We do summary filtering if no cols specified and summary section is linked to a more detailed summary // (or to the summarySource table) // Implemented as multiple column filters, one for each groupByCol of the src table // temp vars for _update to use (can't set filterState directly since it's gotta be a computed) const _filterState = ko.observable(); this.filterState = this.autoDispose(ko.computed(() => _filterState())); // update may be called multiple times, so need a holder to handle disposal // Note: grainjs MultiHolder can't actually be cleared. To be able to dispose of multiple things, we need // to make a MultiHolder in a Holder, which feels ugly but works. // TODO: Update this if we ever patch grainjs to allow multiHolder.clear() const updateHolder = Holder.create(this); // source data table could still be loading (this could happen after changing the group-by // columns of a linked summary table for instance). Define an _update function to be called when data loads const _update = () => { if (srcSection.isDisposed() || srcSection.table().groupByColumns().length === 0) { // srcSection disposed can happen transiently. Can happen when deleting tables and then undoing? // Tests nbrowser/LinkingErrors and RawData might hit this case // groupByColumns === [] can happen if we make a summary tab [group by nothing]. (in which case: don't filter) _filterState(EmptyFilterState); return; } //Make a MultiHolder to own this invocation's objects (disposes of old one) //TODO (MultiHolder in a Holder is a bit of a hack, but needed to hold multiple objects I think) const updateMultiHolder = MultiHolder.create(updateHolder); //Make one filter for each groupBycolumn of srcSection const resultFilters: (ko.Computed|undefined)[] = srcSection.table().groupByColumns().map(srcGCol => this._makeFilterObs(srcGCol, summaryGetCorrespondingCol(srcGCol, tgtSection.table()), updateMultiHolder) ); //If any are undef (i.e. error in makeFilterObs), error out if(resultFilters.some((f) => f === undefined)) { console.warn("LINKINGSTATE: some of filters are undefined", resultFilters); _filterState(EmptyFilterState); return; } //Merge them together in a computed const resultComputed = updateMultiHolder.autoDispose(ko.computed(() => { return merge({}, ...resultFilters.map(filtObs => filtObs!())) as FilterState; })); _filterState(resultComputed()); resultComputed.subscribe((val) => _filterState(val)); }; // End of update function // Call update when data loads, also call now to be safe this.autoDispose(srcTableData.dataLoadedEmitter.addListener(_update)); _update(); // ================ CURSOR LINKS: ================= } else { //!tgtCol && !summary-link && (!lookup-link || !reflist), // either same-table cursor-link (!srcCol && !tgtCol, so do activeRowId -> cursorPos) // or cursor-link by reference ( srcCol && !tgtCol, so do srcCol -> cursorPos) // Cursor linking notes: // // If multiple viewSections are cursor-linked together A->B->C, we need to propagate the linked cursorPos along. // The old way was to have: A.activeRowId -> (sets by cursor-link) -> B.activeRowId, and so on // | // --> [B.LS] --> [C.LS] | // / | B.LS.cursorPos / | C.LS.cursorPos | // / v / v | // [ A ]--------/ [ B ] --------------/ [ C ] | // A.actRowId B.actRowId | // // However, if e.g. viewSec B is filtered, the correct rowId might not exist in B, and so its activeRowId would be // on a different row, and therefore the cursor linking would set C to a different row from A, even if it existed // in C // // Normally this wouldn't be too bad, but to implement bidirectional linking requires allowing cycles of // cursor-links, in which case this behavior becomes extra-problematic, both in being more unexpected from a UX // perspective and because a section will eventually be linked to itself, which is an unstable loop. // // A better solution is to propagate the linked rowId directly through the chain of linkingStates without passing // through the activeRowIds of the sections, so whether a section is filtered or not doesn't affect propagation. // // B.LS.incCursPos | // --> [B.LS] --------------> [C.LS] | // / | | | // / v B.LS.cursorPos v C.LS.cursorPos | // [ A ]--------/ [ B ] [ C ] | // A.actRowId | // // If the previous section has a linkingState, we use the previous LS's incomingCursorPos // (i.e. two sections back) instead of looking at our srcSection's activeRowId. This way it doesn't matter how // section B is filtered, since we're getting our cursorPos straight from A (through a computed in B.LS) // // However, each linkingState needs to decide whether to use the cursorPos from the srcSec (i.e. its activeRowId), // or to use the previous linkState's incomingCursorPos. We want to use whichever section the user most recently // interacted with, i.e. whichever cursor update was most recent. For this we use, the cursor version (given in // viewSection.lastCursorEdit). incomingCursorPos is a pair of [rowId, sequenceNum], so each linkingState sets its // incomingCursorPos to whichever is most recent between its srcSection, and the previous LS's incCursPos. // // If we do this right, the end result is that because the lastCursorEdits are guaranteed to be unique, // there is always a stable configuration of links, where even in the case of a cycle the incomingCursorPos-es // will all take their rowId and version from the most recently edited viewSection in the cycle, // which is what the user expects // // ...from C--> [A.LS] --------> [B.LS] --> [C.LS] ----->...to A | // | | / | | // v v / v | // [ A ] [ B ] ---------/ [ C ] | // (most recently edited) | // // Once the incomingCursorPos-es are determined correctly, the cursorPos-es just need to pull out the rowId, // and that will drive the cursors of the associated tgt section for each LS. // // NOTE: setting cursorPos *WILL* change the viewSections' cursor, but it's special-cased to // so that cursor-driven linking doesn't modify their lastCursorEdit times, so that lastCursorEdit // reflects only changes driven by external factors // (e.g. page load, user moving cursor, user changing linking settings/filter settings) // ============================= // gets the relevant col value for the passed-in rowId, or return rowId unchanged if same-table link const srcValueFunc = this._makeValGetter(this._srcSection.table(), this._srcColId); // check for failure if (srcValueFunc) { //Incoming-cursor-pos determines what the linked cursor position should be, considering the previous //linked section (srcSection) and all upstream sections (through srcSection.linkingState) this.incomingCursorPos = this.autoDispose((ko.computed(() => { // NOTE: This computed primarily decides between srcSec and prevLink. Here's what those mean: // e.g. consider sections A->B->C, (where this === C) // We need to decide between taking cursor info from B, our srcSection (1 hop back) // vs taking cursor info from further back, e.g. A, or before (2+ hops back) // To take cursor info from further back, we rely on B's linkingState, since B's linkingState will // be looking at the preceding sections, either A or whatever is behind A. // Therefore: we either use srcSection (1 back), or prevLink = srcSection.linkingState (2+ back) // Get srcSection's info (1 hop back) const srcSecPos = this._srcSection.activeRowId.peek(); //we don't depend on this, only on its cursor version const srcSecVersion = this._srcSection.lastCursorEdit(); // If cursors haven't been initialized, cursor-linking doesn't make sense, so don't do it if(srcSecVersion === SequenceNEVER) { return [null, SequenceNEVER] as [UIRowId|null, SequenceNum]; } // Get previous linkingstate's info, if applicable (2 or more hops back) const prevLink = this._srcSection.linkingState?.(); const prevLinkHasCursor = prevLink?.incomingCursorPos && (prevLink.linkTypeDescription() === "Cursor:Same-Table" || prevLink.linkTypeDescription() === "Cursor:Reference"); const [prevLinkedPos, prevLinkedVersion] = prevLinkHasCursor ? prevLink.incomingCursorPos() : [null, SequenceNEVER]; // ==== Determine whose info to use: // If prevLinkedVersion < srcSecVersion, then the prev linked data is stale, don't use it // If prevLinkedVersion == srcSecVersion, then srcSec is the driver for this link cycle (i.e. we're its first // outgoing link), AND the link cycle has come all the way around const usePrev = prevLinkHasCursor && prevLinkedVersion > srcSecVersion; // srcSec/prevLinkedPos is rowId from srcSec. However if "Cursor:Reference", we must follow the ref in srcCol // srcValueFunc will get the appropriate value based on this._srcColId if that's the case const tgtCursorPos = (srcValueFunc(usePrev ? prevLinkedPos : srcSecPos) || "new") as UIRowId; // NOTE: srcValueFunc returns 'null' if rowId is the add-row, so we coerce that back into || "new" // NOTE: cursor linking is only ever done by the id column (for same-table) or by single Ref col (cursor:ref), // so we'll never have to worry about `null` showing up as an actual cell-value. (A blank Ref is just `0`) return [ tgtCursorPos, usePrev ? prevLinkedVersion : srcSecVersion, //propagate which version our cursorPos is from ] as [UIRowId|null, SequenceNum]; }))); // Pull out just the rowId from incomingCursor Pos // (This get applied directly to tgtSection's cursor), this.cursorPos = this.autoDispose(ko.computed(() => this.incomingCursorPos()[0])); } if (!srcColId) { // If same-table cursor-link, copy getDefaultColValues from the source if possible const getDefaultColValues = srcSection.linkingState()?.getDefaultColValues; if (getDefaultColValues) { this.getDefaultColValues = getDefaultColValues; } } } // ======= End of cursor linking // Make filterColValues, which is just the filtering-relevant parts of filterState // (it's used in places that don't need the user-facing labels, e.g. CSV export) this.filterColValues = (this.filterState) ? ko.computed(() => FilterStateToColValues(this.filterState!())) : undefined; if (!this.getDefaultColValues) { this.getDefaultColValues = () => { if (!this.filterState) { return {}; } const {filters, operations} = this.filterState.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.filterState) && this._srcSection.activeRowId() === 'new'; } /** * Makes a standard filter link (summary tables and cursor links handled separately) * treats (srcCol === undefined) as srcColId === "id", same for tgt * * if srcColId === "id", uses src activeRowId as the selector value (i.e. a ref to that row) * else, gets the current value in selectedRow's SrcCol * * Returns a FilterColValues with a single filter {[tgtColId|"id":string] : (selectorVals:val[])} * note: selectorVals is always a list of values: if reflist the leading "L" is trimmed, if single val then [val] * * If unable to initialize (sometimes happens when things are loading?), returns undefined * * NOTE: srcColId and tgtColId MUST NOT both be undefined, that implies either cursor linking or summary linking, * which this doesn't handle * * @param srcCol srcCol for the filter, or undefined/the empty column to mean the entire record * @param tgtCol tgtCol for the filter, or undefined/the empty column to mean the entire record * @param [owner=this] Owner for all created disposables * @private */ private _makeFilterObs( srcCol: ColumnRec|undefined, tgtCol: ColumnRec|undefined, owner: MultiHolder = this): ko.Computed | undefined { const srcColId = srcCol?.colId(); const tgtColId = tgtCol?.colId(); //Assert: if both are null then it's a summary filter or same-table cursor-link, neither of which should go here if(!srcColId && !tgtColId) { throw Error("ERROR in _makeFilterObs: srcCol and tgtCol can't both be empty"); } //if (srcCol), selectorVal is the value in activeRowId[srcCol]. //if (!srcCol), then selectorVal is the entire record, so func just returns the rowId, or null if the rowId is "new" const selectorValGetter = this._makeValGetter(this._srcSection.table(), srcColId); // Figure out display val to show for the selector (if selector is a Ref) // - if srcCol is a ref, we display its displayColModel(), which is what is shown in the cell // - However, if srcColId === 'id', there is no srcCol.displayColModel. // We also can't use tgtCol.displayColModel, since we're getting values from the source section. // Therefore: The value we want to display is srcRow[tgtCol.visibleColModel.colId] // // Note: if we've gotten here, tgtCol is guaranteed to be a ref/reflist if srcColId === undefined // (because we ruled out the undef/undef case above) // Note: tgtCol.visibleCol.colId can be undefined, iff visibleCol is rowId. makeValGetter handles that implicitly const displayColId = srcColId ? srcCol!.displayColModel().colId() : tgtCol!.visibleColModel().colId(); const displayValGetter = this._makeValGetter(this._srcSection.table(), displayColId); //Note: if src is a reflist, its displayVal will be a list of the visibleCol vals, // i.e ["L", visVal1, visVal2], but they won't be formatter()-ed //Grab the formatter (for numerics, dates, etc) const displayValFormatter = srcColId ? srcCol!.visibleColFormatter() : tgtCol!.visibleColFormatter(); const isSrcRefList = srcColId && isRefListType(srcCol!.type()); const isTgtRefList = tgtColId && isRefListType(tgtCol!.type()); if (!selectorValGetter || !displayValGetter) { console.error("ERROR in _makeFilterObs: couldn't create valGetters for srcSection"); return undefined; } //Now, create the actual observable that updates with activeRowId //(we autodispose/return it at the end of the function) is this right? TODO JV return owner.autoDispose(ko.computed(() => { if (this._srcSection.isDisposed()) { //srcSection disposed can happen transiently. Can happen when deleting tables and then undoing? //nbrowser tests: LinkingErrors and RawData seem to hit this case console.warn("srcSection disposed in LinkingState._makeFilterObs"); return EmptyFilterState; } if (this._srcSection.isDisposed()) { //happened transiently in test: "RawData should remove all tables except one (...)" console.warn("LinkingState._makeFilterObs: srcSectionDisposed"); return EmptyFilterState; } //Get selector-rowId const srcRowId = this._srcSection.activeRowId(); if (srcRowId === null) { console.warn("LinkingState._makeFilterObs activeRowId is null"); return EmptyFilterState; } //Get values from selector row const selectorCellVal = selectorValGetter(srcRowId); const displayCellVal = displayValGetter(srcRowId); // Coerce values into lists (FilterColValues wants output as a list, even if only 1 val) let filterValues: any[]; let displayValues: any[]; if(!isSrcRefList) { filterValues = [selectorCellVal]; displayValues = [displayCellVal]; } else if(isSrcRefList && isList(selectorCellVal)) { //Reflists are: ["L", ref1, ref2, ...], slice off the L filterValues = selectorCellVal.slice(1); //selectorValue and displayValue might not match up? Shouldn't happen, but let's yell loudly if it does if (isList(displayCellVal) && displayCellVal.length === selectorCellVal.length) { displayValues = displayCellVal.slice(1); } else { console.warn("Error in LinkingState: displayVal list doesn't match selectorVal list "); displayValues = filterValues; //fallback to unformatted values } } else { //isSrcRefList && !isList(val), probably null. Happens with blank reflists, or if cursor on the 'new' row filterValues = []; displayValues = []; if(selectorCellVal !== null) { // should be null, but let's warn if it's not console.warn("Error in LinkingState.makeFilterObs(), srcVal is reflist but has non-list non-null value"); } } //Need to use 'intersects' for ChoiceLists or RefLists let operation = (tgtColId && isListType(tgtCol!.type())) ? 'intersects' : 'in'; // If selectorVal is a blank-cell value, need to change operation for correct behavior with lists // Blank selector shouldn't mean "show no records", it should mean "show records where tgt column is also blank" if(srcRowId !== 'new') { //(EXCEPTION: the add-row, which is when we ACTUALLY want to show no records) // If tgtCol is a list (RefList or Choicelist) and selectorVal is null/blank, operation must be 'empty' if (tgtCol?.type() === "ChoiceList" && !isSrcRefList && selectorCellVal === "") { operation = 'empty'; } else if (isTgtRefList && !isSrcRefList && selectorCellVal === 0) { operation = 'empty'; } else if (isTgtRefList && isSrcRefList && filterValues.length === 0) { operation = 'empty'; } // other types can have falsey values when non-blank (e.g. for numbers, 0 is a valid value; blank cell is null) // However, we don't need to check for those here, since we only care about lists (Reflist or Choicelist) // If tgtCol is a single ref, nullness is represented by [0], not by [], so need to create that null explicitly else if (!isTgtRefList && isSrcRefList && filterValues.length === 0) { filterValues = [0]; displayValues = ['']; } // NOTES ON CHOICELISTS: they only show up in a few cases. // - ChoiceList can only ever appear in links as the tgtcol // (ChoiceLists can only be linked from summ. tables, and summary flattens lists, so srcCol would be 'Choice') // - empty choicelist is [""]. } // Run values through formatters (for dates, numerics, Refs with visCol = rowId) const filterLabelVals: string[] = displayValues.map(v => displayValFormatter.formatAny(v)); return { filters: {[tgtColId || "id"]: filterValues}, filterLabels: {[tgtColId || "id"]: filterLabelVals}, operations: {[tgtColId || "id"]: operation}, colTypes: {[tgtColId || "id"]: (tgtCol || srcCol)!.type()} //at least one of tgt/srcCol is guaranteed to be non-null, and they will have the same type } as FilterState; })); } // Value for this.filterColValues based on the values in srcSection.selectedRows //"null" for column implies id column private _srcCustomFilter( column: ColumnRec|undefined, operation: QueryOperation): ko.Computed { //Note: column may be the empty column, i.e. column != undef, but column.colId() is undefined const colId = (!column || column.colId() === undefined) ? "id" : column.colId(); return this.autoDispose(ko.computed(() => { const values = this._srcSection.selectedRows(); return { filters: {[colId]: values}, filterLabels: {[colId]: values?.map(v => String(v))}, //selectedRows should never be null if customFiltered operations: {[colId]: operation}, colTypes: {[colId]: column?.type() || `Ref:${column?.table().tableId}`} } as FilterState; //TODO: fix this once we have cases of customwidget linking to test with })); } // Returns a ValGetter function, i.e. (rowId) => cellValue(rowId, colId), for the specified table and colId, // Or null if there's an error in making the valgetter // Note: // - Uses a row model to create a dependency on the cell's value, so changes to the cell value will notify observers // - ValGetter returns null for the 'new' row // - An undefined colId means to use the 'id' column, i.e. Valgetter is (rowId)=>rowId private _makeValGetter(table: TableRec, colId: string | undefined, owner: MultiHolder=this) : ( null | ((r: UIRowId | null) => CellValue | null) ) // (null | ValGetter) { if(colId === undefined) { //passthrough for id cols return (rowId: UIRowId | null) => { return rowId === 'new' ? null : rowId; }; } const tableModel = this._docModel.dataTables[table.tableId()]; const rowModel = (tableModel.createFloatingRowModel()) as DataRowModel; owner.autoDispose(rowModel); const cellObs = rowModel.cells[colId]; // If no cellObs, can't make a val getter. This shouldn't happen, but may happen // transiently while the separate linking-related observables get updated. if (!cellObs) { console.warn(`Issue in LinkingState._makeValGetter(${table.tableId()},${colId}): cellObs is nullish`); return null; } return (rowId: UIRowId | null) => { // returns cellValue | null rowModel.assign(rowId); if (rowId === 'new') { return null; } // used to return "new", hopefully the change doesn't come back to haunt us return cellObs(); }; } } // === Helpers: /** * Returns whether 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())); } /** * When TableA is a summary of TableB, each of TableA.groupByCols corresponds to a specific col of TableB * This function returns the column of B that corresponds to a particular groupByCol of A * - If A is a direct summary of B, then the corresponding col for A.someCol is A.someCol.summarySource() * - However if A and B are both summaries of C, then A.someCol.summarySource() would * give us C.someCol, but what we actually want is B.someCol. * - Since we know A is a summary of B, then B's groupByCols must include all of A's groupbycols, * so we can get B.someCol by matching on colId. * @param srcGBCol: ColumnRec, must be a groupByColumn, and srcGBCol.table() must be a summary of tgtTable * @param tgtTable: TableRec to get corresponding column from * @returns {ColumnRec} The corresponding column of tgtTable */ function summaryGetCorrespondingCol(srcGBCol: ColumnRec, tgtTable: TableRec): ColumnRec { if(!isSummaryOf(srcGBCol.table(), tgtTable)) { throw Error("ERROR in LinkingState summaryGetCorrespondingCol: srcTable must be summary of tgtTable"); } if(tgtTable.summarySourceTable() === 0) { //if direct summary return srcGBCol.summarySource(); } else { // else summary->summary, match by colId const srcColId = srcGBCol.colId(); const retVal = tgtTable.groupByColumns().find((tgtCol) => tgtCol.colId() === srcColId); //should always exist if(!retVal) { throw Error("ERROR in LinkingState summaryGetCorrespondingCol: summary table lacks groupby col"); } return retVal; } }