mirror of
				https://github.com/gristlabs/grist-core.git
				synced 2025-06-13 20:53:59 +00:00 
			
		
		
		
	Linkstate refactor (#609)
* Linkingstate Refactor, and displaying link info in rightpanel
Big refactor to LinkingState
    Collects descriptive/user-facing labels into FilterState
    Unifies/cleans up some logic
    Adds LinkTypeDescription, a string enum which can be used
    to easily switch/case between various cases of linking, and
    codifies the logic in one place (currently only used for linkInfo)
Adds Link info to creator panel, near SelectBy dropdown
Bugfix: Disables linking from Attachment columns
Bugfix/Behavior change: changed linking with empty RefLists to better
match behavior of refs.
    for context: Linking by a blank Ref filters to show records with a
    blank value for that Ref. Previously this didn't work with RefLists. 
    Linking from a blank refList would show no records 
    (except in some cases involving summary tables)
    Fixed this so that linking by a blank val consistently means "show
    all records where the corresponding col is blank"
			
			
This commit is contained in:
		
							parent
							
								
									a0f5ab81ad
								
							
						
					
					
						commit
						f8c1bd612c
					
				| @ -9,16 +9,434 @@ 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} from "grainjs"; | ||||
| import {Disposable, Holder, MultiHolder} from "grainjs"; | ||||
| import * as  ko from "knockout"; | ||||
| import identity = require('lodash/identity'); | ||||
| 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<UIRowId>; | ||||
| 
 | ||||
|   // 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<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>; | ||||
| 
 | ||||
|   // 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<LinkType>; | ||||
| 
 | ||||
|   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<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(); | ||||
| 
 | ||||
|       // ================ 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)
 | ||||
| 
 | ||||
|       //colVal, or rowId if no srcCol
 | ||||
|       const srcValueFunc = this._makeValGetter(this._srcSection.table(), this._srcColId); | ||||
| 
 | ||||
|       if (srcValueFunc) { // if makeValGetter succeeded, set up cursorPos
 | ||||
|         this.cursorPos = this.autoDispose(ko.computed(() => | ||||
|           srcValueFunc(srcSection.activeRowId()) as UIRowId | ||||
|         )); | ||||
|       } | ||||
| 
 | ||||
|       if (!srcColId) { // If same-table cursor-link, copy getDefaultColValues from the source if possible
 | ||||
|         const getDefaultColValues = srcSection.linkingState()?.getDefaultColValues; | ||||
|         if (getDefaultColValues) { | ||||
|           this.getDefaultColValues = getDefaultColValues; | ||||
|         } | ||||
|       } | ||||
|     } | ||||
| 
 | ||||
|     // 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<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(); | ||||
|       if (srcRowId === null) { | ||||
|         console.warn("_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<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(() => { | ||||
|       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 if the first table is a summary of the second. If both are summary tables, returns true | ||||
|  * 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. | ||||
| @ -35,201 +453,27 @@ function isSummaryOf(summary: TableRec, detail: TableRec): boolean { | ||||
| } | ||||
| 
 | ||||
| /** | ||||
|  * 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. | ||||
|  * 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 | ||||
|  */ | ||||
| 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>; | ||||
| 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 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.selectedRowsActive()) { | ||||
|         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.selectedRowsActive()) { | ||||
|       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 = 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(); | ||||
|     }; | ||||
|   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; | ||||
|   } | ||||
| } | ||||
|  | ||||
| @ -1,5 +1,5 @@ | ||||
| import BaseView from 'app/client/components/BaseView'; | ||||
| import {LinkingState} from 'app/client/components/LinkingState'; | ||||
| import {EmptyFilterColValues, LinkingState} from 'app/client/components/LinkingState'; | ||||
| import {KoArray} from 'app/client/lib/koArray'; | ||||
| import {ColumnToMapImpl} from 'app/client/models/ColumnToMap'; | ||||
| import { | ||||
| @ -637,7 +637,7 @@ export function createViewSectionRec(this: ViewSectionRec, docModel: DocModel): | ||||
|   })); | ||||
| 
 | ||||
|   this.linkingFilter = this.autoDispose(ko.pureComputed(() => { | ||||
|     return this.linkingState()?.filterColValues?.() || {filters: {}, operations: {}}; | ||||
|     return this.linkingState()?.filterColValues?.() || EmptyFilterColValues; | ||||
|   })); | ||||
| 
 | ||||
|   // If the view instance for this section is instantiated, it will be accessible here.
 | ||||
|  | ||||
| @ -16,14 +16,15 @@ | ||||
| 
 | ||||
| import * as commands from 'app/client/components/commands'; | ||||
| import {GristDoc, IExtraTool, TabContent} from 'app/client/components/GristDoc'; | ||||
| import {EmptyFilterState} from "app/client/components/LinkingState"; | ||||
| import {RefSelect} from 'app/client/components/RefSelect'; | ||||
| import ViewConfigTab from 'app/client/components/ViewConfigTab'; | ||||
| import {domAsync} from 'app/client/lib/domAsync'; | ||||
| import * as imports from 'app/client/lib/imports'; | ||||
| import {makeT} from 'app/client/lib/localization'; | ||||
| import {createSessionObs} from 'app/client/lib/sessionObs'; | ||||
| import {createSessionObs, isBoolean, SessionObs} from 'app/client/lib/sessionObs'; | ||||
| import {reportError} from 'app/client/models/AppModel'; | ||||
| import {ViewSectionRec} from 'app/client/models/DocModel'; | ||||
| import {ColumnRec, ViewSectionRec} from 'app/client/models/DocModel'; | ||||
| import {CustomSectionConfig} from 'app/client/ui/CustomSectionConfig'; | ||||
| import {buildDescriptionConfig} from 'app/client/ui/DescriptionConfig'; | ||||
| import {BuildEditorOptions} from 'app/client/ui/FieldConfig'; | ||||
| @ -41,6 +42,8 @@ import {IconName} from 'app/client/ui2018/IconList'; | ||||
| import {icon} from 'app/client/ui2018/icons'; | ||||
| import {select} from 'app/client/ui2018/menus'; | ||||
| import {FieldBuilder} from 'app/client/widgets/FieldBuilder'; | ||||
| import {isFullReferencingType} from "app/common/gristTypes"; | ||||
| import {not} from 'app/common/gutil'; | ||||
| import {StringUnion} from 'app/common/StringUnion'; | ||||
| import {IWidgetType} from 'app/common/widgetTypes'; | ||||
| import { | ||||
| @ -60,6 +63,10 @@ import { | ||||
| } from 'grainjs'; | ||||
| import * as ko from 'knockout'; | ||||
| 
 | ||||
| // some unicode characters
 | ||||
| const BLACK_CIRCLE = '\u2022'; | ||||
| const ELEMENTOF = '\u2208'; //220A for small elementof
 | ||||
| 
 | ||||
| const t = makeT('RightPanel'); | ||||
| 
 | ||||
| // Represents a top tab of the right side-pane.
 | ||||
| @ -109,6 +116,10 @@ export class RightPanel extends Disposable { | ||||
|     return sec.getRowId() ? sec : null; | ||||
|   }); | ||||
| 
 | ||||
|   // Which subtab is open for configuring page widget.
 | ||||
|   private _advLinkInfoCollapsed = createSessionObs(this, "rightPageAdvancedLinkInfoCollapsed", | ||||
|                                                    true, isBoolean); | ||||
| 
 | ||||
|   constructor(private _gristDoc: GristDoc, private _isOpen: Observable<boolean>) { | ||||
|     super(); | ||||
|     this._extraTool = _gristDoc.rightPanelTool; | ||||
| @ -484,6 +495,189 @@ export class RightPanel extends Disposable { | ||||
|     return dom.maybe(viewConfigTab, (vct) => vct.buildSortFilterDom()); | ||||
|   } | ||||
| 
 | ||||
|   private _buildLinkInfo(activeSection: ViewSectionRec, ...domArgs: DomElementArg[]) { | ||||
|     //NOTE!: linkingState.filterState might transiently be EmptyFilterState while things load
 | ||||
|     //Each case (filters-table, id cols, etc) needs to be able to handle having lfilter.filterLabels = {}
 | ||||
|     const tgtSec = activeSection; | ||||
|     return dom.domComputed((use) => { | ||||
| 
 | ||||
|       const srcSec = use(tgtSec.linkSrcSection); //might be the empty section
 | ||||
|       const srcCol = use(tgtSec.linkSrcCol); | ||||
|       const srcColId = use(use(tgtSec.linkSrcCol).colId); // if srcCol is the empty col, colId will be undefined
 | ||||
|       //const tgtColId = use(use(tgtSec.linkTargetCol).colId);
 | ||||
|       const srcTable = use(srcSec.table); | ||||
|       const tgtTable = use(tgtSec.table); | ||||
| 
 | ||||
|       const lstate = use(tgtSec.linkingState); | ||||
|       if(lstate == null) { return null; } | ||||
| 
 | ||||
|       // if not filter-linking, this will be incorrect, but we don't use it then
 | ||||
|       const lfilter = lstate.filterState ? use(lstate.filterState): EmptyFilterState; | ||||
| 
 | ||||
|       //If it's null then no cursor-link is set, but in that case we won't show the string anyway.
 | ||||
|       const cursorPos = lstate.cursorPos ? use(lstate.cursorPos) : 0; | ||||
|       const linkedCursorStr =  cursorPos ? `${use(tgtTable.tableId)}[${cursorPos}]` : ''; | ||||
| 
 | ||||
|       // Make descriptor for the link's source like: "TableName . ColName" or "${SIGMA} TableName", etc
 | ||||
|       const fromTableDom = [ | ||||
|           dom.maybe((use2) => use2(srcTable.summarySourceTable), () => cssLinkInfoIcon("Pivot")), | ||||
|           use(srcSec.titleDef) + (srcColId ? ` ${BLACK_CIRCLE} ${use(srcCol.label)}` : ''), | ||||
|           dom.style("white-space", "normal"), //Allow table name to wrap, reduces how often scrollbar needed
 | ||||
|         ]; | ||||
| 
 | ||||
|       //Count filters for proper pluralization
 | ||||
|       const hasId = lfilter.filterLabels?.hasOwnProperty("id"); | ||||
|       const numFilters = Object.keys(lfilter.filterLabels).length - (hasId ? 1 : 0); | ||||
| 
 | ||||
|       // ================== Link-info Helpers
 | ||||
| 
 | ||||
|       //For each col-filter in lfilters, makes a row showing "${icon} colName = [filterVals]"
 | ||||
|       //FilterVals is in a box to look like a grid cell
 | ||||
|       const makeFiltersTable = (): DomContents => { | ||||
|         return cssLinkInfoBody( | ||||
|           dom.style("width", "100%"), //width 100 keeps table from growing outside bounds of flex parent if overfull
 | ||||
|           dom("table", | ||||
|             dom.style("margin-left", "8px"), | ||||
|             Object.keys(lfilter.filterLabels).map( (colId) => { | ||||
|               const vals = lfilter.filterLabels[colId]; | ||||
|               let operationSymbol = "="; | ||||
|               //if [filter (reflist) <- ref], op="intersects", need to convey "list has value". symbol =":"
 | ||||
|               //if [filter (ref) <- reflist], op="in", vals.length>1, need to convey "ref in list"
 | ||||
|               //Sometimes operation will be 'empty', but in that case "=" still works fine, i.e. "list = []"
 | ||||
|               if (lfilter.operations[colId] == "intersects") { operationSymbol = ":"; } | ||||
|               else if (vals.length > 1) { operationSymbol = ELEMENTOF; } | ||||
| 
 | ||||
|               if (colId == "id") { | ||||
|                 return dom("div", `ERROR: ID FILTER: ${colId}[${vals}]`); | ||||
|               } else { | ||||
|                 return dom("tr", | ||||
|                   dom("td", cssLinkInfoIcon("Filter"), | ||||
|                     `${colId}`), | ||||
|                   dom("td", operationSymbol, dom.style('padding', '0 2px 0 2px')), | ||||
|                   dom("td", cssLinkInfoValuesBox( | ||||
|                     isFullReferencingType(lfilter.colTypes[colId]) ? | ||||
|                       cssLinkInfoIcon("FieldReference"): null, | ||||
|                     `${vals.join(', ')}`)), | ||||
|                 ); | ||||
|             } }), //end of keys(filterLabels).map
 | ||||
|         )); | ||||
|       }; | ||||
| 
 | ||||
|       //Given a list of filterLabels, show them all in a box, as if a grid cell
 | ||||
|       //Shows a "Reference" icon in the left side, since this should only be used for reflinks and cursor links
 | ||||
|       const makeValuesBox = (valueLabels: string[]): DomContents => { | ||||
|         return cssLinkInfoBody(( | ||||
|             cssLinkInfoValuesBox( | ||||
|             cssLinkInfoIcon("FieldReference"), | ||||
|             valueLabels.join(', '), ) //TODO: join labels like "Entries[1], Entries[2]" to "Entries[[1,2]]"
 | ||||
|         )); | ||||
|       }; | ||||
| 
 | ||||
|       const linkType = lstate.linkTypeDescription(); | ||||
| 
 | ||||
|       return cssLinkInfoPanel(() => { switch (linkType) { | ||||
|           case "Filter:Summary-Group": | ||||
|           case "Filter:Col->Col": | ||||
|           case "Filter:Row->Col": | ||||
|           case "Summary": | ||||
|             return [ | ||||
|               dom("div", `Link applies filter${numFilters > 1 ? "s" : ""}:`), | ||||
|               makeFiltersTable(), | ||||
|               dom("div", `Linked from `, fromTableDom), | ||||
|             ]; | ||||
|           case "Show-Referenced-Records": { | ||||
|             //filterLabels might be {} if EmptyFilterState, so filterLabels["id"] might be undefined
 | ||||
|             const displayValues = lfilter.filterLabels["id"] ?? []; | ||||
|             return [ | ||||
|               dom("div", `Link shows record${displayValues.length > 1 ? "s" : ""}:`), | ||||
|               makeValuesBox(displayValues), | ||||
|               dom("div", `from `, fromTableDom), | ||||
|             ]; | ||||
|           } | ||||
|           case "Cursor:Same-Table": | ||||
|           case "Cursor:Reference": | ||||
|             return [ | ||||
|               dom("div", `Link sets cursor to:`), | ||||
|               makeValuesBox([linkedCursorStr]), | ||||
|               dom("div", `from `, fromTableDom), | ||||
|             ]; | ||||
|           case "Error:Invalid": | ||||
|           default: | ||||
|             return dom("div", `Error: Couldn't identify link state`); | ||||
|         } }, | ||||
|         ...domArgs | ||||
|       ); // End of cssLinkInfoPanel
 | ||||
|     }); | ||||
| } | ||||
| 
 | ||||
|   private _buildLinkInfoAdvanced(activeSection: ViewSectionRec) { | ||||
|     return  dom.domComputed((use): DomContents => { | ||||
|       //TODO: if this just outputs a string, this could really be in LinkingState as a toDebugStr function
 | ||||
|       //      but the fact that it's all observables makes that trickier to do correctly, so let's leave it here
 | ||||
|       const srcSec = use(activeSection.linkSrcSection); //might be the empty section
 | ||||
|       const tgtSec = activeSection; | ||||
|       const srcCol = use(activeSection.linkSrcCol); // might be the empty column
 | ||||
|       const tgtCol = use(activeSection.linkTargetCol); | ||||
|       // columns might be the empty column
 | ||||
|       // to check nullness, use `.getRowId() == 0` or `use(srcCol.colId) == undefined`
 | ||||
| 
 | ||||
|       const secToStr = (sec: ViewSectionRec) => (!sec || !sec.getRowId()) ? | ||||
|           'null' : | ||||
|           `#${use(sec.id)} "${use(sec.titleDef)}", (table "${use(use(sec.table).tableId)}")`; | ||||
|       const colToStr = (col: ColumnRec) => (!col || !col.getRowId()) ? | ||||
|           'null' : | ||||
|           `#${use(col.id)} "${use(col.colId)}", type "${use(col.type)}")`; | ||||
| 
 | ||||
|       // linkingState can be null if the constructor throws, so for debugging we want to show link info
 | ||||
|       // if either the viewSection or the linkingState claim there's a link
 | ||||
|       const hasLink = use(srcSec.id) != undefined || use(tgtSec.linkingState) != null; | ||||
|       const lstate = use(tgtSec.linkingState); | ||||
|       const lfilter = lstate?.filterState ? use(lstate.filterState) : undefined; | ||||
| 
 | ||||
|       const cursorPosStr = lstate?.cursorPos ? `${tgtSec.tableId()}[${use(lstate.cursorPos)}]` : "N/A"; | ||||
| 
 | ||||
|       //Main link info as a big string, will be in a <pre></pre> block
 | ||||
|       let preString = "No Incoming Link"; | ||||
|       if (hasLink) { | ||||
|         preString = [ | ||||
|           `From Sec: ${secToStr(srcSec)}`, | ||||
|           `To   Sec: ${secToStr(tgtSec)}`, | ||||
|           '', | ||||
|           `From Col: ${colToStr(srcCol)}`, | ||||
|           `To   Col: ${colToStr(tgtCol)}`, | ||||
|           '===========================', | ||||
|           // Show linkstate
 | ||||
|           lstate == null ? "LinkState: null" : [ | ||||
|               `Link Type: ${use(lstate.linkTypeDescription)}`, | ||||
|               ``, | ||||
| 
 | ||||
|               "Cursor Pos: " + cursorPosStr, | ||||
|               !lfilter ? "Filter State: null" : | ||||
|                 ["Filter State:", ...(Object.keys(lfilter).map(key => | ||||
|                   `- ${key}: ${JSON.stringify((lfilter as any)[key])}`))].join('\n'), | ||||
|             ].join('\n') | ||||
|         ].join('\n'); | ||||
|       } | ||||
| 
 | ||||
|       const collapsed: SessionObs<Boolean> = this._advLinkInfoCollapsed; | ||||
|       return hasLink ? [ | ||||
|           cssRow( | ||||
|             icon('Dropdown', dom.style('transform', (use2) => use2(collapsed) ? 'rotate(-90deg)' : '')), | ||||
|             "Advanced Link info", | ||||
|             dom.style('font-size', `${vars.smallFontSize}`), | ||||
|             dom.style('text-transform', 'uppercase'), | ||||
|             dom.style('cursor', 'pointer'), | ||||
|             dom.on('click', () => collapsed.set(!collapsed.get())), | ||||
|           ), | ||||
|           dom.maybe(not(collapsed), () => cssRow(cssLinkInfoPre(preString))) | ||||
|       ] : null; | ||||
|     }); | ||||
|   } | ||||
| 
 | ||||
| 
 | ||||
| 
 | ||||
| 
 | ||||
| 
 | ||||
|   private _buildPageDataConfig(owner: MultiHolder, activeSection: ViewSectionRec) { | ||||
|     const viewConfigTab = this._createViewConfigTab(owner); | ||||
|     const viewModel = this._gristDoc.viewModel; | ||||
| @ -570,15 +764,22 @@ export class RightPanel extends Disposable { | ||||
|         ), | ||||
|       ]), | ||||
| 
 | ||||
|       dom.maybe(activeSection.linkingState, () => cssRow(this._buildLinkInfo(activeSection))), | ||||
| 
 | ||||
|       domComputed((use) => { | ||||
|         const selectorFor = use(use(activeSection.linkedSections).getObservable()); | ||||
|         // TODO: sections should be listed following the order of appearance in the view layout (ie:
 | ||||
|         // left/right - top/bottom);
 | ||||
|         return selectorFor.length ? [ | ||||
|           cssLabel(t("SELECTOR FOR"), testId('selector-for')), | ||||
|           cssRow(cssList(selectorFor.map((sec) => this._buildSectionItem(sec)))) | ||||
|           cssRow(cssList(selectorFor.map((sec) => [ | ||||
|             this._buildSectionItem(sec) | ||||
|           ]))), | ||||
|         ] : null; | ||||
|       }), | ||||
| 
 | ||||
|       //Advanced link info is a little too JSON-ish for general use. But it's very useful for debugging
 | ||||
|       this._buildLinkInfoAdvanced(activeSection), | ||||
|     ]; | ||||
|   } | ||||
| 
 | ||||
| @ -597,6 +798,7 @@ export class RightPanel extends Disposable { | ||||
|   private _buildSectionItem(sec: ViewSectionRec) { | ||||
|     return cssListItem( | ||||
|       dom.text(sec.titleDef), | ||||
|       this._buildLinkInfo(sec, dom.style("border", "none")), | ||||
|       testId('selector-for-entry') | ||||
|     ); | ||||
|   } | ||||
| @ -865,3 +1067,65 @@ const cssTextInput = styled(textInput, ` | ||||
| const cssSection = styled('div', ` | ||||
|   position: relative; | ||||
| `);
 | ||||
| 
 | ||||
| 
 | ||||
| //============ LinkInfo CSS ============
 | ||||
| 
 | ||||
| //LinkInfoPanel is a flex-column
 | ||||
| //`LinkInfoPanel > table` is the table where we show linked filters, if there are any
 | ||||
| const cssLinkInfoPanel = styled('div', ` | ||||
|   width: 100%; | ||||
| 
 | ||||
|   display: flex; | ||||
|   flex-flow: column; | ||||
|   align-items: start; | ||||
|   text-align: left; | ||||
| 
 | ||||
|   font-family: ${vars.fontFamily}; | ||||
| 
 | ||||
|   border: 1px solid ${theme.pagePanelsBorder}; | ||||
|   border-radius: 4px; | ||||
| 
 | ||||
|   padding: 6px; | ||||
| 
 | ||||
|   white-space: nowrap; | ||||
|   overflow-x: auto; | ||||
| 
 | ||||
|   & table { | ||||
|       border-spacing: 2px; | ||||
|       border-collapse: separate; | ||||
|   } | ||||
| `);
 | ||||
| 
 | ||||
| // Center table / values box inside LinkInfoPanel
 | ||||
| const cssLinkInfoBody= styled('div', ` | ||||
|   margin: 2px 0 2px 0; | ||||
|   align-self: center; | ||||
| `);
 | ||||
| 
 | ||||
| // Intended to imitate style of a grid cell
 | ||||
| // white-space: normal allows multiple values to wrap
 | ||||
| // min-height: 22px matches real field size, +2 for the borders
 | ||||
| const cssLinkInfoValuesBox = styled('div', ` | ||||
|   border: 1px solid ${'#CCC'}; | ||||
|   padding: 3px 3px 0px 3px; | ||||
|   min-width: 60px; | ||||
|   min-height: 24px; | ||||
| 
 | ||||
|   white-space: normal; | ||||
| `);
 | ||||
| 
 | ||||
| //If inline with text, icons look better shifted up slightly
 | ||||
| //since icons are position:relative, bottom:1 should shift it without affecting layout
 | ||||
| const cssLinkInfoIcon = styled(icon, ` | ||||
|   bottom: 1px; | ||||
|   margin-right: 3px; | ||||
|   background-color: ${theme.controlSecondaryFg}; | ||||
| `);
 | ||||
| 
 | ||||
| // ============== styles for _buildLinkInfoAdvanced
 | ||||
| const cssLinkInfoPre = styled("pre", ` | ||||
|   padding: 6px; | ||||
|   font-size: ${vars.smallFontSize}; | ||||
|   line-height: 1.2; | ||||
| `);
 | ||||
|  | ||||
| @ -42,6 +42,9 @@ interface LinkNode { | ||||
|   // is the table a summary table
 | ||||
|   isSummary: boolean; | ||||
| 
 | ||||
|   // does this node involve an "Attachments" column. Can be tricky if Attachments is one of groupby cols
 | ||||
|   isAttachments: boolean; | ||||
| 
 | ||||
|   // For a summary table, the set of col refs of the groupby columns of the underlying table
 | ||||
|   groupbyColumns?: Set<number>; | ||||
| 
 | ||||
| @ -114,6 +117,12 @@ function isValidLink(source: LinkNode, target: LinkNode) { | ||||
|     return false; | ||||
|   } | ||||
| 
 | ||||
|   //cannot select from attachments, even though they're implemented as reflists
 | ||||
|   if (source.isAttachments || target.isAttachments) { | ||||
|     return false; | ||||
|   } | ||||
| 
 | ||||
| 
 | ||||
|   // cannot select from chart
 | ||||
|   if (source.widgetType === 'chart') { | ||||
|     return false; | ||||
| @ -230,6 +239,7 @@ function fromViewSectionRec(section: ViewSectionRec): LinkNode[] { | ||||
|   const mainNode: LinkNode = { | ||||
|     tableId: table.primaryTableId.peek(), | ||||
|     isSummary, | ||||
|     isAttachments: isSummary && table.groupByColumns.peek().some(col => col.type.peek() == "Attachments"), | ||||
|     groupbyColumns: isSummary ? table.summarySourceColRefs.peek() : undefined, | ||||
|     widgetType: section.parentKey.peek(), | ||||
|     ancestors, | ||||
| @ -266,6 +276,8 @@ function fromPageWidget(docModel: DocModel, pageWidget: IPageWidget): LinkNode[] | ||||
|   const mainNode: LinkNode = { | ||||
|     tableId: table.primaryTableId.peek(), | ||||
|     isSummary, | ||||
|     isAttachments: false, // hmm, we should need a check here in case attachments col is on the main-node link
 | ||||
|     // (e.g.: link from summary table with Attachments in group-by) but it seems to work fine as is
 | ||||
|     groupbyColumns, | ||||
|     widgetType: pageWidget.type, | ||||
|     ancestors: new Set(), | ||||
| @ -284,7 +296,7 @@ function fromColumns(table: TableRec, mainNode: LinkNode, tableExists: boolean = | ||||
|     } | ||||
|     const tableId = getReferencedTableId(column.type.peek()); | ||||
|     if (tableId) { | ||||
|       nodes.push({...mainNode, tableId, column}); | ||||
|       nodes.push({...mainNode, tableId, column, isAttachments: column.type.peek() == "Attachments"}); | ||||
|     } | ||||
|   } | ||||
|   return nodes; | ||||
|  | ||||
| @ -245,7 +245,7 @@ describe('RightPanel', function() { | ||||
| 
 | ||||
|     // check that selector-of is present and that all selected section are listed
 | ||||
|     assert.equal(await driver.find('.test-selector-for').isPresent(), true); | ||||
|     assert.deepEqual(await driver.findAll('.test-selector-for-entry', (e) => e.getText()), [ | ||||
|     assert.deepEqual(await driver.findAll('.test-selector-for-entry', (e) => e.getText().then(s => s.split('\n')[0])), [ | ||||
|       "CITY", | ||||
|       "COUNTRYLANGUAGE", | ||||
|       "COUNTRY Card List", | ||||
|  | ||||
		Loading…
	
		Reference in New Issue
	
	Block a user