mirror of
https://github.com/gristlabs/grist-core.git
synced 2026-03-02 04:09:24 +00:00
Added bidirectional linking (BIG DIFF)
This commit is contained in:
@@ -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;
|
||||
|
||||
@@ -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()); }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user