From 083a20417e2ba654101d6b8b5d4e0f8e090202d4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jaros=C5=82aw=20Sadzi=C5=84ski?= Date: Tue, 10 Oct 2023 14:54:50 +0200 Subject: [PATCH] (core) Tests and bug fixes for bidirectional linking Summary: - Adding tests for bidirectional linking - Fixing loop bug for bidirectional linking in custom widgets which use row filtering Test Plan: New tests Reviewers: JakubSerafin Reviewed By: JakubSerafin Differential Revision: https://phab.getgrist.com/D4070 --- app/client/components/LinkingState.ts | 2 +- app/client/ui/selectBy.ts | 17 ++ app/plugin/grist-plugin-api.ts | 5 +- test/nbrowser/CustomWidgetsConfig.ts | 92 +++------ test/nbrowser/LinkingBidirectional.ts | 280 ++++++++++++++++++++++++++ test/nbrowser/LinkingSelector.ts | 3 +- test/nbrowser/gristUtils.ts | 120 +++++++++-- 7 files changed, 439 insertions(+), 80 deletions(-) create mode 100644 test/nbrowser/LinkingBidirectional.ts diff --git a/app/client/components/LinkingState.ts b/app/client/components/LinkingState.ts index 115e127a..5481ff38 100644 --- a/app/client/components/LinkingState.ts +++ b/app/client/components/LinkingState.ts @@ -286,7 +286,7 @@ export class LinkingState extends Disposable { // Get previous linkingstate's info, if applicable (2 or more hops back) const prevLink = this._srcSection.linkingState?.(); - const prevLinkHasCursor = prevLink && + const prevLinkHasCursor = prevLink?.incomingCursorPos && (prevLink.linkTypeDescription() === "Cursor:Same-Table" || prevLink.linkTypeDescription() === "Cursor:Reference"); const [prevLinkedPos, prevLinkedVersion] = prevLinkHasCursor ? prevLink.incomingCursorPos() : diff --git a/app/client/ui/selectBy.ts b/app/client/ui/selectBy.ts index 2c754d00..7cb08b3d 100644 --- a/app/client/ui/selectBy.ts +++ b/app/client/ui/selectBy.ts @@ -156,6 +156,11 @@ function isValidLink(source: LinkNode, target: LinkNode) { return false; } + // If one of the section has custom row filter, we can't make cycles. + if (target.section.selectedRowsActive()) { + return false; + } + // We know our ancestors cycle back around to ourselves // - lets walk back along the cyclic portion of the ancestor chain and verify that each link in that chain is // a cursor-link @@ -426,6 +431,18 @@ export class LinkConfig { assert(srcTableId, "srcCol not a valid reference"); } assert(srcTableId === tgtTableId, "mismatched tableIds"); + + // If this section has a custom link filter, it can't create cycles. + if (this.tgtSection.selectedRowsActive()) { + // Make sure we don't have a cycle. + let src = this.tgtSection.linkSrcSection(); + while (!src.isDisposed() && src.getRowId()) { + assert(src.getRowId() !== this.srcSection.getRowId(), + "Sections with filter linking can't be part of a cycle (same record linking)'"); + src = src.linkSrcSection(); + } + } + } catch (e) { throw new Error(`LinkConfig invalid: ` + `${this.srcSection.getRowId()}:${this.srcCol?.getRowId()}[${srcTableId}] -> ` + diff --git a/app/plugin/grist-plugin-api.ts b/app/plugin/grist-plugin-api.ts index 9c3fbf51..001947eb 100644 --- a/app/plugin/grist-plugin-api.ts +++ b/app/plugin/grist-plugin-api.ts @@ -77,11 +77,12 @@ export const allowSelectBy = viewApi.allowSelectBy; */ export const setSelectedRows = viewApi.setSelectedRows; - +/** + * Sets the cursor position in a linked section. + */ export const setCursorPos = viewApi.setCursorPos; - /** * Fetches data backing the widget as for [[GristView.fetchSelectedTable]], * but decoding data by default, replacing e.g. ['D', timestamp] with diff --git a/test/nbrowser/CustomWidgetsConfig.ts b/test/nbrowser/CustomWidgetsConfig.ts index 269c0893..35232ffc 100644 --- a/test/nbrowser/CustomWidgetsConfig.ts +++ b/test/nbrowser/CustomWidgetsConfig.ts @@ -57,27 +57,6 @@ async function getListItems(col: string) { .findAll(`.test-config-widget-map-list-for-${col} .test-config-widget-ref-select-label`, el => el.getText()); } -// Gets or sets access level -async function givenAccess(level?: AccessLevel) { - const text = { - [AccessLevel.none]: 'No document access', - [AccessLevel.read_table]: 'Read selected table', - [AccessLevel.full]: 'Full document access', - }; - if (!level) { - const currentAccess = await driver.find('.test-config-widget-access .test-select-open').getText(); - return Object.entries(text).find(e => e[1] === currentAccess)![0]; - } else { - await driver.find('.test-config-widget-access .test-select-open').click(); - await driver.findContent('.test-select-menu li', text[level]).click(); - await gu.waitForServer(); - } -} - -// Checks if access prompt is visible. -const hasPrompt = () => driver.find('.test-config-widget-access-accept').isPresent(); -// Accepts new access level. -const accept = () => driver.find('.test-config-widget-access-accept').click(); // When refreshing, we need to make sure widget repository is enabled once again. async function refresh() { await driver.navigate().refresh(); @@ -88,19 +67,6 @@ async function refresh() { await gu.selectSectionByTitle('Widget'); } -async function selectAccess(access: string) { - // if the current access is ok do nothing - if ((await givenAccess()) === access) { - // unless we need to confirm it - if (await hasPrompt()) { - await accept(); - } - } else { - // else switch access level - await givenAccess(access as AccessLevel); - } -} - // Checks if active section has option in the menu to open configuration async function hasSectionOption() { const menu = await gu.openSectionMenu('viewLayout'); @@ -158,7 +124,7 @@ async function checkSortMenu(state: 'empty' | 'modified' | 'customized' | 'empty } describe('CustomWidgetsConfig', function () { - this.timeout(60000); + this.timeout('60s'); const cleanup = setupTestSuite(); let mainSession: gu.Session; gu.bigScreen(); @@ -371,7 +337,7 @@ describe('CustomWidgetsConfig', function () { }) ); - await accept(); + await gu.acceptAccessRequest(); // Get the drop for M2 mappings. const mappingsForM2 = () => driver.find(pickerDrop('M2')); @@ -421,7 +387,7 @@ describe('CustomWidgetsConfig', function () { }) ); - await accept(); + await gu.acceptAccessRequest(); // Get the drop for M2 mappings. const mappingsForM2 = () => driver.find(pickerDrop('M2')); @@ -461,7 +427,7 @@ describe('CustomWidgetsConfig', function () { // Select widget that has single column configuration. await clickOption(COLUMN_WIDGET); await widget.waitForFrame(); - await accept(); + await gu.acceptAccessRequest(); // Visible columns section should be hidden. assert.isFalse(await driver.find('.test-vfc-visible-fields-select-all').isPresent()); // Record event should be fired. @@ -496,7 +462,7 @@ describe('CustomWidgetsConfig', function () { requiredAccess: 'read table', }) ); - await accept(); + await gu.acceptAccessRequest(); const empty = {M1: null, M2: null, M3: null, M4: null}; await widget.waitForFrame(); assert.isNull(await widget.onRecordsMappings()); @@ -562,7 +528,7 @@ describe('CustomWidgetsConfig', function () { await toggleWidgetMenu(); await clickOption(COLUMN_WIDGET); - await accept(); + await gu.acceptAccessRequest(); // Make sure columns are there to pick. @@ -584,7 +550,7 @@ describe('CustomWidgetsConfig', function () { assert.isTrue(await driver.find('.test-vfc-visible-fields-select-all').isPresent()); assert.isFalse(await driver.find('.test-config-widget-label-for-Column').isPresent()); - await selectAccess(AccessLevel.read_table); + await gu.changeWidgetAccess(AccessLevel.read_table); // Widget should receive full records. assert.deepEqual(await widget.onRecords(), [ {id: 1, A: 'A'}, @@ -594,7 +560,7 @@ describe('CustomWidgetsConfig', function () { // Now go back to the widget with mappings. await toggleWidgetMenu(); await clickOption(COLUMN_WIDGET); - await accept(); + await gu.acceptAccessRequest(); assert.equal(await driver.find(pickerDrop('Column')).getText(), 'Pick a column'); assert.isFalse(await driver.find('.test-vfc-visible-fields-select-all').isPresent()); assert.isTrue(await driver.find('.test-config-widget-label-for-Column').isPresent()); @@ -614,7 +580,7 @@ describe('CustomWidgetsConfig', function () { requiredAccess: 'read table', }) ); - await accept(); + await gu.acceptAccessRequest(); const empty = {M1: [], M2: []}; await widget.waitForFrame(); // Add some columns, numeric B and any C. @@ -691,7 +657,7 @@ describe('CustomWidgetsConfig', function () { requiredAccess: 'read table', }) ); - await accept(); + await gu.acceptAccessRequest(); await widget.waitForFrame(); // Add B=Date, C=DateTime, D=Numeric await gu.sendActions([ @@ -753,7 +719,7 @@ describe('CustomWidgetsConfig', function () { requiredAccess: 'read table', }) ); - await accept(); + await gu.acceptAccessRequest(); await widget.waitForFrame(); await gu.sendActions([ ['AddVisibleColumn', 'Table1', 'Any', {type: 'Any'}], @@ -801,7 +767,7 @@ describe('CustomWidgetsConfig', function () { await gu.sendActions([ ['AddVisibleColumn', 'Table1', 'Choice', {type: 'Choice', widgetOptions: JSON.stringify(widgetOptions)}] ]); - await accept(); + await gu.acceptAccessRequest(); await widget.waitForFrame(); await gu.selectSectionByTitle('Widget'); @@ -837,7 +803,7 @@ describe('CustomWidgetsConfig', function () { requiredAccess: 'read table', }) ); - await accept(); + await gu.acceptAccessRequest(); await widget.waitForFrame(); // Add some columns, to remove later await gu.selectSectionByTitle('Table'); @@ -918,7 +884,7 @@ describe('CustomWidgetsConfig', function () { requiredAccess: 'read table', }) ); - await accept(); + await gu.acceptAccessRequest(); await widget.waitForFrame(); assert.deepEqual(await widget.onRecordsMappings(), null); assert.deepEqual(await widget.onRecords(), [ @@ -1059,7 +1025,7 @@ describe('CustomWidgetsConfig', function () { } }); it(`should get null options`, async () => { - await selectAccess(access); + await gu.changeWidgetAccess(access); await widget.waitForFrame(); assert.equal(await widget.onOptions(), null); assert.equal(await widget.access(), access); @@ -1067,7 +1033,7 @@ describe('CustomWidgetsConfig', function () { }); it(`should save config options and inform about it the main widget`, async () => { - await selectAccess(access); + await gu.changeWidgetAccess(access); await widget.waitForFrame(); // Save config and check if normal widget received new configuration const config = {key: 1} as const; @@ -1084,7 +1050,7 @@ describe('CustomWidgetsConfig', function () { }); it(`should save and read options`, async () => { - await selectAccess(access); + await gu.changeWidgetAccess(access); await widget.waitForFrame(); // Make sure get options returns null. assert.equal(await widget.getOptions(), null); @@ -1096,7 +1062,7 @@ describe('CustomWidgetsConfig', function () { }); it(`should save and read options by keys`, async () => { - await selectAccess(access); + await gu.changeWidgetAccess(access); await widget.waitForFrame(); // Should support key operations const set = async (key: string, value: any) => { @@ -1117,7 +1083,7 @@ describe('CustomWidgetsConfig', function () { }); it(`should call configure method`, async () => { - await selectAccess(access); + await gu.changeWidgetAccess(access); await widget.waitForFrame(); // Make sure configure wasn't called yet. assert.isFalse(await widget.wasConfigureCalled()); @@ -1158,26 +1124,26 @@ describe('CustomWidgetsConfig', function () { // Select widget without request await toggleWidgetMenu(); await clickOption(NORMAL_WIDGET); - assert.isFalse(await hasPrompt()); - assert.equal(await givenAccess(), AccessLevel.none); + assert.isFalse(await gu.hasAccessPrompt()); + assert.equal(await gu.widgetAccess(), AccessLevel.none); assert.equal(await widget.access(), AccessLevel.none); // Select widget that requests read access. await toggleWidgetMenu(); await clickOption(READ_WIDGET); - assert.isTrue(await hasPrompt()); - assert.equal(await givenAccess(), AccessLevel.none); + assert.isTrue(await gu.hasAccessPrompt()); + assert.equal(await gu.widgetAccess(), AccessLevel.none); assert.equal(await widget.access(), AccessLevel.none); - await accept(); - assert.equal(await givenAccess(), AccessLevel.read_table); + await gu.acceptAccessRequest(); + assert.equal(await gu.widgetAccess(), AccessLevel.read_table); assert.equal(await widget.access(), AccessLevel.read_table); // Select widget that requests full access. await toggleWidgetMenu(); await clickOption(FULL_WIDGET); - assert.isTrue(await hasPrompt()); - assert.equal(await givenAccess(), AccessLevel.none); + assert.isTrue(await gu.hasAccessPrompt()); + assert.equal(await gu.widgetAccess(), AccessLevel.none); assert.equal(await widget.access(), AccessLevel.none); - await accept(); - assert.equal(await givenAccess(), AccessLevel.full); + await gu.acceptAccessRequest(); + assert.equal(await gu.widgetAccess(), AccessLevel.full); assert.equal(await widget.access(), AccessLevel.full); await gu.undo(5); }); diff --git a/test/nbrowser/LinkingBidirectional.ts b/test/nbrowser/LinkingBidirectional.ts new file mode 100644 index 00000000..7064f7da --- /dev/null +++ b/test/nbrowser/LinkingBidirectional.ts @@ -0,0 +1,280 @@ +import * as gu from 'test/nbrowser/gristUtils'; +import {setupTestSuite} from 'test/nbrowser/testUtils'; +import {assert, driver, Key} from 'mocha-webdriver'; + +describe('LinkingBidirectional', function() { + this.timeout('60s'); + + const cleanup = setupTestSuite({team: true}); + let session: gu.Session; + + afterEach(() => gu.checkForErrors()); + + before(async function() { + session = await gu.session().login(); + await session.tempDoc(cleanup, 'Class Enrollment.grist'); + }); + + it('should update cursor when sections cursor-linked together', async function() { + // Setup new page with "Classes1" + await gu.addNewPage('Table', 'Classes', {}); + await gu.renameActiveSection('Classes1'); + + // Set its cursor to row 2 + await gu.getCell('Semester', 2, 'Classes1').click(); + + // Add Classes2 & 3 + await gu.addNewSection('Table', 'Classes', { selectBy: 'Classes1' }); + await gu.renameActiveSection('Classes2'); + await gu.addNewSection('Table', 'Classes', { selectBy: 'Classes2' }); + await gu.renameActiveSection('Classes3'); + + // Make sure that linking put them all on row 2 + assert.deepEqual(await getCursorAndArrow('Classes1'), {arrow: null, cursor: {rowNum: 2, col: 0}}); + assert.deepEqual(await getCursorAndArrow('Classes2'), {arrow: 2, cursor: {rowNum: 2, col: 0}}); + assert.deepEqual(await getCursorAndArrow('Classes3'), {arrow: 2, cursor: {rowNum: 2, col: 0}}); + }); + + it('should propagate cursor-links downstream', async function() { + // Cursor link from upstream should drive a given section, but we can still move the downstream cursor + // To other positions. The downstream linking should use whichever of its ancestors was most recently clicked + + // Set cursors: Sec1 on row 1, Sec2 on row 2 + await gu.getCell('Semester', 1, 'Classes1').click(); + await gu.getCell('Semester', 2, 'Classes2').click(); + + // Sec1 should be on row 1. Sec2 should be on 2 even though link is telling it to be on 1. Sec3 should be on 2 + assert.deepEqual(await getCursorAndArrow('Classes1'), {arrow: null, cursor: {rowNum: 1, col: 0}}); + assert.deepEqual(await getCursorAndArrow('Classes2'), {arrow: 1, cursor: {rowNum: 2, col: 0}}); + assert.deepEqual(await getCursorAndArrow('Classes3'), {arrow: 2, cursor: {rowNum: 2, col: 0}}); + + // click on Sec3 row 3 + await gu.getCell('Semester', 3, 'Classes3').click(); + + // Cursors should be on 1, 2, 3, with arrows lagging 1 step behind + assert.deepEqual(await getCursorAndArrow('Classes1'), {arrow: null, cursor: {rowNum: 1, col: 0}}); + assert.deepEqual(await getCursorAndArrow('Classes2'), {arrow: 1, cursor: {rowNum: 2, col: 0}}); + assert.deepEqual(await getCursorAndArrow('Classes3'), {arrow: 2, cursor: {rowNum: 3, col: 0}}); + + // When clicking on oldest ancestor, it should now take priority over all downstream cursors again + // Click on S1 row 4 + await gu.getCell('Semester', 4, 'Classes1').click(); + // assert that all are on 4 + assert.deepEqual(await getCursorAndArrow('Classes1'), {arrow: null, cursor: {rowNum: 4, col: 0}}); + assert.deepEqual(await getCursorAndArrow('Classes2'), {arrow: 4, cursor: {rowNum: 4, col: 0}}); + assert.deepEqual(await getCursorAndArrow('Classes3'), {arrow: 4, cursor: {rowNum: 4, col: 0}}); + }); + + it('should work correctly when linked into a cycle', async function() { + // Link them all into a cycle. + await gu.selectBy('Classes3'); + + // Now, any section should drive all the other sections + + // Click on row 1 in Sec1 + await gu.getCell('Semester', 1, 'Classes1').click(); + assert.deepEqual(await getCursorAndArrow('Classes1'), {arrow: 1, cursor: {rowNum: 1, col: 0}}); + assert.deepEqual(await getCursorAndArrow('Classes2'), {arrow: 1, cursor: {rowNum: 1, col: 0}}); + assert.deepEqual(await getCursorAndArrow('Classes3'), {arrow: 1, cursor: {rowNum: 1, col: 0}}); + + // Click on row 2 in Sec2 + await gu.getCell('Semester', 2, 'Classes2').click(); + assert.deepEqual(await getCursorAndArrow('Classes1'), {arrow: 2, cursor: {rowNum: 2, col: 0}}); + assert.deepEqual(await getCursorAndArrow('Classes2'), {arrow: 2, cursor: {rowNum: 2, col: 0}}); + assert.deepEqual(await getCursorAndArrow('Classes3'), {arrow: 2, cursor: {rowNum: 2, col: 0}}); + + // Click on row 3 in Sec3 + await gu.getCell('Semester', 3, 'Classes3').click(); + assert.deepEqual(await getCursorAndArrow('Classes1'), {arrow: 3, cursor: {rowNum: 3, col: 0}}); + assert.deepEqual(await getCursorAndArrow('Classes2'), {arrow: 3, cursor: {rowNum: 3, col: 0}}); + assert.deepEqual(await getCursorAndArrow('Classes3'), {arrow: 3, cursor: {rowNum: 3, col: 0}}); + }); + + it('should keep position after refresh', async function() { + // Nothing should change after a refresh + await gu.reloadDoc(); + + assert.deepEqual(await getCursorAndArrow('Classes1'), {arrow: 3, cursor: {rowNum: 3, col: 0}}); + assert.deepEqual(await getCursorAndArrow('Classes2'), {arrow: 3, cursor: {rowNum: 3, col: 0}}); + assert.deepEqual(await getCursorAndArrow('Classes3'), {arrow: 3, cursor: {rowNum: 3, col: 0}}); + }); + + it('should update linking when filters change', async function() { + // Let's filter out the current row ("Spring 2019") from Classes2 + await gu.selectSectionByTitle('Classes2'); + await gu.sortAndFilter() + .then(x => x.addColumn()) + .then(x => x.clickColumn('Semester')) + .then(x => x.close()) + .then(x => x.click()); + + // Open the pinned filter, and filter out the date. + await gu.openPinnedFilter('Semester') + .then(x => x.toggleValue('Spring 2019')) + .then(x => x.close()); + + // Classes2 got its cursor moved when we filtered out its current row, so all sections should now + // have moved to row 1 + assert.deepEqual(await getCursorAndArrow('Classes1'), {arrow: 1, cursor: {rowNum: 1, col: 0}}); + assert.deepEqual(await getCursorAndArrow('Classes2'), {arrow: 1, cursor: {rowNum: 1, col: 0}}); + assert.deepEqual(await getCursorAndArrow('Classes3'), {arrow: 1, cursor: {rowNum: 1, col: 0}}); + }); + + it('should propagate cursor linking even through a filtered-out section', async function() { + // Row 3 (from Classes1 and Classes3) is no longer present in Classes2 + // Make sure it can still get the value through the link from Classes1 -> 2 -> 3 + + // Click on row 3 in Sec1 + await gu.getCell('Semester', 3, 'Classes1').click(); + + // Classes1 and 3 should be on row3 + // Classes2 should still be on row 1 (from the last test case), and should have no arrow (since it's desynced) + assert.deepEqual(await getCursorAndArrow('Classes1'), {arrow: 3, cursor: {rowNum: 3, col: 0}}); + assert.deepEqual(await getCursorAndArrow('Classes2'), {arrow: null, cursor: {rowNum: 1, col: 0}}); + assert.deepEqual(await getCursorAndArrow('Classes3'), {arrow: 3, cursor: {rowNum: 3, col: 0}}); + }); + + it('should keep position after refresh when section is filtered out', async function() { + // Save the filter in Classes2 + await gu.selectSectionByTitle('Classes2'); + await gu.sortAndFilter() + .then(x => x.save()); + + // Go to Classes1, and refresh (its curser will be restored). + await gu.selectSectionByTitle('Classes1'); + await gu.reloadDoc(); + + // Nothing should change after a refresh + assert.deepEqual(await getCursorAndArrow('Classes1'), {arrow: 3, cursor: {rowNum: 3, col: 0}}); + assert.deepEqual(await getCursorAndArrow('Classes2'), {arrow: null, cursor: {rowNum: 1, col: 0}}); + assert.deepEqual(await getCursorAndArrow('Classes3'), {arrow: 3, cursor: {rowNum: 3, col: 0}}); + }); + + it('should navigate to correct cells and sections', async function() { + await gu.getCell('Semester', 3, 'Classes1').click(); + const anchor = await gu.getAnchor(); + await gu.wipeToasts(); + + await gu.onNewTab(async () => { + await driver.get(anchor); + await gu.waitForDocToLoad(); + + assert.deepEqual(await getCursorAndArrow('Classes1'), {arrow: 3, cursor: {rowNum: 3, col: 0}}); + assert.deepEqual(await getCursorAndArrow('Classes2'), {arrow: null, cursor: {rowNum: 1, col: 0}}); + assert.deepEqual(await getCursorAndArrow('Classes3'), {arrow: 3, cursor: {rowNum: 3, col: 0}}); + }); + + await gu.getCell('Semester', 3, 'Classes3').click(); + const anchor2 = await gu.getAnchor(); + await gu.wipeToasts(); + + await gu.onNewTab(async () => { + await driver.get(anchor2); + await gu.waitForDocToLoad(); + + assert.deepEqual(await getCursorAndArrow('Classes1'), {arrow: 3, cursor: {rowNum: 3, col: 0}}); + assert.deepEqual(await getCursorAndArrow('Classes2'), {arrow: null, cursor: {rowNum: 1, col: 0}}); + assert.deepEqual(await getCursorAndArrow('Classes3'), {arrow: 3, cursor: {rowNum: 3, col: 0}}); + }); + }); + + it("should update cursor-linking when clicking on a cell, even if the position doesn't change", async function() { + // Classes2 should still be on row 1, but the other 2 sections have their cursors on row 3 + // If we click on Classes2, even if the cursor position doesn't change, we should still record + // Classes2 as now being the most recently-clicked section and have it drive the linking of the other 2 sections + // (i.e. they should change to match it) + + // Click on row 1 in Classes2 (it's got filtered-out-rows, and so is desynced from the other 2) + await gu.getCell('Semester', 1, 'Classes2').click(); + + // All sections should now jump to join it + assert.deepEqual(await getCursorAndArrow('Classes1'), {arrow: 1, cursor: {rowNum: 1, col: 0}}); + assert.deepEqual(await getCursorAndArrow('Classes2'), {arrow: 1, cursor: {rowNum: 1, col: 0}}); + assert.deepEqual(await getCursorAndArrow('Classes3'), {arrow: 1, cursor: {rowNum: 1, col: 0}}); + }); + + it('should update cursor linking correctly when the selected row is deleted', async function() { + // When deleting a row, the current section moves down 1 row (preserving the cursorpos), but other + // sections with the same table jump to row 1 + + // If we're cursor-linked, make sure that doesn't cause a desync. + + // Click on row 2 in Classes1 and delete it + await gu.getCell('Semester', 2, 'Classes1').click(); + await gu.removeRow(2); + + // Classes1 and Classes3 should both have moved to the next row (previously rowNum3, but now is rowNum 2) + // Classes2 has this row filtered out, so it should have jumped to row1 instead (desynced from the others) + assert.deepEqual(await getCursorAndArrow('Classes1'), {arrow: 2, cursor: {rowNum: 2, col: 0}}); + assert.deepEqual(await getCursorAndArrow('Classes2'), {arrow: null, cursor: {rowNum: 1, col: 0}}); + assert.deepEqual(await getCursorAndArrow('Classes3'), {arrow: 2, cursor: {rowNum: 2, col: 0}}); + + // Undo the row-deletion + await gu.undo(); + + // The first 2 sections will still be on row2, but verify that Classes2 also joins them + assert.deepEqual(await getCursorAndArrow('Classes1'), {arrow: 2, cursor: {rowNum: 2, col: 0}}); + assert.deepEqual(await getCursorAndArrow('Classes2'), {arrow: 2, cursor: {rowNum: 2, col: 0}}); + assert.deepEqual(await getCursorAndArrow('Classes3'), {arrow: 2, cursor: {rowNum: 2, col: 0}}); + }); + + it('should support custom filters', async function() { + // Add a new custom section with a widget. + await gu.addNewPage('Table', 'Classes', {}); + + // Rename this section as Data. + await gu.renameActiveSection('Data'); + + // Add new custom section with a widget. + await gu.addNewSection('Custom', 'Classes', { selectBy: 'Data' }); + + // Rename this section as Custom. + await gu.renameActiveSection('Custom'); + + // Make sure it can be used as a filter. + await gu.changeWidgetAccess('read table'); + // Tell grist that we can be linked to. + await gu.customCode(grist => grist.sectionApi.configure({allowSelectBy: true})); + + // Now link them together. + await gu.selectSectionByTitle('Data'); + await gu.selectBy('Custom'); + + // And now lets filter some rows in the Data by using custom widget API. + await gu.selectSectionByTitle('Custom'); + await gu.customCode(grist => grist.setSelectedRows([1, 2, 3, 4]).catch(() => {})); + + // Check for the error. It was easy to create an infinite loop with the call above, and checking + // for errors here was catching it. + await gu.checkForErrors(); + + // This link was not valid, so custom section wasn't filtered, but it managed to filter Data. + // But in the process, the configuration was cleared. + + // Switch section, so that UI is refreshed. + await gu.selectSectionByTitle('Data'); + await gu.selectSectionByTitle('Custom'); + + // Make sure we can't select Data by Custom anymore. + await gu.openSelectByForSection('Custom'); + + // Make sure it is empty. + assert.deepEqual(await driver.findAll('.test-select-menu .test-select-row', e => e.getText()), ['Select Widget']); + await gu.sendKeys(Key.ESCAPE); + }); +}); + +interface CursorArrowInfo { + arrow: null | number; + cursor: {rowNum: number, col: number}; +} + +// NOTE: this differs from getCursorSelectorInfo in LinkingSelector.ts +// That function only gives the cursorPos if that section is actively selected (false otherwise), whereas this +// function returns it always +async function getCursorAndArrow(sectionName: string): Promise { + return { + arrow: await gu.getArrowPosition(sectionName), + cursor: await gu.getCursorPosition(sectionName), + }; +} diff --git a/test/nbrowser/LinkingSelector.ts b/test/nbrowser/LinkingSelector.ts index 4251def1..b2aa8b68 100644 --- a/test/nbrowser/LinkingSelector.ts +++ b/test/nbrowser/LinkingSelector.ts @@ -207,9 +207,8 @@ interface CursorSelectorInfo { async function getCursorSelectorInfo(section: WebElement): Promise { const hasCursor = await section.find('.active_cursor').isPresent(); - const hasSelector = await section.find('.link_selector_row').isPresent(); return { - linkSelector: hasSelector && Number(await section.find('.link_selector_row .gridview_data_row_num').getText()), + linkSelector: await gu.getSelectorPosition(section).then(r => r ?? false), cursor: hasCursor && await gu.getCursorPosition(section), }; } diff --git a/test/nbrowser/gristUtils.ts b/test/nbrowser/gristUtils.ts index f97e59c3..40c84aaf 100644 --- a/test/nbrowser/gristUtils.ts +++ b/test/nbrowser/gristUtils.ts @@ -7,11 +7,13 @@ import * as fse from 'fs-extra'; import escapeRegExp = require('lodash/escapeRegExp'); import noop = require('lodash/noop'); import startCase = require('lodash/startCase'); -import { assert, driver as driverOrig, error, Key, WebElement, WebElementPromise } from 'mocha-webdriver'; +import { assert, By, driver as driverOrig, error, Key, WebElement, WebElementPromise } from 'mocha-webdriver'; import { stackWrapFunc, stackWrapOwnMethods, WebDriver } from 'mocha-webdriver'; import * as path from 'path'; +import * as PluginApi from 'app/plugin/grist-plugin-api'; import {csvDecodeRow} from 'app/common/csvFormat'; +import { AccessLevel } from 'app/common/CustomWidget'; import { decodeUrl } from 'app/common/gristUrls'; import { FullUser, UserProfile } from 'app/common/LoginSessionAPI'; import { resetOrg } from 'app/common/resetOrg'; @@ -567,6 +569,31 @@ export async function rightClick(cell: WebElement) { await driver.withActions((actions) => actions.contextClick(cell)); } +/** + * Gets the selector position in the Grid view section (or null if not present). + * Selector is the black box around the row number. + */ +export async function getSelectorPosition(section?: WebElement|string) { + if (typeof section === 'string') { section = await getSection(section); } + section = section ?? await driver.findWait('.active_section', 4000); + const hasSelector = await section.find('.link_selector_row').isPresent(); + return hasSelector && Number(await section.find('.link_selector_row .gridview_data_row_num').getText()); +} + +/** + * Gets the arrow position in the Grid view section (or null if no arrow is present). + */ +export async function getArrowPosition(section?: WebElement|string) { + if (typeof section === 'string') { section = await getSection(section); } + section = section ?? await driver.findWait('.active_section', 4000); + const arrow = section.find('.gridview_data_row_info.linked_dst'); + const hasArrow = await arrow.isPresent(); + return hasArrow ? Number( + await arrow.findElement(By.xpath("./..")) //Get its parent + .getText() + ) : null; +} + /** * Returns {rowNum, col} object representing the position of the cursor in the active view * section. RowNum is a 1-based number as in the row headers, and col is a 0-based index for @@ -859,18 +886,18 @@ export async function importUrlDialog(url: string): Promise { await driver.switchTo().defaultContent(); } - /** - * Executed passed function in the context of given iframe, and then switching back to original context - * - */ - export async function doInIframe(iframe: WebElement, func: () => Promise) { - try { - await driver.switchTo().frame(iframe); - return await func(); - } finally { - await driver.switchTo().defaultContent(); - } +/** + * Executed passed function in the context of given iframe, and then switching back to original context + * + */ +export async function doInIframe(iframe: WebElement, func: () => Promise) { + try { + await driver.switchTo().frame(iframe); + return await func(); + } finally { + await driver.switchTo().defaultContent(); } +} /** * Starts or resets the collections of UserActions. This should be followed some time later by @@ -3178,6 +3205,75 @@ export async function setGristTheme(options: { } } +/** + * Executes custom code inside active custom widget. + */ +export async function customCode(fn: (grist: typeof PluginApi) => void) { + const section = await driver.findWait('.active_section iframe', 4000); + return await doInIframe(section, async () => { + return await driver.executeScript(`(${fn})(grist)`); + }); +} + +/** + * Gets or sets widget access level (doesn't deal with prompts). + */ +export async function widgetAccess(level?: AccessLevel) { + const text = { + [AccessLevel.none]: 'No document access', + [AccessLevel.read_table]: 'Read selected table', + [AccessLevel.full]: 'Full document access', + }; + if (!level) { + const currentAccess = await driver.find('.test-config-widget-access .test-select-open').getText(); + return Object.entries(text).find(e => e[1] === currentAccess)![0]; + } else { + await driver.find('.test-config-widget-access .test-select-open').click(); + await driver.findContent('.test-select-menu li', text[level]).click(); + await waitForServer(); + } +} + +/** + * Checks if access prompt is visible. + */ +export async function hasAccessPrompt() { + return await driver.find('.test-config-widget-access-accept').isPresent(); +} + +/** + * Accepts new access level. + */ +export async function acceptAccessRequest() { + await driver.find('.test-config-widget-access-accept').click(); +} + +/** + * Rejects new access level. + */ +export async function rejectAccessRequest() { + await driver.find('.test-config-widget-access-reject').click(); +} + +/** + * Sets widget access level (deals with requests). + */ +export async function changeWidgetAccess(access: 'read table'|'full'|'none') { + await openWidgetPanel(); + + // if the current access is ok do nothing + if ((await widgetAccess()) === access) { + // unless we need to confirm it + if (await hasAccessPrompt()) { + await acceptAccessRequest(); + } + } else { + // else switch access level + await widgetAccess(access as AccessLevel); + } +} + + } // end of namespace gristUtils stackWrapOwnMethods(gristUtils);