(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
This commit is contained in:
Jarosław Sadziński 2023-10-10 14:54:50 +02:00
parent a8e0f96813
commit 083a20417e
7 changed files with 439 additions and 80 deletions

View File

@ -286,7 +286,7 @@ export class LinkingState extends Disposable {
// Get previous linkingstate's info, if applicable (2 or more hops back) // Get previous linkingstate's info, if applicable (2 or more hops back)
const prevLink = this._srcSection.linkingState?.(); const prevLink = this._srcSection.linkingState?.();
const prevLinkHasCursor = prevLink && const prevLinkHasCursor = prevLink?.incomingCursorPos &&
(prevLink.linkTypeDescription() === "Cursor:Same-Table" || (prevLink.linkTypeDescription() === "Cursor:Same-Table" ||
prevLink.linkTypeDescription() === "Cursor:Reference"); prevLink.linkTypeDescription() === "Cursor:Reference");
const [prevLinkedPos, prevLinkedVersion] = prevLinkHasCursor ? prevLink.incomingCursorPos() : const [prevLinkedPos, prevLinkedVersion] = prevLinkHasCursor ? prevLink.incomingCursorPos() :

View File

@ -156,6 +156,11 @@ function isValidLink(source: LinkNode, target: LinkNode) {
return false; 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 // 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 // - lets walk back along the cyclic portion of the ancestor chain and verify that each link in that chain is
// a cursor-link // a cursor-link
@ -426,6 +431,18 @@ export class LinkConfig {
assert(srcTableId, "srcCol not a valid reference"); assert(srcTableId, "srcCol not a valid reference");
} }
assert(srcTableId === tgtTableId, "mismatched tableIds"); 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) { } catch (e) {
throw new Error(`LinkConfig invalid: ` + throw new Error(`LinkConfig invalid: ` +
`${this.srcSection.getRowId()}:${this.srcCol?.getRowId()}[${srcTableId}] -> ` + `${this.srcSection.getRowId()}:${this.srcCol?.getRowId()}[${srcTableId}] -> ` +

View File

@ -77,11 +77,12 @@ export const allowSelectBy = viewApi.allowSelectBy;
*/ */
export const setSelectedRows = viewApi.setSelectedRows; export const setSelectedRows = viewApi.setSelectedRows;
/**
* Sets the cursor position in a linked section.
*/
export const setCursorPos = viewApi.setCursorPos; export const setCursorPos = viewApi.setCursorPos;
/** /**
* Fetches data backing the widget as for [[GristView.fetchSelectedTable]], * Fetches data backing the widget as for [[GristView.fetchSelectedTable]],
* but decoding data by default, replacing e.g. ['D', timestamp] with * but decoding data by default, replacing e.g. ['D', timestamp] with

View File

@ -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()); .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. // When refreshing, we need to make sure widget repository is enabled once again.
async function refresh() { async function refresh() {
await driver.navigate().refresh(); await driver.navigate().refresh();
@ -88,19 +67,6 @@ async function refresh() {
await gu.selectSectionByTitle('Widget'); 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 // Checks if active section has option in the menu to open configuration
async function hasSectionOption() { async function hasSectionOption() {
const menu = await gu.openSectionMenu('viewLayout'); const menu = await gu.openSectionMenu('viewLayout');
@ -158,7 +124,7 @@ async function checkSortMenu(state: 'empty' | 'modified' | 'customized' | 'empty
} }
describe('CustomWidgetsConfig', function () { describe('CustomWidgetsConfig', function () {
this.timeout(60000); this.timeout('60s');
const cleanup = setupTestSuite(); const cleanup = setupTestSuite();
let mainSession: gu.Session; let mainSession: gu.Session;
gu.bigScreen(); gu.bigScreen();
@ -371,7 +337,7 @@ describe('CustomWidgetsConfig', function () {
}) })
); );
await accept(); await gu.acceptAccessRequest();
// Get the drop for M2 mappings. // Get the drop for M2 mappings.
const mappingsForM2 = () => driver.find(pickerDrop('M2')); const mappingsForM2 = () => driver.find(pickerDrop('M2'));
@ -421,7 +387,7 @@ describe('CustomWidgetsConfig', function () {
}) })
); );
await accept(); await gu.acceptAccessRequest();
// Get the drop for M2 mappings. // Get the drop for M2 mappings.
const mappingsForM2 = () => driver.find(pickerDrop('M2')); const mappingsForM2 = () => driver.find(pickerDrop('M2'));
@ -461,7 +427,7 @@ describe('CustomWidgetsConfig', function () {
// Select widget that has single column configuration. // Select widget that has single column configuration.
await clickOption(COLUMN_WIDGET); await clickOption(COLUMN_WIDGET);
await widget.waitForFrame(); await widget.waitForFrame();
await accept(); await gu.acceptAccessRequest();
// Visible columns section should be hidden. // Visible columns section should be hidden.
assert.isFalse(await driver.find('.test-vfc-visible-fields-select-all').isPresent()); assert.isFalse(await driver.find('.test-vfc-visible-fields-select-all').isPresent());
// Record event should be fired. // Record event should be fired.
@ -496,7 +462,7 @@ describe('CustomWidgetsConfig', function () {
requiredAccess: 'read table', requiredAccess: 'read table',
}) })
); );
await accept(); await gu.acceptAccessRequest();
const empty = {M1: null, M2: null, M3: null, M4: null}; const empty = {M1: null, M2: null, M3: null, M4: null};
await widget.waitForFrame(); await widget.waitForFrame();
assert.isNull(await widget.onRecordsMappings()); assert.isNull(await widget.onRecordsMappings());
@ -562,7 +528,7 @@ describe('CustomWidgetsConfig', function () {
await toggleWidgetMenu(); await toggleWidgetMenu();
await clickOption(COLUMN_WIDGET); await clickOption(COLUMN_WIDGET);
await accept(); await gu.acceptAccessRequest();
// Make sure columns are there to pick. // 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.isTrue(await driver.find('.test-vfc-visible-fields-select-all').isPresent());
assert.isFalse(await driver.find('.test-config-widget-label-for-Column').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. // Widget should receive full records.
assert.deepEqual(await widget.onRecords(), [ assert.deepEqual(await widget.onRecords(), [
{id: 1, A: 'A'}, {id: 1, A: 'A'},
@ -594,7 +560,7 @@ describe('CustomWidgetsConfig', function () {
// Now go back to the widget with mappings. // Now go back to the widget with mappings.
await toggleWidgetMenu(); await toggleWidgetMenu();
await clickOption(COLUMN_WIDGET); await clickOption(COLUMN_WIDGET);
await accept(); await gu.acceptAccessRequest();
assert.equal(await driver.find(pickerDrop('Column')).getText(), 'Pick a column'); assert.equal(await driver.find(pickerDrop('Column')).getText(), 'Pick a column');
assert.isFalse(await driver.find('.test-vfc-visible-fields-select-all').isPresent()); assert.isFalse(await driver.find('.test-vfc-visible-fields-select-all').isPresent());
assert.isTrue(await driver.find('.test-config-widget-label-for-Column').isPresent()); assert.isTrue(await driver.find('.test-config-widget-label-for-Column').isPresent());
@ -614,7 +580,7 @@ describe('CustomWidgetsConfig', function () {
requiredAccess: 'read table', requiredAccess: 'read table',
}) })
); );
await accept(); await gu.acceptAccessRequest();
const empty = {M1: [], M2: []}; const empty = {M1: [], M2: []};
await widget.waitForFrame(); await widget.waitForFrame();
// Add some columns, numeric B and any C. // Add some columns, numeric B and any C.
@ -691,7 +657,7 @@ describe('CustomWidgetsConfig', function () {
requiredAccess: 'read table', requiredAccess: 'read table',
}) })
); );
await accept(); await gu.acceptAccessRequest();
await widget.waitForFrame(); await widget.waitForFrame();
// Add B=Date, C=DateTime, D=Numeric // Add B=Date, C=DateTime, D=Numeric
await gu.sendActions([ await gu.sendActions([
@ -753,7 +719,7 @@ describe('CustomWidgetsConfig', function () {
requiredAccess: 'read table', requiredAccess: 'read table',
}) })
); );
await accept(); await gu.acceptAccessRequest();
await widget.waitForFrame(); await widget.waitForFrame();
await gu.sendActions([ await gu.sendActions([
['AddVisibleColumn', 'Table1', 'Any', {type: 'Any'}], ['AddVisibleColumn', 'Table1', 'Any', {type: 'Any'}],
@ -801,7 +767,7 @@ describe('CustomWidgetsConfig', function () {
await gu.sendActions([ await gu.sendActions([
['AddVisibleColumn', 'Table1', 'Choice', {type: 'Choice', widgetOptions: JSON.stringify(widgetOptions)}] ['AddVisibleColumn', 'Table1', 'Choice', {type: 'Choice', widgetOptions: JSON.stringify(widgetOptions)}]
]); ]);
await accept(); await gu.acceptAccessRequest();
await widget.waitForFrame(); await widget.waitForFrame();
await gu.selectSectionByTitle('Widget'); await gu.selectSectionByTitle('Widget');
@ -837,7 +803,7 @@ describe('CustomWidgetsConfig', function () {
requiredAccess: 'read table', requiredAccess: 'read table',
}) })
); );
await accept(); await gu.acceptAccessRequest();
await widget.waitForFrame(); await widget.waitForFrame();
// Add some columns, to remove later // Add some columns, to remove later
await gu.selectSectionByTitle('Table'); await gu.selectSectionByTitle('Table');
@ -918,7 +884,7 @@ describe('CustomWidgetsConfig', function () {
requiredAccess: 'read table', requiredAccess: 'read table',
}) })
); );
await accept(); await gu.acceptAccessRequest();
await widget.waitForFrame(); await widget.waitForFrame();
assert.deepEqual(await widget.onRecordsMappings(), null); assert.deepEqual(await widget.onRecordsMappings(), null);
assert.deepEqual(await widget.onRecords(), [ assert.deepEqual(await widget.onRecords(), [
@ -1059,7 +1025,7 @@ describe('CustomWidgetsConfig', function () {
} }
}); });
it(`should get null options`, async () => { it(`should get null options`, async () => {
await selectAccess(access); await gu.changeWidgetAccess(access);
await widget.waitForFrame(); await widget.waitForFrame();
assert.equal(await widget.onOptions(), null); assert.equal(await widget.onOptions(), null);
assert.equal(await widget.access(), access); 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 () => { it(`should save config options and inform about it the main widget`, async () => {
await selectAccess(access); await gu.changeWidgetAccess(access);
await widget.waitForFrame(); await widget.waitForFrame();
// Save config and check if normal widget received new configuration // Save config and check if normal widget received new configuration
const config = {key: 1} as const; const config = {key: 1} as const;
@ -1084,7 +1050,7 @@ describe('CustomWidgetsConfig', function () {
}); });
it(`should save and read options`, async () => { it(`should save and read options`, async () => {
await selectAccess(access); await gu.changeWidgetAccess(access);
await widget.waitForFrame(); await widget.waitForFrame();
// Make sure get options returns null. // Make sure get options returns null.
assert.equal(await widget.getOptions(), null); assert.equal(await widget.getOptions(), null);
@ -1096,7 +1062,7 @@ describe('CustomWidgetsConfig', function () {
}); });
it(`should save and read options by keys`, async () => { it(`should save and read options by keys`, async () => {
await selectAccess(access); await gu.changeWidgetAccess(access);
await widget.waitForFrame(); await widget.waitForFrame();
// Should support key operations // Should support key operations
const set = async (key: string, value: any) => { const set = async (key: string, value: any) => {
@ -1117,7 +1083,7 @@ describe('CustomWidgetsConfig', function () {
}); });
it(`should call configure method`, async () => { it(`should call configure method`, async () => {
await selectAccess(access); await gu.changeWidgetAccess(access);
await widget.waitForFrame(); await widget.waitForFrame();
// Make sure configure wasn't called yet. // Make sure configure wasn't called yet.
assert.isFalse(await widget.wasConfigureCalled()); assert.isFalse(await widget.wasConfigureCalled());
@ -1158,26 +1124,26 @@ describe('CustomWidgetsConfig', function () {
// Select widget without request // Select widget without request
await toggleWidgetMenu(); await toggleWidgetMenu();
await clickOption(NORMAL_WIDGET); await clickOption(NORMAL_WIDGET);
assert.isFalse(await hasPrompt()); assert.isFalse(await gu.hasAccessPrompt());
assert.equal(await givenAccess(), AccessLevel.none); assert.equal(await gu.widgetAccess(), AccessLevel.none);
assert.equal(await widget.access(), AccessLevel.none); assert.equal(await widget.access(), AccessLevel.none);
// Select widget that requests read access. // Select widget that requests read access.
await toggleWidgetMenu(); await toggleWidgetMenu();
await clickOption(READ_WIDGET); await clickOption(READ_WIDGET);
assert.isTrue(await hasPrompt()); assert.isTrue(await gu.hasAccessPrompt());
assert.equal(await givenAccess(), AccessLevel.none); assert.equal(await gu.widgetAccess(), AccessLevel.none);
assert.equal(await widget.access(), AccessLevel.none); assert.equal(await widget.access(), AccessLevel.none);
await accept(); await gu.acceptAccessRequest();
assert.equal(await givenAccess(), AccessLevel.read_table); assert.equal(await gu.widgetAccess(), AccessLevel.read_table);
assert.equal(await widget.access(), AccessLevel.read_table); assert.equal(await widget.access(), AccessLevel.read_table);
// Select widget that requests full access. // Select widget that requests full access.
await toggleWidgetMenu(); await toggleWidgetMenu();
await clickOption(FULL_WIDGET); await clickOption(FULL_WIDGET);
assert.isTrue(await hasPrompt()); assert.isTrue(await gu.hasAccessPrompt());
assert.equal(await givenAccess(), AccessLevel.none); assert.equal(await gu.widgetAccess(), AccessLevel.none);
assert.equal(await widget.access(), AccessLevel.none); assert.equal(await widget.access(), AccessLevel.none);
await accept(); await gu.acceptAccessRequest();
assert.equal(await givenAccess(), AccessLevel.full); assert.equal(await gu.widgetAccess(), AccessLevel.full);
assert.equal(await widget.access(), AccessLevel.full); assert.equal(await widget.access(), AccessLevel.full);
await gu.undo(5); await gu.undo(5);
}); });

View File

@ -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<CursorArrowInfo> {
return {
arrow: await gu.getArrowPosition(sectionName),
cursor: await gu.getCursorPosition(sectionName),
};
}

View File

@ -207,9 +207,8 @@ interface CursorSelectorInfo {
async function getCursorSelectorInfo(section: WebElement): Promise<CursorSelectorInfo> { async function getCursorSelectorInfo(section: WebElement): Promise<CursorSelectorInfo> {
const hasCursor = await section.find('.active_cursor').isPresent(); const hasCursor = await section.find('.active_cursor').isPresent();
const hasSelector = await section.find('.link_selector_row').isPresent();
return { 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), cursor: hasCursor && await gu.getCursorPosition(section),
}; };
} }

View File

@ -7,11 +7,13 @@ import * as fse from 'fs-extra';
import escapeRegExp = require('lodash/escapeRegExp'); import escapeRegExp = require('lodash/escapeRegExp');
import noop = require('lodash/noop'); import noop = require('lodash/noop');
import startCase = require('lodash/startCase'); 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 { stackWrapFunc, stackWrapOwnMethods, WebDriver } from 'mocha-webdriver';
import * as path from 'path'; import * as path from 'path';
import * as PluginApi from 'app/plugin/grist-plugin-api';
import {csvDecodeRow} from 'app/common/csvFormat'; import {csvDecodeRow} from 'app/common/csvFormat';
import { AccessLevel } from 'app/common/CustomWidget';
import { decodeUrl } from 'app/common/gristUrls'; import { decodeUrl } from 'app/common/gristUrls';
import { FullUser, UserProfile } from 'app/common/LoginSessionAPI'; import { FullUser, UserProfile } from 'app/common/LoginSessionAPI';
import { resetOrg } from 'app/common/resetOrg'; import { resetOrg } from 'app/common/resetOrg';
@ -567,6 +569,31 @@ export async function rightClick(cell: WebElement) {
await driver.withActions((actions) => actions.contextClick(cell)); 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 * 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 * 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<void> {
await driver.switchTo().defaultContent(); await driver.switchTo().defaultContent();
} }
/** /**
* Executed passed function in the context of given iframe, and then switching back to original context * Executed passed function in the context of given iframe, and then switching back to original context
* *
*/ */
export async function doInIframe<T>(iframe: WebElement, func: () => Promise<T>) { export async function doInIframe<T>(iframe: WebElement, func: () => Promise<T>) {
try { try {
await driver.switchTo().frame(iframe); await driver.switchTo().frame(iframe);
return await func(); return await func();
} finally { } finally {
await driver.switchTo().defaultContent(); await driver.switchTo().defaultContent();
} }
} }
/** /**
* Starts or resets the collections of UserActions. This should be followed some time later by * 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 } // end of namespace gristUtils
stackWrapOwnMethods(gristUtils); stackWrapOwnMethods(gristUtils);