@ -9,137 +9,201 @@ import {FilterColValues, QueryOperation} from "app/common/ActiveDocAPI";
import { isList , isListType , isRefListType } from "app/common/gristTypes" ;
import { isList , isListType , isRefListType } from "app/common/gristTypes" ;
import * as gutil from "app/common/gutil" ;
import * as gutil from "app/common/gutil" ;
import { UIRowId } from 'app/plugin/GristAPI' ;
import { UIRowId } from 'app/plugin/GristAPI' ;
import { CellValue } from "app/plugin/GristData" ;
import { encodeObject } from 'app/plugin/objtypes' ;
import { encodeObject } from 'app/plugin/objtypes' ;
import { Disposable } from "grainjs" ;
import { Disposable , Holder , MultiHolder } from "grainjs" ;
import * as ko from "knockout" ;
import * as ko from "knockout" ;
import identity = require ( 'lodash/identity ') ;
import merge = require ( 'lodash/merge ') ;
import mapValues = require ( 'lodash/mapValues' ) ;
import mapValues = require ( 'lodash/mapValues' ) ;
import pick = require ( 'lodash/pick' ) ;
import pickBy = require ( 'lodash/pickBy' ) ;
import pickBy = require ( 'lodash/pickBy' ) ;
/ * *
// Descriptive string enum for each case of linking
* Returns if the first table is a summary of the second . If both are summary tables , returns true
// Currently used for rendering user-facing link info
* if the second table is a more detailed summary , i . e . has additional group - by columns .
// TODO JV: Eventually, switching the main block of linking logic in LinkingState constructor to be a big
* @param summary : TableRec for the table to check for being the summary table .
// switch(linkType){} would make things cleaner.
* @param detail : TableRec for the table to check for being the detailed version .
// TODO JV: also should add "Custom-widget-linked" to this, but holding off until Jarek's changes land
* @returns { Boolean } Whether the first argument is a summarized version of the second .
type LinkType = "Filter:Summary-Group" |
* /
"Filter:Col->Col" |
function isSummaryOf ( summary : TableRec , detail : TableRec ) : boolean {
"Filter:Row->Col" |
const summarySource = summary . summarySourceTable ( ) ;
"Summary" |
if ( summarySource === detail . getRowId ( ) ) { return true ; }
"Show-Referenced-Records" |
const detailSource = detail . summarySourceTable ( ) ;
"Cursor:Same-Table" |
return ( Boolean ( summarySource ) &&
"Cursor:Reference" |
detailSource === summarySource &&
"Error:Invalid" ;
summary . getRowId ( ) !== detail . getRowId ( ) &&
gutil . isSubset ( summary . summarySourceColRefs ( ) , detail . summarySourceColRefs ( ) ) ) ;
// 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 ) ;
/ * *
* 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 {
export class LinkingState extends Disposable {
// If linking affects target section's cursor, this will be a computed for the cursor rowId.
// 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 < UIRowId > ;
public readonly cursorPos? : ko.Computed < UIRowId > ;
// If linking affects filtering, this is a computed for the current filtering state, as a
// If linking affects filtering, this is a computed for the current filtering state, including user-facing
// {[colId]: colValues} mapping, with a dependency on srcSection.activeRowId()
// 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 < FilterState > ;
// filterColValues is a subset of the current filterState needed for filtering (subset of ClientQuery)
// {[colId]: colValues, [colId]: operations} mapping,
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
// Get default values for a new record so that it continues to satisfy the current linking filters
public readonly getDefaultColValues : ( ) = > any ;
public readonly getDefaultColValues : ( ) = > any ;
// Which case of linking we've got, this is a descriptive string-enum.
public readonly linkTypeDescription : ko.Computed < LinkType > ;
private _docModel : DocModel ;
private _srcSection : ViewSectionRec ;
private _srcSection : ViewSectionRec ;
private _srcTableModel : DataTableModel ;
private _srcTableModel : DataTableModel ;
private _srcCol : ColumnRec ;
private _srcColId : string | undefined ;
private _srcColId : string | undefined ;
constructor ( docModel : DocModel , 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 . _docModel = docModel ;
this . _srcSection = srcSection ;
this . _srcSection = srcSection ;
this . _srcCol = srcCol ;
this . _srcColId = srcColId ;
this . _srcColId = srcColId ;
this . _srcTableModel = docModel . dataTables [ srcSection . table ( ) . tableId ( ) ] ;
this . _srcTableModel = docModel . dataTables [ srcSection . table ( ) . tableId ( ) ] ;
const srcTableData = this . _srcTableModel . tableData ;
const srcTableData = this . _srcTableModel . tableData ;
if ( tgtColId ) {
// === IMPORTANT NOTE! (this applies throughout this file)
const operation = isRefListType ( tgtCol . type ( ) ) ? 'intersects' : 'in' ;
// srcCol and tgtCol can be the "empty column"
if ( srcSection . selectedRowsActive ( ) ) {
// - emptyCol.getRowId() === 0
this . filterColValues = this . _srcCustomFilter ( tgtColId , operation ) ;
// - emptyCol.colId() === undefined
} else if ( srcColId ) {
// The typical pattern to deal with this is to use `srcColId = col?.colId()`, and test for `if (srcColId) {...}`
this . filterColValues = this . _srcCellFilter ( tgtColId , operation ) ;
} else {
this . linkTypeDescription = this . autoDispose ( ko . computed ( ( ) : LinkType = > {
this . filterColValues = this . _simpleFilter ( tgtColId , operation , ( rowId = > [ rowId ] ) ) ;
if ( srcSection . isDisposed ( ) ) {
}
//srcSection disposed can happen transiently. Can happen when deleting tables and then undoing?
} else if ( srcColId && isRefListType ( srcCol . type ( ) ) ) {
//nbrowser tests: LinkingErrors and RawData seem to hit this case
this . filterColValues = this . _srcCellFilter ( 'id' , 'in' ) ;
console . warn ( "srcSection disposed in linkingState: linkTypeDescription" ) ;
} else if ( ! srcColId && isSummaryOf ( srcSection . table ( ) , tgtSection . table ( ) ) ) {
return "Error:Invalid" ;
// 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).
if ( srcSection . table ( ) . summarySourceTable ( ) && srcColId === "group" ) {
// TODO: This approach doesn't help cursor-linking (the other direction). If we have the
return "Filter:Summary-Group" ; //implemented as col->col, but special-cased in select-by
// inverse of summary-table's 'group' column, we could implement both, and more efficiently.
} else if ( srcColId && tgtColId ) {
const isDirectSummary = srcSection . table ( ) . summarySourceTable ( ) === tgtSection . table ( ) . getRowId ( ) ;
return "Filter:Col->Col" ;
const _filterColValues = ko . observable < FilterColValues > ( ) ;
} else if ( ! srcColId && tgtColId ) {
this . filterColValues = this . autoDispose ( ko . computed ( ( ) = > _filterColValues ( ) ) ) ;
return "Filter:Row->Col" ;
} else if ( srcColId && ! tgtColId ) { // Col->Row, i.e. show a ref
// source data table could still be loading (this could happen after changing the group by
if ( isRefListType ( srcCol . type ( ) ) ) // TODO: fix this once ref-links are unified, both could be show-ref-rec
// columns of a linked summary table for instance), hence the below listener.
{ return "Show-Referenced-Records" ; }
this . autoDispose ( srcTableData . dataLoadedEmitter . addListener ( _update ) ) ;
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 < FilterState > ( ) ;
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 < FilterState > | 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 ( ) ;
_update ( ) ;
function _update() {
const result : FilterColValues = { filters : { } , operations : { } } ;
// ================ CURSOR LINKS: =================
if ( srcSection . isDisposed ( ) ) {
} else { //!tgtCol && !summary-link && (!lookup-link || !reflist),
return result ;
// either same-table cursor-link (!srcCol && !tgtCol, so do activeRowId -> cursorPos)
}
// or cursor-link by reference ( srcCol && !tgtCol, so do srcCol -> cursorPos)
const srcRowId = srcSection . activeRowId ( ) ;
for ( const c of srcSection . table ( ) . groupByColumns ( ) ) {
//colVal, or rowId if no srcCol
const colId = c . colId ( ) ;
const srcValueFunc = this . _makeValGetter ( this . _srcSection . table ( ) , this . _srcColId ) ;
const srcValue = srcTableData . getValue ( srcRowId as number , colId ) ;
result . filters [ colId ] = [ srcValue ] ;
if ( srcValueFunc ) { // if makeValGetter succeeded, set up cursorPos
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 . selectedRowsActive ( ) ) {
this . filterColValues = this . _srcCustomFilter ( 'id' , 'in' ) ;
} else {
const srcValueFunc = srcColId ? this . _makeSrcCellGetter ( ) : identity ;
if ( srcValueFunc ) {
this . cursorPos = this . autoDispose ( ko . computed ( ( ) = >
this . cursorPos = this . autoDispose ( ko . computed ( ( ) = >
srcValueFunc ( srcSection . activeRowId ( ) ) as UIRowId
srcValueFunc ( srcSection . activeRowId ( ) ) as UIRowId
) ) ;
) ) ;
}
}
if ( ! srcColId ) {
if ( ! srcColId ) { // If same-table cursor-link, copy getDefaultColValues from the source if possible
// This is a same-record link: copy getDefaultColValues from the source if possible
const getDefaultColValues = srcSection . linkingState ( ) ? . getDefaultColValues ;
const getDefaultColValues = srcSection . linkingState ( ) ? . getDefaultColValues ;
if ( getDefaultColValues ) {
if ( getDefaultColValues ) {
this . getDefaultColValues = getDefaultColValues ;
this . getDefaultColValues = getDefaultColValues ;
@ -147,12 +211,18 @@ export class LinkingState extends Disposable {
}
}
}
}
// 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 ) {
if ( ! this . getDefaultColValues ) {
this . getDefaultColValues = ( ) = > {
this . getDefaultColValues = ( ) = > {
if ( ! this . filterColValues ) {
if ( ! this . filter State ) {
return { } ;
return { } ;
}
}
const { filters , operations } = this . filterColValues . peek ( ) ;
const { filters , operations } = this . filter State . peek ( ) ;
return mapValues (
return mapValues (
pickBy ( filters , ( value : any [ ] , key : string ) = > value . length > 0 && key !== "id" ) ,
pickBy ( filters , ( value : any [ ] , key : string ) = > value . length > 0 && key !== "id" ) ,
( value , key ) = > operations [ key ] === "intersects" ? encodeObject ( value ) : value [ 0 ]
( value , key ) = > operations [ key ] === "intersects" ? encodeObject ( value ) : value [ 0 ]
@ -165,71 +235,245 @@ export class LinkingState extends Disposable {
* Returns a boolean indicating whether editing should be disabled in the destination section .
* Returns a boolean indicating whether editing should be disabled in the destination section .
* /
* /
public disableEditing ( ) : boolean {
public disableEditing ( ) : boolean {
return Boolean ( this . filter ColValues ) && this . _srcSection . activeRowId ( ) === 'new' ;
return Boolean ( this . filter State ) && 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 [ ]
* Makes a standard filter link ( summary tables and cursor links handled separately )
) : ko . Computed < FilterColValues > {
* treats ( srcCol === undefined ) as srcColId === "id" , same for tgt
return this . autoDispose ( ko . computed ( ( ) = > {
*
* 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 < FilterState > | 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 ( ( ) = > {
//Get selector-rowId
const srcRowId = this . _srcSection . activeRowId ( ) ;
const srcRowId = this . _srcSection . activeRowId ( ) ;
if ( srcRowId === null ) {
if ( srcRowId === null ) {
console . warn ( "_simpleFilter activeRowId is null" ) ;
console . warn ( "_makeFilterObs activeRowId is null" ) ;
return { filters : { } , operations : { } } ;
return EmptyFilterState ;
}
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
//Get values from selector row
private _srcCellFilter ( colId : string , operation : QueryOperation ) : ko . Computed < FilterColValues > | undefined {
const selectorCellVal = selectorValGetter ( srcRowId ) ;
const srcCellGetter = this . _makeSrcCellGetter ( ) ;
const displayCellVal = displayValGetter ( srcRowId ) ;
if ( srcCellGetter ) {
const isSrcRefList = isRefListType ( this . _srcCol . type ( ) ) ;
// Coerce values into lists (FilterColValues wants output as a list, even if only 1 val)
return this . _simpleFilter ( colId , operation , rowId = > {
let filterValues : any [ ] ;
const value = srcCellGetter ( rowId ) ;
let displayValues : any [ ] ;
if ( isSrcRefList ) {
if ( ! isSrcRefList ) {
if ( isList ( value ) ) {
filterValues = [ selectorCellVal ] ;
return value . slice ( 1 ) ;
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 {
} else {
// The cell value is invalid, so the filter should be empty
console . warn ( "Error in LinkingState: displayVal list doesn't match selectorVal list " ) ;
return [ ] ;
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" ) ;
}
}
} else {
return [ 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
// Value for this.filterColValues based on the values in srcSection.selectedRows
private _srcCustomFilter ( colId : string , operation : QueryOperation ) : ko . Computed < FilterColValues > | undefined {
//"null" for column implies id column
private _srcCustomFilter (
column : ColumnRec | undefined , operation : QueryOperation ) : ko . Computed < FilterState > {
//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 ( ( ) = > {
return this . autoDispose ( ko . computed ( ( ) = > {
const values = this . _srcSection . selectedRows ( ) ;
const values = this . _srcSection . selectedRows ( ) ;
return { filters : { [ colId ] : values } , operations : { [ colId ] : operation } } as FilterColValues ;
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 function which returns the value of the cell
// Returns a ValGetter function, i.e. (rowId) => cellValue(rowId, colId), for the specified table and colId,
// in srcCol in the selected record of srcSection.
// Or null if there's an error in making the valgetter
// Uses a row model to create a dependency on the cell's value,
// Note:
// so changes to the cell value will notify observers
// - Uses a row model to create a dependency on the cell's value, so changes to the cell value will notify observers
private _makeSrcCellGetter() {
// - ValGetter returns null for the 'new' row
const srcRowModel = this . autoDispose ( this . _srcTableModel . createFloatingRowModel ( ) ) as DataRowModel ;
// - An undefined colId means to use the 'id' column, i.e. Valgetter is (rowId)=>rowId
const srcCellObs = srcRowModel . cells [ this . _srcColId ! ] ;
private _makeValGetter ( table : TableRec , colId : string | undefined , owner : MultiHolder = this )
// If no srcCellObs, linking is broken; do nothing. This shouldn't happen, but may happen
: ( 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.
// transiently while the separate linking-related observables get updated.
if ( ! srcCellObs ) {
if ( ! cellObs ) {
console . warn ( ` Issue in LinkingState._makeValGetter( ${ table . tableId ( ) } , ${ colId } ): cellObs is nullish ` ) ;
return null ;
return null ;
}
}
return ( rowId : UIRowId | null ) = > {
srcRowModel . assign ( rowId ) ;
return ( rowId : UIRowId | null ) = > { // returns cellValue | null
if ( rowId === 'new' ) {
rowModel . assign ( rowId ) ;
return 'new' ;
if ( rowId === 'new' ) { return null ; } // used to return "new", hopefully the change doesn't come back to haunt us
}
return cellObs ( ) ;
return srcCellObs ( ) ;
} ;
} ;
}
}
}
}
// === 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 ;
}
}