mirror of
https://github.com/gristlabs/grist-core.git
synced 2024-10-27 20:44:07 +00:00
(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:
parent
a8e0f96813
commit
083a20417e
@ -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() :
|
||||
|
@ -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}] -> ` +
|
||||
|
@ -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
|
||||
|
@ -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);
|
||||
});
|
||||
|
280
test/nbrowser/LinkingBidirectional.ts
Normal file
280
test/nbrowser/LinkingBidirectional.ts
Normal 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),
|
||||
};
|
||||
}
|
@ -207,9 +207,8 @@ interface CursorSelectorInfo {
|
||||
|
||||
async function getCursorSelectorInfo(section: WebElement): Promise<CursorSelectorInfo> {
|
||||
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),
|
||||
};
|
||||
}
|
||||
|
@ -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<void> {
|
||||
await driver.switchTo().defaultContent();
|
||||
}
|
||||
|
||||
/**
|
||||
/**
|
||||
* 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 {
|
||||
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);
|
||||
|
Loading…
Reference in New Issue
Block a user