From 527e9670efdf5dcefcac2eca45662afc0d56fe0a Mon Sep 17 00:00:00 2001 From: Dmitry S Date: Fri, 29 Dec 2023 00:17:50 -0500 Subject: [PATCH] (core) Include linking rowIds into remembered cursor position and anchor links. Summary: When linking using a Reference List column, there may be multiple source records that show the same target record. With this change, we remember those (rather than just pick one that shows the target record). Test Plan: Added a browser test. Reviewers: jarek Reviewed By: jarek Differential Revision: https://phab.getgrist.com/D4140 --- app/client/components/BaseView.js | 3 +- app/client/components/CursorMonitor.ts | 7 +- app/client/components/GristDoc.ts | 48 +++- app/common/gristUrls.ts | 13 +- app/plugin/GristAPI-ti.ts | 1 + app/plugin/GristAPI.ts | 5 + test/fixtures/docs/CursorWithRefLists1.grist | Bin 0 -> 167936 bytes test/nbrowser/CursorSaving.ts | 223 +++++++++++++++++++ 8 files changed, 291 insertions(+), 9 deletions(-) create mode 100644 test/fixtures/docs/CursorWithRefLists1.grist create mode 100644 test/nbrowser/CursorSaving.ts diff --git a/app/client/components/BaseView.js b/app/client/components/BaseView.js index 10f3bd4b..b91bb1af 100644 --- a/app/client/components/BaseView.js +++ b/app/client/components/BaseView.js @@ -395,7 +395,8 @@ BaseView.prototype.getAnchorLinkForSection = function(sectionId) { const fieldIndex = this.cursor.fieldIndex.peek(); const field = fieldIndex !== null ? this.viewSection.viewFields().peek()[fieldIndex] : null; const colRef = field?.colRef.peek(); - return {hash: {sectionId, rowId, colRef}}; + const linkingRowIds = sectionId ? this.gristDoc.getLinkingRowIds(sectionId) : undefined; + return {hash: {sectionId, rowId, colRef, linkingRowIds}}; } // Copy an anchor link for the current row to the clipboard. diff --git a/app/client/components/CursorMonitor.ts b/app/client/components/CursorMonitor.ts index 1c875770..0490c811 100644 --- a/app/client/components/CursorMonitor.ts +++ b/app/client/components/CursorMonitor.ts @@ -59,7 +59,12 @@ export class CursorMonitor extends Disposable { if (!this._restored) { return; } // store position only when we have valid rowId // for some views (like CustomView) cursor position might not reflect actual row - if (pos && pos.rowId !== undefined) { this._storePosition(pos); } + if (pos && pos.rowId !== undefined) { + if (pos.sectionId) { + pos = {...pos, linkingRowIds: doc.getLinkingRowIds(pos.sectionId)}; + } + this._storePosition(pos); + } })); } diff --git a/app/client/components/GristDoc.ts b/app/client/components/GristDoc.ts index 8a030929..eab011ca 100644 --- a/app/client/components/GristDoc.ts +++ b/app/client/components/GristDoc.ts @@ -72,7 +72,7 @@ import {TableData} from 'app/common/TableData'; import {getGristConfig} from 'app/common/urlUtils'; import {DocStateComparison} from 'app/common/UserAPI'; import {AttachedCustomWidgets, IAttachedCustomWidget, IWidgetType} from 'app/common/widgetTypes'; -import {CursorPos} from 'app/plugin/GristAPI'; +import {CursorPos, UIRowId} from 'app/plugin/GristAPI'; import { bundleChanges, Computed, @@ -1181,6 +1181,29 @@ export class GristDoc extends DisposableWithEvents { return rulesTable.numRecords() > rulesTable.filterRowIds({permissionsText: '', permissions: 63}).length; } + /** + * If the given section is the target of linking, collect and return the active rowIDs up the + * chain of links, returning the list of rowIds starting with the current section's parent. This + * method is intended for when there is ambiguity such as when RefList linking is involved. + * In other cases, returns undefined. + */ + public getLinkingRowIds(sectionId: number): UIRowId[]|undefined { + const linkingRowIds: UIRowId[] = []; + let anyAmbiguity = false; + let section = this.docModel.viewSections.getRowModel(sectionId); + const seen = new Set(); + while (section?.id.peek() && !seen.has(section.id.peek())) { + seen.add(section.id.peek()); + const rowId = section.activeRowId.peek() || 'new'; + if (isRefListType(section.linkTargetCol.peek().type.peek()) || rowId === 'new') { + anyAmbiguity = true; + } + linkingRowIds.push(rowId); + section = section.linkSrcSection.peek(); + } + return anyAmbiguity ? linkingRowIds.slice(1) : undefined; + } + /** * Move to the desired cursor position. If colRef is supplied, the cursor will be * moved to a field with that colRef. Any linked sections that need their cursors @@ -1211,6 +1234,8 @@ export class GristDoc extends DisposableWithEvents { } const srcSection = section.linkSrcSection.peek(); + const linkingRowId = cursorPos.linkingRowIds?.[0]; + const linkingRowIds = cursorPos.linkingRowIds?.slice(1); if (srcSection.id.peek()) { // We're in a linked section, so we need to recurse to make sure the row we want // will be visible. @@ -1218,7 +1243,11 @@ export class GristDoc extends DisposableWithEvents { let controller: any; if (linkTargetCol.colId.peek()) { const destTable = await this._getTableData(section); - controller = destTable.getValue(cursorPos.rowId, linkTargetCol.colId.peek()); + if (cursorPos.rowId === 'new') { + controller = 'new'; + } else { + controller = destTable.getValue(cursorPos.rowId, linkTargetCol.colId.peek()); + } } else { controller = cursorPos.rowId; } @@ -1228,8 +1257,15 @@ export class GristDoc extends DisposableWithEvents { if (!colId && !isSrcSummary) { // Simple case - source linked by rowId, not a summary. if (isList(controller)) { - // Should be a reference list. Pick the first reference. - controller = controller[1]; // [0] is the L type code, [1] is the first value + // Should be a reference list. Use linkingRowId if available and present in the list, + if (linkingRowId && controller.indexOf(linkingRowId) > 0) { + controller = linkingRowId; + } else { + // Otherwise, pick the first reference. + controller = controller[1]; // [0] is the L type code, [1] is the first value + } + } else if (controller === 'new' && linkingRowId) { + controller = linkingRowId; } srcRowId = controller; } else { @@ -1253,12 +1289,13 @@ export class GristDoc extends DisposableWithEvents { } srcRowId = srcTable.getRowIds().find(getFilterFunc(this.docData, query)); } - if (!srcRowId || typeof srcRowId !== 'number') { + if (!srcRowId || (typeof srcRowId !== 'number' && srcRowId !== 'new')) { throw new Error('cannot trace rowId'); } await this.recursiveMoveToCursorPos({ rowId: srcRowId, sectionId: srcSection.id.peek(), + linkingRowIds, }, false, silent, visitedSections.concat([section.id.peek()])); } const view: ViewRec = section.view.peek(); @@ -1694,6 +1731,7 @@ export class GristDoc extends DisposableWithEvents { if (fieldIndex >= 0) { cursorPos.fieldIndex = fieldIndex; } + cursorPos.linkingRowIds = hash.linkingRowIds; } return cursorPos; } diff --git a/app/common/gristUrls.ts b/app/common/gristUrls.ts index eee562dd..486c370c 100644 --- a/app/common/gristUrls.ts +++ b/app/common/gristUrls.ts @@ -313,7 +313,11 @@ export function encodeUrl(gristConfig: Partial, hashParts.push('a1'); } for (const key of ['sectionId', 'rowId', 'colRef'] as Array) { - const partValue = hash[key]; + let enhancedRowId: string|undefined; + if (key === 'rowId' && hash.linkingRowIds?.length) { + enhancedRowId = [hash.rowId, ...hash.linkingRowIds].join("-"); + } + const partValue = enhancedRowId ?? hash[key]; if (partValue) { const partKey = key === 'rowId' && state.hash?.rickRow ? 'rr' : key[0]; hashParts.push(`${partKey}${partValue}`); @@ -512,7 +516,7 @@ export function decodeUrl(gristConfig: Partial, location: Locat 'sectionId', 'rowId', 'colRef', - ] as Array>; + ] as Array<'sectionId'|'rowId'|'colRef'>; for (const key of keys) { let ch: string; if (key === 'rowId' && hashMap.has('rr')) { @@ -525,6 +529,10 @@ export function decodeUrl(gristConfig: Partial, location: Locat const value = hashMap.get(ch); if (key === 'rowId' && value === 'new') { link[key] = 'new'; + } else if (key === 'rowId' && value && value.includes("-")) { + const rowIdParts = value.split("-").map(p => (p === 'new' ? p : parseInt(p, 10))); + link[key] = rowIdParts[0]; + link.linkingRowIds = rowIdParts.slice(1); } else { link[key] = parseInt(value!, 10); } @@ -1005,6 +1013,7 @@ export interface HashLink { popup?: boolean; rickRow?: boolean; recordCard?: boolean; + linkingRowIds?: UIRowId[]; } // Check whether a urlId is a prefix of the docId, and adequately long to be diff --git a/app/plugin/GristAPI-ti.ts b/app/plugin/GristAPI-ti.ts index 24168dac..c457e42d 100644 --- a/app/plugin/GristAPI-ti.ts +++ b/app/plugin/GristAPI-ti.ts @@ -11,6 +11,7 @@ export const CursorPos = t.iface([], { "rowIndex": t.opt("number"), "fieldIndex": t.opt("number"), "sectionId": t.opt("number"), + "linkingRowIds": t.opt(t.array("UIRowId")), }); export const ComponentKind = t.union(t.lit("safeBrowser"), t.lit("safePython"), t.lit("unsafeNode")); diff --git a/app/plugin/GristAPI.ts b/app/plugin/GristAPI.ts index 056b9ae0..c5c1fa5c 100644 --- a/app/plugin/GristAPI.ts +++ b/app/plugin/GristAPI.ts @@ -65,6 +65,11 @@ export interface CursorPos { * The id of a section that this cursor is in. Ignored when setting a cursor position for a particular view. */ sectionId?: number; + /** + * When in a linked section, CursorPos may include which rows in the controlling sections are + * selected: the rowId in the linking-source section, in _that_ section's linking source, etc. + */ + linkingRowIds?: UIRowId[]; } export type ComponentKind = "safeBrowser" | "safePython" | "unsafeNode"; diff --git a/test/fixtures/docs/CursorWithRefLists1.grist b/test/fixtures/docs/CursorWithRefLists1.grist new file mode 100644 index 0000000000000000000000000000000000000000..ec36547f4fc584ff1883341f1c2f03b91e657f9c GIT binary patch literal 167936 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 literal 0 HcmV?d00001 diff --git a/test/nbrowser/CursorSaving.ts b/test/nbrowser/CursorSaving.ts new file mode 100644 index 00000000..40321817 --- /dev/null +++ b/test/nbrowser/CursorSaving.ts @@ -0,0 +1,223 @@ +import {assert, driver} from 'mocha-webdriver'; +import * as gu from 'test/nbrowser/gristUtils'; +import {cleanupExtraWindows, setupTestSuite} from 'test/nbrowser/testUtils'; + +describe('CursorSaving', function() { + this.timeout(20000); + cleanupExtraWindows(); + const cleanup = setupTestSuite(); + const clipboard = gu.getLockableClipboard(); + afterEach(() => gu.checkForErrors()); + + describe('WithRefLists', function() { + before(async function() { + const session = await gu.session().login(); + await session.tempDoc(cleanup, "CursorWithRefLists1.grist"); + }); + + it('should remember positions when record is linked from multiple source records', async function() { + // Select Tag 'a' (row 1), and Item 'Apples' (row 1), which has tags 'b' and 'a'. + await clickAndCheck({section: 'Tags', rowNum: 1, col: 0}, 'a'); + await clickAndCheck({section: 'Items', rowNum: 1, col: 0}, 'Apple'); + + await gu.reloadDoc(); + assert.deepEqual(await gu.getCursorPosition('Tags'), {rowNum: 1, col: 0}); + assert.deepEqual(await gu.getCursorPosition('Items'), {rowNum: 1, col: 0}); + assert.equal(await gu.getCardCell('Name', 'Items Card').getText(), 'Apple'); + + // Now select a different Tag, but the same Item. + await clickAndCheck({section: 'Tags', rowNum: 2, col: 0}, 'b'); + await clickAndCheck({section: 'Items', rowNum: 1, col: 0}, 'Apple'); + + await gu.reloadDoc(); + assert.deepEqual(await gu.getCursorPosition('Tags'), {rowNum: 2, col: 0}); + assert.deepEqual(await gu.getCursorPosition('Items'), {rowNum: 1, col: 0}); + assert.equal(await gu.getCardCell('Name', 'Items Card').getText(), 'Apple'); + + // Try the third section. + await clickAndCheck({section: 'Items', rowNum: 3, col: 0}, 'Orange'); + await clickAndCheckCard({section: 'ITEMS Card', col: 'Name', rowNum: 1}, 'Orange'); + + await gu.reloadDoc(); + assert.deepEqual(await gu.getCursorPosition('Tags'), {rowNum: 2, col: 0}); + assert.deepEqual(await gu.getCursorPosition('Items'), {rowNum: 3, col: 0}); + assert.equal(await gu.getActiveSectionTitle(), 'ITEMS Card'); + assert.equal(await gu.getCardCell('Name', 'ITEMS Card').getText(), 'Orange'); + + // Try getting to the same card via different selections. + await clickAndCheck({section: 'Tags', rowNum: 1, col: 0}, 'a'); + await clickAndCheck({section: 'Items', rowNum: 2, col: 0}, 'Orange'); + await clickAndCheckCard({section: 'ITEMS Card', col: 'Name', rowNum: 1}, 'Orange'); + + await gu.reloadDoc(); + assert.deepEqual(await gu.getCursorPosition('Tags'), {rowNum: 1, col: 0}); + assert.deepEqual(await gu.getCursorPosition('Items'), {rowNum: 2, col: 0}); + assert.equal(await gu.getActiveSectionTitle(), 'ITEMS Card'); + assert.equal(await gu.getCardCell('Name', 'ITEMS Card').getText(), 'Orange'); + }); + + it('should remember positions when "new" row is involved', async function() { + // Try a position when when the parent record is on a "new" row. + await clickAndCheck({section: 'Tags', rowNum: 2, col: 0}, 'b'); + await clickAndCheck({section: 'Items', rowNum: 4, col: 0}, ''); + await clickAndCheckCard({section: 'ITEMS Card', col: 'Tags', rowNum: 1}, ''); + + await gu.reloadDoc(); + assert.deepEqual(await gu.getCursorPosition('Tags'), {rowNum: 2, col: 0}); + assert.deepEqual(await gu.getCursorPosition('Items'), {rowNum: 4, col: 0}); + assert.equal(await gu.getActiveSectionTitle(), 'ITEMS Card'); + assert.equal(await gu.getCardCell('Tags', 'ITEMS Card').getText(), ''); + + // Try a position when when the grandparent parent record is on a "new" row. + await clickAndCheck({section: 'Tags', rowNum: 4, col: 0}, ''); + assert.match(await gu.getSection('Items').find('.disable_viewpane').getText(), /No row selected/); + await clickAndCheckCard({section: 'ITEMS Card', col: 'Tags', rowNum: 1}, ''); + + await gu.reloadDoc(); + assert.deepEqual(await gu.getCursorPosition('Tags'), {rowNum: 4, col: 0}); + assert.match(await gu.getSection('Items').find('.disable_viewpane').getText(), /No row selected/); + assert.equal(await gu.getActiveSectionTitle(), 'ITEMS Card'); + assert.equal(await gu.getCardCell('Tags', 'ITEMS Card').getText(), ''); + }); + + it('should create anchor links that preserve row positions in linking sources', async function() { + await clickAndCheck({section: 'Tags', rowNum: 1, col: 0}, 'a'); + await clickAndCheck({section: 'Items', rowNum: 1, col: 0}, 'Apple'); + await gu.openRowMenu(1); + + const anchorLinks: string[] = []; + await clipboard.lockAndPerform(async () => { anchorLinks.push(await gu.getAnchor()); }); + + // Now select a different Tag, but the same Item. + await clickAndCheck({section: 'Tags', rowNum: 2, col: 0}, 'b'); + await clickAndCheck({section: 'Items', rowNum: 1, col: 0}, 'Apple'); + await clipboard.lockAndPerform(async () => { anchorLinks.push(await gu.getAnchor()); }); + + // Try the third section. + await clickAndCheck({section: 'Items', rowNum: 3, col: 0}, 'Orange'); + await clickAndCheckCard({section: 'ITEMS Card', col: 'Name', rowNum: 1}, 'Orange'); + await clipboard.lockAndPerform(async () => { anchorLinks.push(await gu.getAnchor()); }); + + // A different way to get to the same value in third section. + await clickAndCheck({section: 'Tags', rowNum: 1, col: 0}, 'a'); + await clickAndCheck({section: 'Items', rowNum: 2, col: 0}, 'Orange'); + await gu.getCardCell('Name', 'ITEMS Card').click(); + await clipboard.lockAndPerform(async () => { anchorLinks.push(await gu.getAnchor()); }); + + // Now go through the anchor links, and make sure each gets us to the expected point. + await driver.get(anchorLinks[0]); + assert.deepEqual(await gu.getCursorPosition('Tags'), {rowNum: 1, col: 0}); + assert.deepEqual(await gu.getCursorPosition('Items'), {rowNum: 1, col: 0}); + assert.equal(await gu.getCardCell('Name', 'Items Card').getText(), 'Apple'); + + await driver.get(anchorLinks[1]); + assert.deepEqual(await gu.getCursorPosition('Tags'), {rowNum: 2, col: 0}); + assert.deepEqual(await gu.getCursorPosition('Items'), {rowNum: 1, col: 0}); + assert.equal(await gu.getCardCell('Name', 'Items Card').getText(), 'Apple'); + + await driver.get(anchorLinks[2]); + assert.deepEqual(await gu.getCursorPosition('Tags'), {rowNum: 2, col: 0}); + assert.deepEqual(await gu.getCursorPosition('Items'), {rowNum: 3, col: 0}); + assert.equal(await gu.getActiveSectionTitle(), 'ITEMS Card'); + assert.equal(await gu.getCardCell('Name', 'ITEMS Card').getText(), 'Orange'); + + await driver.get(anchorLinks[3]); + assert.deepEqual(await gu.getCursorPosition('Tags'), {rowNum: 1, col: 0}); + assert.deepEqual(await gu.getCursorPosition('Items'), {rowNum: 2, col: 0}); + assert.equal(await gu.getActiveSectionTitle(), 'ITEMS Card'); + assert.equal(await gu.getCardCell('Name', 'ITEMS Card').getText(), 'Orange'); + }); + + it('should handle anchor links when "new" row is involved', async function() { + const anchorLinks: string[] = []; + + // Try a position when when the parent record is on a "new" row. + await clickAndCheck({section: 'Tags', rowNum: 2, col: 0}, 'b'); + await clickAndCheck({section: 'Items', rowNum: 4, col: 0}, ''); + await clickAndCheckCard({section: 'ITEMS Card', col: 'Tags', rowNum: 1}, ''); + + await clipboard.lockAndPerform(async () => { anchorLinks.push(await gu.getAnchor()); }); + + // Try a position when when the grandparent parent record is on a "new" row. + await clickAndCheck({section: 'Tags', rowNum: 4, col: 0}, ''); + assert.match(await gu.getSection('Items').find('.disable_viewpane').getText(), /No row selected/); + await clickAndCheckCard({section: 'ITEMS Card', col: 'Tags', rowNum: 1}, ''); + + await clipboard.lockAndPerform(async () => { anchorLinks.push(await gu.getAnchor()); }); + + await driver.get(anchorLinks[0]); + assert.deepEqual(await gu.getCursorPosition('Tags'), {rowNum: 2, col: 0}); + assert.deepEqual(await gu.getCursorPosition('Items'), {rowNum: 4, col: 0}); + assert.equal(await gu.getActiveSectionTitle(), 'ITEMS Card'); + assert.equal(await gu.getCardCell('Tags', 'ITEMS Card').getText(), ''); + + await driver.get(anchorLinks[1]); + assert.deepEqual(await gu.getCursorPosition('Tags'), {rowNum: 4, col: 0}); + assert.match(await gu.getSection('Items').find('.disable_viewpane').getText(), /No row selected/); + assert.equal(await gu.getActiveSectionTitle(), 'ITEMS Card'); + assert.equal(await gu.getCardCell('Tags', 'ITEMS Card').getText(), ''); + }); + }); + + describe('WithRefs', function() { + // This is a similar test to the above, but without RefLists. In particular it checks that + // when a cursor is in the "new" row, enough is remembered to restore positions. + + before(async function() { + const session = await gu.session().login(); + const doc = await session.tempDoc(cleanup, "World.grist", {load: false}); + await session.loadDoc(`/doc/${doc.id}/p/5`, {wait: true}); + }); + + it('should remember row positions in linked sections', async function() { + // Select a country and a city within it. + await clickAndCheck({section: 'Country', rowNum: 2, col: 0}, 'AFG'); + await clickAndCheck({section: 'City', rowNum: 4, col: 1}, 'Balkh'); + await gu.reloadDoc(); + assert.deepEqual(await gu.getCursorPosition('Country'), {rowNum: 2, col: 0}); + assert.deepEqual(await gu.getCursorPosition('City'), {rowNum: 4, col: 1}); + + // Now select a country, and the "new" row in the linked City widget. + await clickAndCheck({section: 'Country', rowNum: 3, col: 0}, 'AGO'); + await clickAndCheck({section: 'City', rowNum: 6, col: 1}, ''); + await gu.reloadDoc(); + assert.deepEqual(await gu.getCursorPosition('Country'), {rowNum: 3, col: 0}); + assert.deepEqual(await gu.getCursorPosition('City'), {rowNum: 6, col: 1}); + }); + + it('should create anchor links that preserve row positions in linked sections', async function() { + const anchorLinks: string[] = []; + + // Select a country and a city within it. + await clickAndCheck({section: 'Country', rowNum: 2, col: 0}, 'AFG'); + await clickAndCheck({section: 'City', rowNum: 4, col: 1}, 'Balkh'); + await clipboard.lockAndPerform(async () => { anchorLinks.push(await gu.getAnchor()); }); + + // Now select a country, and the "new" row in the linked City widget. + await clickAndCheck({section: 'Country', rowNum: 3, col: 0}, 'AGO'); + await clickAndCheck({section: 'City', rowNum: 6, col: 1}, ''); + + await clipboard.lockAndPerform(async () => { anchorLinks.push(await gu.getAnchor()); }); + + await driver.get(anchorLinks[0]); + assert.deepEqual(await gu.getCursorPosition('Country'), {rowNum: 2, col: 0}); + assert.deepEqual(await gu.getCursorPosition('City'), {rowNum: 4, col: 1}); + + await driver.get(anchorLinks[1]); + assert.deepEqual(await gu.getCursorPosition('Country'), {rowNum: 3, col: 0}); + assert.deepEqual(await gu.getCursorPosition('City'), {rowNum: 6, col: 1}); + }); + }); +}); + +async function clickAndCheck(options: gu.ICellSelect, expectedValue: string) { + const cell = gu.getCell(options); + await cell.click(); + assert.equal(await cell.getText(), expectedValue); +} + +async function clickAndCheckCard(options: gu.ICellSelect, expectedValue: string) { + const cell = gu.getDetailCell(options); + await cell.click(); + assert.equal(await cell.getText(), expectedValue); +}