Added bidirectional linking (BIG DIFF)

This commit is contained in:
Janet Vorobyeva 2023-09-05 09:22:55 -07:00
parent 1b1970c075
commit d3126bec62
7 changed files with 215 additions and 26 deletions

View File

@ -131,8 +131,8 @@ function BaseView(gristDoc, viewSectionModel, options) {
// Update the cursor whenever linkedRowId() changes (but only if we have any linking).
this.autoDispose(this.linkedRowId.subscribe(rowId => {
if (this.viewSection.linkingState.peek()) {
this.setCursorPos({rowId: rowId || 'new'});
if (this.viewSection.linkingState.peek() && rowId != null) { //TODO JV: used to be that null meant "new", now it means "no cursor linking"
this.setCursorPos({rowId: rowId || 'new'}, true); //true b/c not a user-edit (caused by linking)
}
}));
@ -282,14 +282,14 @@ BaseView.prototype.deleteRecords = function(source) {
/**
* Sets the cursor to the given position, deferring if necessary until the current query finishes
* loading.
* loading. silentUpdate will be set if updating as a result of cursor linking(see Cursor.setCursorPos for info)
*/
BaseView.prototype.setCursorPos = function(cursorPos) {
BaseView.prototype.setCursorPos = function(cursorPos, silentUpdate = false) {
if (this.isDisposed()) {
return;
}
if (!this._isLoading.peek()) {
this.cursor.setCursorPos(cursorPos);
this.cursor.setCursorPos(cursorPos, silentUpdate);
} else {
// This is the first step; the second happens in onTableLoaded.
this._pendingCursorPos = cursorPos;

View File

@ -16,6 +16,21 @@ function nullAsUndefined<T>(value: T|null|undefined): T|undefined {
return value == null ? undefined : value;
}
// ================ SequenceNum: used to keep track of cursor edits (lastEditedAt)
// basically just a global auto-incrementing counter, with some types to make intent a bit more clear
// Each cursor starts at SequenceNEVER (0), and after that all cursors using NextSequenceNum() will have
// a unique, monotonically increasing number for their lastEditedAt()
export type SequenceNum = number;
export const SequenceNEVER: SequenceNum = 0;
let latestGlobalSequenceNum = SequenceNEVER;
const nextSequenceNum = () => { return latestGlobalSequenceNum++; };
// Note: we don't make any provisions for handling overflow. It's fine because:
// - Number.MAX_SAFE_INTEGER is 9,007,199,254,740,991 (9 * 10^15)
// - even at 1000 cursor-edits per second, it would take ~300,000 yrs to overflow
// - Plus it's client-side, so that's a single continuous 300-millenia-long session, which would be impressive uptime
/**
* Cursor represents the location of the cursor in the viewsection. It is maintained by BaseView,
* and implements the shared functionality related to the cursor cell.
@ -62,6 +77,12 @@ export class Cursor extends Disposable {
private _sectionId: ko.Computed<number>;
private _properRowId: ko.Computed<UIRowId|null>;
private _lastEditedAt: ko.Observable<SequenceNum>;
private _silentUpdatesFlag: boolean = false;
// lastEditedAt is updated on rowIndex or fieldIndex update (including through setCursorPos)
// _silentUpdatesFlag disables this, set when setCursorPos called from cursor link to prevent infinite loops
// WARNING: the flag approach will only work if ko observables work synchronously, which they appear to do.
constructor(baseView: BaseView, optCursorPos?: CursorPos) {
super();
@ -79,10 +100,13 @@ export class Cursor extends Disposable {
write: (index) => {
const rowIndex = index === null ? null : this.viewData.clampIndex(index);
this._rowId(rowIndex == null ? null : this.viewData.getRowId(rowIndex));
this.cursorEdited();
},
}));
this.fieldIndex = baseView.viewSection.viewFields().makeLiveIndex(optCursorPos.fieldIndex || 0);
this.fieldIndex.subscribe(() => { this.cursorEdited(); });
this.autoDispose(commands.createGroup(Cursor.editorCommands, this, baseView.viewSection.hasFocus));
// RowId might diverge from the one stored in _rowId when the data changes (it is filtered out). So here
@ -93,8 +117,11 @@ export class Cursor extends Disposable {
return rowId;
}));
// Update the section's activeRowId when the cursor's rowIndex is changed.
this._lastEditedAt = ko.observable(SequenceNEVER);
// update the section's activeRowId and lastCursorEdit when needed
this.autoDispose(this._properRowId.subscribe((rowId) => baseView.viewSection.activeRowId(rowId)));
this.autoDispose(this._lastEditedAt.subscribe((seqNum) => baseView.viewSection.lastCursorEdit(seqNum)));
// On dispose, save the current cursor position to the section model.
this.onDispose(() => { baseView.viewSection.lastCursorPos = this.getCursorPos(); });
@ -116,9 +143,16 @@ export class Cursor extends Disposable {
/**
* Moves the cursor to the given position. Only moves the row if rowId or rowIndex is valid,
* preferring rowId.
*
* silentUpdate prevents lastEditedAt from being updated, so linking doesn't cause an infinite loop of updates
* @param cursorPos: Position as { rowId?, rowIndex?, fieldIndex? }, as from getCursorPos().
* @param silentUpdate: should only be set if this is a cascading update from cursor-linking
*/
public setCursorPos(cursorPos: CursorPos): void {
public setCursorPos(cursorPos: CursorPos, silentUpdate: boolean = false): void {
//If updating as a result of links, we want to NOT update lastEditedAt
if(silentUpdate) { this._silentUpdatesFlag = true; }
//console.log(`CURSOR: ${silentUpdate}, silentUpdate=${this._silentUpdatesFlag}, lastUpdated = ${this.lastUpdated.peek()}`) //TODO JV DEBUG TEMP
if (cursorPos.rowId !== undefined && this.viewData.getRowIndex(cursorPos.rowId) >= 0) {
this.rowIndex(this.viewData.getRowIndex(cursorPos.rowId) );
} else if (cursorPos.rowIndex !== undefined && cursorPos.rowIndex >= 0) {
@ -130,9 +164,25 @@ export class Cursor extends Disposable {
if (cursorPos.fieldIndex !== undefined) {
this.fieldIndex(cursorPos.fieldIndex);
}
//console.log(`CURSOR-END: silentUpdate=${this._silentUpdatesFlag}, lastEditedAt = ${this._lastEditedAt.peek()} `); //TODO JV DEBUG TEMP
this._silentUpdatesFlag = false;
}
public setLive(isLive: boolean): void {
this._isLive(isLive);
}
//Should be called whenever the cursor is updated
//EXCEPT FOR: when cursor is set by linking
//this is used to determine which widget/cursor has most recently been touched,
//and therefore which one should be used to drive linking if there's a conflict
public cursorEdited(): void {
//If updating as a result of links, we want to NOT update lastEdited
if(!this._silentUpdatesFlag)
{ this._lastEditedAt(nextSequenceNum()); }
}
}

View File

@ -1120,7 +1120,8 @@ export class GristDoc extends DisposableWithEvents {
public async recursiveMoveToCursorPos(
cursorPos: CursorPos,
setAsActiveSection: boolean,
silent: boolean = false): Promise<boolean> {
silent: boolean = false,
visitedSections: number[] = []): Promise<boolean> {
try {
if (!cursorPos.sectionId) {
throw new Error('sectionId required');
@ -1132,6 +1133,12 @@ export class GristDoc extends DisposableWithEvents {
if (!section.id.peek()) {
throw new Error(`Section ${cursorPos.sectionId} does not exist`);
}
if (visitedSections.includes(section.id.peek())) {
//We've already been here (we hit a cycle), just return immediately
return true;
}
const srcSection = section.linkSrcSection.peek();
if (srcSection.id.peek()) {
// We're in a linked section, so we need to recurse to make sure the row we want
@ -1178,10 +1185,12 @@ export class GristDoc extends DisposableWithEvents {
if (!srcRowId || typeof srcRowId !== 'number') {
throw new Error('cannot trace rowId');
}
await this.recursiveMoveToCursorPos({
rowId: srcRowId,
sectionId: srcSection.id.peek(),
}, false, silent);
}, false, silent, visitedSections.concat([section.id.peek()]));
}
const view: ViewRec = section.view.peek();
const docPage: ViewDocPage = section.isRaw.peek() ? "data" : view.getRowId();

View File

@ -17,6 +17,7 @@ import merge = require('lodash/merge');
import mapValues = require('lodash/mapValues');
import pick = require('lodash/pick');
import pickBy = require('lodash/pickBy');
import {SequenceNEVER, SequenceNum} from "./Cursor";
// Descriptive string enum for each case of linking
@ -51,7 +52,13 @@ export const EmptyFilterColValues: FilterColValues = FilterStateToColValues(Empt
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>;
public readonly cursorPos?: ko.Computed<UIRowId|null>;
//TODO: JV bidirectional linking stuff
//It's a pair of [position, version]
//NOTE: observables don't do deep-equality check, so MUST NEVER change the value of the components individually,
//have to update the whole array so that `==` can catch the change
public readonly incomingCursorPos: ko.Computed<[UIRowId|null, SequenceNum]>;
// If linking affects filtering, this is a computed for the current filtering state, including user-facing
// labels for filter values and types of the filtered columns
@ -71,6 +78,7 @@ export class LinkingState extends Disposable {
private _docModel: DocModel;
private _srcSection: ViewSectionRec;
private _tgtSection: ViewSectionRec;
private _srcTableModel: DataTableModel;
private _srcColId: string | undefined;
@ -79,10 +87,14 @@ export class LinkingState extends Disposable {
const {srcSection, srcCol, srcColId, tgtSection, tgtCol, tgtColId} = linkConfig;
this._docModel = docModel;
this._srcSection = srcSection;
this._tgtSection = tgtSection;
this._srcColId = srcColId;
this._srcTableModel = docModel.dataTables[srcSection.table().tableId()];
const srcTableData = this._srcTableModel.tableData;
console.log(`============ LinkingState: Re-running constructor; tgtSec:${tgtSection.id()}: ${tgtSection.titleDef()}`); //TODO JV TEMP;
// === IMPORTANT NOTE! (this applies throughout this file)
// srcCol and tgtCol can be the "empty column"
// - emptyCol.getRowId() === 0
@ -194,13 +206,70 @@ export class LinkingState extends Disposable {
// 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
// gets the relevant col value for the passed-in rowId, or return rowId unchanged if same-table link
const srcValueFunc = this._makeValGetter(this._srcSection.table(), this._srcColId);
if (srcValueFunc) { // if makeValGetter succeeded, set up cursorPos
this.cursorPos = this.autoDispose(ko.computed(() =>
srcValueFunc(srcSection.activeRowId()) as UIRowId
));
if (srcValueFunc) {
//Incoming-cursor-pos determines what the linked cursor position should be only considering the previous
//linked section (srcSection) and all upstream sections (through srcSection.linkingState)
//does NOT take into account tgtSection, so will be out of date if tgtSection has been updated more recently
this.incomingCursorPos = this.autoDispose((ko.computed(() => {
// Note: prevLink is the link info for 2 hops behind the current (tgt) section. 1 hop back is this.srcSec;
// this.srcSec.linkingState is 2 hops back, (i.e. it reads from from this.srcSec.linkSrcSec)
const prevLink = this._srcSection.linkingState?.();
const prevLinkHasCursor = prevLink &&
(prevLink.linkTypeDescription() == "Cursor:Same-Table" ||
prevLink.linkTypeDescription() == "Cursor:Reference");
const [prevLinkedPos, prevLinkedVersion] = prevLinkHasCursor ? prevLink.incomingCursorPos(): [null, SequenceNEVER];
const srcSecPos = this._srcSection.activeRowId.peek(); //we don't depend on this, only on its cursor version
const srcSecVersion = this._srcSection.lastCursorEdit();
// is NEVER if viewSection's cursor hasn't yet initialized (shouldn't happen?)?
//TODO JV when will this happen? do some checks. Should get set on page load / setCursorPos to saved pos
// maybe more correct to just interpret NEVER as "never updated", which is already handled correctly
if(srcSecVersion == SequenceNEVER) {
console.log("=== linkingState: cursor-linking, srcSecVersion = NEVER");
return [null, SequenceNEVER] as [UIRowId|null, SequenceNum];
}
// ==== Determine whose info to use:
// If prevLinkedVersion < srcSecVersion, then the prev linked data is stale, don't use it
// If prevLinkedVersion == srcSecVersion, then srcSec is the driver for this link cycle (i.e. we're its first
// outgoing link), AND the link cycle has come all the way around
const useLinked = prevLinkHasCursor && prevLinkedVersion > srcSecVersion;
// srcSec/prevLinkedPos is rowId from srcSec. However if "Cursor:Reference", need to follow the ref in srcCol
// srcValueFunc will get the appropriate value based on this._srcColId
const tgtCursorPos = (srcValueFunc(useLinked ? prevLinkedPos : srcSecPos) || "new") as UIRowId;
// NOTE: srcValueFunc returns 'null' if rowId is the add-row, so we coerce that back into "new"
// NOTE: cursor linking is only ever done by the id column (for same-table) or by single Ref col (cursor:ref),
// so we'll never have to worry about `null` showing up as an actual cell-value, since null refs are `0`
return [
tgtCursorPos,
useLinked ? prevLinkedVersion : srcSecVersion, //propagate which version our cursorPos is from
] as [UIRowId|null, SequenceNum];
})));
//public readonly incomingCursorPos: ko.Computed<UIRowId>;
//This is the cursorPos that's directly applied to tgtSection, should be null if incoming link is outdated
//where null means "cursorPos does not apply to tgtSection and should be ignored"
this.cursorPos = this.autoDispose(ko.computed(() => {
const [incomingPos, incomingVersion]: [UIRowId|null, SequenceNum] = this.incomingCursorPos();
const tgtSecVersion = this._tgtSection.lastCursorEdit();
//if(!tgtSecVersion) { return null; }
if(incomingVersion > tgtSecVersion) { // if linked cursor newer that current sec, use it
return incomingPos;
} else { // else, there's no linked cursor, since current section is driving the linking
return null;
}
}));
}
if (!srcColId) { // If same-table cursor-link, copy getDefaultColValues from the source if possible

View File

@ -1,4 +1,5 @@
import BaseView from 'app/client/components/BaseView';
import {SequenceNEVER, SequenceNum} from 'app/client/components/Cursor';
import {EmptyFilterColValues, LinkingState} from 'app/client/components/LinkingState';
import {KoArray} from 'app/client/lib/koArray';
import {ColumnToMapImpl} from 'app/client/models/ColumnToMap';
@ -155,6 +156,8 @@ export interface ViewSectionRec extends IRowModel<"_grist_Views_section">, RuleO
activeRowId: ko.Observable<UIRowId | null>; // May be null when there are no rows.
lastCursorEdit: ko.Observable<SequenceNum>;
// If the view instance for section is instantiated, it will be accessible here.
viewInstance: ko.Observable<BaseView | null>;
@ -618,6 +621,7 @@ export function createViewSectionRec(this: ViewSectionRec, docModel: DocModel):
this.linkTargetCol = refRecord(docModel.columns, this.linkTargetColRef);
this.activeRowId = ko.observable<UIRowId|null>(null);
this.lastCursorEdit = ko.observable<SequenceNum>(SequenceNEVER);
this._linkingState = Holder.create(this);
this.linkingState = this.autoDispose(ko.pureComputed(() => {

View File

@ -644,7 +644,14 @@ export class RightPanel extends Disposable {
const lstate = use(tgtSec.linkingState);
const lfilter = lstate?.filterState ? use(lstate.filterState) : undefined;
const cursorPosStr = lstate?.cursorPos ? `${tgtSec.tableId()}[${use(lstate.cursorPos)}]` : "N/A";
//TODO JV TEMP DEBUG: srcLastUpdated is temporary
const cursorPosStr = (lstate?.cursorPos ? `${tgtSec.tableId()}[${use(lstate.cursorPos)}]` : "N/A") +
`\n srclastEdited: ${use(srcSec.lastCursorEdit)} \n tgtLastEdited: ${use(tgtSec.lastCursorEdit)}` +
`\n incomingCursorPos: ${lstate?.incomingCursorPos ? `${lstate.incomingCursorPos()}` : "N/A"}` +
(() => {
//const;
return '';
})();
//Main link info as a big string, will be in a <pre></pre> block
let preString = "No Incoming Link";

View File

@ -49,8 +49,12 @@ interface LinkNode {
groupbyColumns?: Set<number>;
// list of ids of the sections that are ancestors to this section according to the linked section
// relationship
ancestors: Set<number>;
// relationship. ancestors[0] is this.section, ancestors[last] is oldest ancestor
ancestors: number[];
//corresponds to ancestors array, but is 1 shorter.
// if isAncCursLink[0] == true, that means the link from ancestors[0] to ancestors[1] is a same-table cursor-link
isAncestorCursorLink: boolean[];
// the section record. Must be the empty record sections that are to be created.
section: ViewSectionRec;
@ -141,9 +145,39 @@ function isValidLink(source: LinkNode, target: LinkNode) {
}
}
// The link must not create a cycle
if (source.ancestors.has(target.section.getRowId())) {
return false;
//The link must not create a cycle, unless it's only same-table cursor-links all the way to target
if (source.ancestors.includes(target.section.getRowId())) {
//cycles only allowed for cursor links
if(source.column || target.column || source.isSummary) {
return false;
}
// Walk backwards along the chain of ancestors
// - if we hit a non-cursor link before reaching target, then that would be an illegal cycle
// - when we hit target, we've verified that this is a legal cycle, so break
// (ancestors further up the hierarchy past target don't matter, since once we set target.linkSrcSec = src.sec,
// they would stop being ancestors of src)
// NOTE: we're guaranteed to hit target before the end of the array (because of the `if` above)
// but I'm paranoid so let's check and throw if it happens
// ALSO NOTE: isAncestorCursorLink may be 1 shorter than ancestors, but it's accounted for by the above
for(let i = 0; ; i++) {
//We made it! All is well
if(source.ancestors[i] == target.section.getRowId()) {
break;
}
//If we've hit the last ancestor and haven't found target, error out (shouldn't happen)
if(i == source.ancestors.length-1) { throw Error("Error: Array doesn't include targetSection"); }
//Need to keep following links back, make sure this one is cursorLink
if(!source.isAncestorCursorLink[i]) {
return false;
}
}
console.log("===== selectBy found valid cycle", JSON.stringify(source)); //TODO JV TEMP DEBUG
//Yay, this is a valid cycle of same-table cursor-links
}
return true;
@ -224,15 +258,29 @@ function fromViewSectionRec(section: ViewSectionRec): LinkNode[] {
return [];
}
const table = section.table.peek();
const ancestors = new Set<number>();
const ancestors: number[] = [];
const isAncestorCursorLink: boolean[] = [];
for (let sec = section; sec.getRowId(); sec = sec.linkSrcSection.peek()) {
if (ancestors.has(sec.getRowId())) {
if (ancestors.includes(sec.getRowId())) {
// tslint:disable-next-line:no-console
console.warn(`Links should not create a cycle - section ids: ${Array.from(ancestors)}`);
console.warn(`Links should not create a cycle - section ids: ${ancestors}`);
//TODO JV: change this to only warn if cycles aren't all Cursor:Same-Table
break;
}
ancestors.add(sec.getRowId());
ancestors.push(sec.getRowId());
//isAncestorCursorLink may be 1 shorter than ancestors, since last ancestor has no incoming link
// however if we have a cycle (of cursor-links), then they'll be the same length
if(sec.linkSrcSection.peek().getRowId()) {
//TODO JV TEMP: Dear god determining if something is a cursor link or not is a nightmare
const srcCol = sec.linkSrcCol.peek().getRowId();
const tgtCol = sec.linkTargetCol.peek().getRowId();
const srcTable = sec.linkSrcSection.peek().table.peek();
const srcIsSummary = srcTable.primaryTableId.peek() !== srcTable.tableId.peek();
isAncestorCursorLink.push(srcCol == 0 && tgtCol == 0 && !srcIsSummary);
}
}
const isSummary = table.primaryTableId.peek() !== table.tableId.peek();
@ -243,6 +291,7 @@ function fromViewSectionRec(section: ViewSectionRec): LinkNode[] {
groupbyColumns: isSummary ? table.summarySourceColRefs.peek() : undefined,
widgetType: section.parentKey.peek(),
ancestors,
isAncestorCursorLink,
section,
};
@ -280,7 +329,8 @@ function fromPageWidget(docModel: DocModel, pageWidget: IPageWidget): LinkNode[]
// (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(),
ancestors: [],
isAncestorCursorLink: [],
section: docModel.viewSections.getRowModel(pageWidget.section),
};