mirror of
https://github.com/gristlabs/grist-core.git
synced 2024-10-27 20:44:07 +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