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