diff --git a/.yarnrc b/.yarnrc new file mode 100644 index 00000000..7f4776cd --- /dev/null +++ b/.yarnrc @@ -0,0 +1,5 @@ +# THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY. +# yarn lockfile v1 + + +yarn-offline-mirror false diff --git a/app/client/components/BaseView.js b/app/client/components/BaseView.js index ea277cfc..0c9c6f55 100644 --- a/app/client/components/BaseView.js +++ b/app/client/components/BaseView.js @@ -140,8 +140,12 @@ function BaseView(gristDoc, viewSectionModel, options) { return linking && linking.cursorPos ? linking.cursorPos() : null; }).extend({deferred: true})); - // Update the cursor whenever linkedRowId() changes. - this.autoDispose(this.linkedRowId.subscribe(rowId => this.setCursorPos({rowId: rowId || 'new'}))); + // Update the cursor whenever linkedRowId() changes (but only if we have any linking). + this.autoDispose(this.linkedRowId.subscribe(rowId => { + if (this.viewSection.linkingState.peek()) { + this.setCursorPos({rowId: rowId || 'new'}); + } + })); // Indicated whether editing the section should be disabled given the current linking state. this.disableEditing = this.autoDispose(ko.computed(() => { @@ -693,7 +697,11 @@ BaseView.prototype.onRowResize = function(rowModels) { * Called when user selects a different row which drives the link-filtering of this section. */ BaseView.prototype.onLinkFilterChange = function(rowId) { - this.setCursorPos({rowIndex: 0}); + // If this section is linked, go to the first row as the row previously selected may no longer + // be visible. + if (this.viewSection.linkingState.peek()) { + this.setCursorPos({rowIndex: 0}); + } }; /** diff --git a/test/nbrowser/ReferenceColumns.ts b/test/nbrowser/ReferenceColumns.ts index c3c83a75..205d247f 100644 --- a/test/nbrowser/ReferenceColumns.ts +++ b/test/nbrowser/ReferenceColumns.ts @@ -11,7 +11,7 @@ describe('ReferenceColumns', function() { describe('rendering', function() { before(async function() { session = await gu.session().teamSite.login(); - await session.tempDoc(cleanup, 'Favorite_Films_With_Linked_Ref.grist'); + await session.tempDoc(cleanup, 'Favorite_Films.grist'); await gu.toggleSidePanel('right'); await driver.find('.test-config-data').click(); @@ -147,23 +147,6 @@ describe('ReferenceColumns', function() { ] ); }); - - it('should have linked card for friends', async () => { - // Open the All page. - await driver.findContentWait('.test-treeview-itemHeader', /Linked Friends/, 2000).click(); - await gu.waitForDocToLoad(); - - await driver.findContentWait('.field_clip', /Mary/, 2000).click(); - await gu.waitForServer(); - await driver.findContentWait('.g_record_detail_label', /Title/, 2000).click(); - assert.equal(await gu.getActiveCell().getText(), 'Alien'); - - await driver.findContentWait('.field_clip', /Jarek/, 2000).click(); - await gu.waitForServer(); - await driver.findContentWait('.g_record_detail_label', /Title/, 2000).click(); - assert.equal(await gu.getActiveCell().getText(), ''); - - }); }); describe('autocomplete', function() { diff --git a/test/nbrowser/RightPanel.ts b/test/nbrowser/RightPanel.ts new file mode 100644 index 00000000..1a735dd5 --- /dev/null +++ b/test/nbrowser/RightPanel.ts @@ -0,0 +1,295 @@ +import {assert, driver, Key} from 'mocha-webdriver'; +import * as gu from 'test/nbrowser/gristUtils'; +import {server, setupTestSuite} from 'test/nbrowser/testUtils'; + +describe('RightPanel', function() { + this.timeout(20000); + setupTestSuite(); + + afterEach(() => gu.checkForErrors()); + + it('should open/close panel, and reflect the current section', async function() { + // Open a document with multiple views and multiple sections. + await server.simulateLogin("Chimpy", "chimpy@getgrist.com", 'nasa'); + const doc = await gu.importFixturesDoc('chimpy', 'nasa', 'Horizon', 'World.grist', false); + await driver.get(`${server.getHost()}/o/nasa/doc/${doc.id}`); + + // Check current view and section name. + assert.equal(await gu.getActiveSectionTitle(6000), 'CITY'); + assert.equal(await driver.find('.test-bc-page').getAttribute('value'), 'City'); + + // Open side pane, and check it shows the right section. + await gu.toggleSidePanel('right'); + await driver.find('.test-config-widget').click(); + assert.equal(await gu.isSidePanelOpen('right'), true); + + assert.equal(await driver.find('.test-right-widget-title').value(), 'CITY'); + + // Check that the tab's name reflects suitable text + assert.equal(await driver.find('.test-right-tab-pagewidget').getText(), 'Table'); + assert.equal(await driver.find('.test-right-tab-field').getText(), 'Column'); + + // Switch to Field tab, check that it shows the right field. + await driver.find('.test-right-tab-field').click(); + assert.equal(await driver.find('.test-field-label').value(), "Name"); + + // Check to a different field, check a different field is shown. + await driver.sendKeys(Key.RIGHT); + assert.equal(await driver.find('.test-field-label').value(), "Country"); + + // Click to a different section, check a different field is shown. + await gu.getSection('CITY Card List').find('.detail_row_num').click(); + assert.equal(await driver.find('.test-field-label').value(), "Name"); + + // Check that the tab's name reflects suitable text + assert.equal(await driver.find('.test-right-tab-pagewidget').getText(), 'Card List'); + assert.equal(await driver.find('.test-right-tab-field').getText(), 'Field'); + + // Close panel, check it's hidden. + await gu.toggleSidePanel('right'); + assert.equal(await gu.isSidePanelOpen('right'), false); + assert.equal(await driver.find('.config_item').isPresent(), false); + + // Reopen panel, check it's still right. + await gu.toggleSidePanel('right'); + assert.equal(await driver.find('.test-field-label').value(), "Name"); + + // Switch to the section tab, check the new section is reflected. + await driver.find('.test-right-tab-pagewidget').click(); + assert.equal(await driver.find('.test-right-widget-title').value(), 'CITY Card List'); + + // Switch to a different view, check the new section is reflected. + await driver.findContent('.test-treeview-itemHeader', /Country/).click(); + assert.equal(await driver.find('.test-right-widget-title').value(), 'COUNTRY'); + + // Switch to field tab; check the new field is reflected. + await driver.find('.test-right-tab-field').click(); + assert.equal(await driver.find('.test-field-label').value(), "Code"); + }); + + it('should not cause errors when switching pages with Field tab open', async () => { + // There was an error ("this.calcSize is not a function") switching between pages when the + // active section changes type and Field tab is open, triggered by an unnecessary rebuilding + // of FieldConfigTab. + + // Check that the active field tab is called "Column" (since the active section is "Table") + // and is open to column "Code". + assert.equal(await driver.find('.test-right-tab-field').getText(), 'Column'); + assert.equal(await driver.find('.test-field-label').value(), "Code"); + + // Switch to the "City" page. Check that the tab is now called "Field" (since the active section is of + // type "CardList"), and open to the field "Name". + await driver.findContent('.test-treeview-itemHeader', /City/).click(); + assert.equal(await driver.find('.test-right-tab-field').getText(), 'Field'); + assert.equal(await driver.find('.test-field-label').value(), "Name"); + + // Check that this did not cause client-side errors. + await gu.checkForErrors(); + + // Now switch back, and check for errors again. + await driver.findContent('.test-treeview-itemHeader', /Country/).click(); + await gu.checkForErrors(); + }); + + it('should show tools when requested', async function() { + // Select specific view/section/field. Close side-pane. + await gu.getCell({col: "Name", rowNum: 3}).click(); + assert.equal(await driver.find('.test-field-label').value(), "Name"); + await gu.toggleSidePanel('right'); + assert.equal(await gu.isSidePanelOpen('right'), false); + + // Click Activity Log. + assert.equal(await driver.find('.action_log').isPresent(), false); + await driver.find('.test-tools-log').click(); + await gu.waitToPass(() => // Click might not work while panel is sliding out to open. + driver.findContentWait('.test-doc-history-tabs .test-select-button', 'Activity', 500).click()); + + // Check that panel is shown, and correct. + assert.equal(await gu.isSidePanelOpen('right'), true); + assert.equal(await driver.find('.test-right-tab-field').isPresent(), false); + assert.equal(await driver.find('.action_log').isDisplayed(), true); + + // Click "x", Check expected section config shown. + await driver.find('.test-right-tool-close').click(); + assert.equal(await driver.find('.test-right-tab-field').getText(), 'Column'); + assert.equal(await driver.find('.test-field-label').value(), "Name"); + + // TODO: polish data validation and then uncomment + /* + // Click Validations. Check it's shown and correct. + await driver.find('.test-tools-validate').click(); + assert.equal(await driver.findContent('.config_item', /Validations/).isDisplayed(), true); + + // Close panel. Switch to another view. + await gu.toggleSidePanel('right'); + assert.equal(await gu.isSidePanelOpen('right'), false); + assert.equal(await driver.findContent('.config_item', /Validations/).isPresent(), false); + await driver.findContent('.test-treeview-itemHeader', /Country/).click(); + + // Open panel. Check Validations are still shown. + await gu.toggleSidePanel('right'); + assert.equal(await driver.findContent('.config_item', /Validations/).isDisplayed(), true); + await driver.find('.test-right-tool-close').click(); + */ + }); + + it('should keep panel state on reload', async function() { + // Check the panel is currently open and showing Field options. + assert.equal(await gu.isSidePanelOpen('right'), true); + assert.equal(await driver.find('.test-field-label').value(), "Name"); + + // Reload the page, and click the same cell as before. + await driver.navigate().refresh(); + assert.equal(await gu.getActiveSectionTitle(3000), 'COUNTRY'); + await gu.waitForServer(); + await gu.getCell({col: "Name", rowNum: 3}).click(); + + // Check the panel is still open and showing the same Field options. + assert.equal(await gu.isSidePanelOpen('right'), true); + assert.equal(await driver.find('.test-field-label').value(), "Name"); + }); + + it('\'SELECTOR FOR\' should work correctly', async function() { + // open the Data tab + await driver.find('.test-right-tab-pagewidget').click(); + await driver.find('.test-config-data').click(); + + // open a page that has linked section + await driver.findContent('.test-treeview-itemHeader', /Country/).click(); + + // wait for data to load + assert(await gu.getActiveSectionTitle(3000)); + await gu.waitForServer(); + + // select a view section that does not select other section + await gu.getSection('COUNTRY Card List').click(); + + // check that selector-for is not present + assert.equal(await driver.find('.test-selector-for').isPresent(), false); + + // select a view section that does select other section + await gu.getSection('COUNTRY').click(); + + // check that selector-of is present and that all selected section are listed + assert.equal(await driver.find('.test-selector-for').isPresent(), true); + assert.deepEqual(await driver.findAll('.test-selector-for-entry', (e) => e.getText()), [ + "CITY", + "COUNTRYLANGUAGE", + "COUNTRY Card List", + ]); + }); + + it('\'Edit Data Selection\' should allow to change link', async () => { + // select COUNTRY DETAIL + await gu.getSection('CITY').click(); + + // open page widget picker + await driver.find('.test-pwc-editDataSelection').click(); + + // remove link + await driver.find('.test-wselect-selectby').doClick(); + await driver.findContent('.test-wselect-selectby option', /Select Widget/).doClick(); + + // click save + await driver.find('.test-wselect-addBtn').doClick(); + await gu.waitForServer(); + + // Go to the first record. + await gu.sendKeys(Key.chord(await gu.modKey(), Key.UP)); + + // Check that link was removed, by going to Aruba. + await gu.getSection('COUNTRY').click(); + await gu.getCell(0, 1).click(); + // City section should stay where it was + assert.equal(await gu.getCell(0, 1, 'CITY').getText(), 'Kabul'); + + // re-set the link + await gu.getSection('CITY').click(); + await driver.find('.test-pwc-editDataSelection').click(); + await driver.find('.test-wselect-selectby').click(); + await driver.findContent('.test-wselect-selectby option', /Country$/).click(); + await driver.find('.test-wselect-addBtn').doClick(); + await gu.waitForServer(); + + // check link is set + await gu.getSection('COUNTRY').click(); + await gu.getCell(0, 1).click(); + assert.equal(await gu.getCell(0, 1, 'CITY').getText(), 'Oranjestad'); + }); + + it('should not cause errors when switching pages with Table tab open', async () => { + // There were an error doing eigher one of 1) switching to `Code View`, or 2) removing the + // active page, when the Table tab was open, because: both caused the activeView to be set to an + // empty model causes some computed property of the ViewSectionRec to fail. This is what this + // test is aiming at catching. + + await gu.toggleSidePanel('right', 'open'); + await driver.find('.test-config-widget').click(); + + assert.deepEqual(await gu.getPageNames(), ['City', 'Country', 'CountryLanguage']); + + // adds a new page + await gu.addNewPage(/Table/, /City/); + + assert.deepEqual(await gu.getPageNames(), ['City', 'Country', 'CountryLanguage', 'New page']); + + // remove that page + await gu.openPageMenu(/New page/); + await driver.find('.grist-floating-menu .test-docpage-remove').click(); + await gu.waitForServer(); + + // check pages were removed and nothing break + assert.deepEqual(await gu.getPageNames(), ['City', 'Country', 'CountryLanguage']); + await gu.checkForErrors(); + + // now switch to `Code View` + await driver.find('.test-tools-code').click(); + assert.equal(await driver.findWait('.g-code-viewer', 1000).isPresent(), true); + + // check nothing broke + await gu.checkForErrors(); + + // switch back to City + await gu.getPageItem(/City/).click(); + }); + + it('should not cause errors when editing summary table with `Change Widget` button', async () => { + // Changing the grouped by columns using the `Change Widget` used to throw `TypeError: Cannot + // read property `toUpperCase` of undefined`. The goal of this test is to prevent future + // regression. + + // Create a summary table of City groupbed by country + await gu.addNewPage(/Table/, /City/, {summarize: [/Country/]}); + + // open right panel Widget + await gu.toggleSidePanel('right', 'open'); + await driver.find('.test-right-tab-pagewidget').click(); + await driver.find('.test-config-widget').click(); + + // click `Change Widget` + await driver.findContent('.test-right-panel button', /Change Widget/).click(); + + // remove column `Country` and save + await gu.selectWidget(/Table/, /City/, {summarize: []}); + + // check there were no error + await gu.checkForErrors(); + }); + + it('should not raise errors when opening with table\'s `Widget Options`', async function() { + // Open right panel and select 'Column' + await gu.toggleSidePanel('right', 'open'); + await driver.find('.test-right-tab-field').click(); + + // Close the right panel + await gu.toggleSidePanel('right', 'close'); + + // Open the right panel using the table's `Widget option` + await gu.openSectionMenu('viewLayout'); + await driver.find('.test-widget-options').click(); + + await gu.checkForErrors(); + }); + + +}); diff --git a/test/nbrowser/RightPanelSelectBy.ts b/test/nbrowser/RightPanelSelectBy.ts new file mode 100644 index 00000000..a496e5d2 --- /dev/null +++ b/test/nbrowser/RightPanelSelectBy.ts @@ -0,0 +1,201 @@ +import { addToRepl, assert, driver} from 'mocha-webdriver'; +import * as gu from 'test/nbrowser/gristUtils'; +import {server, setupTestSuite} from 'test/nbrowser/testUtils'; + +describe('RightPanelSelectBy', function() { + this.timeout(20000); + setupTestSuite(); + addToRepl('gu2', gu); + + async function setup() { + await server.simulateLogin("Chimpy", "chimpy@getgrist.com", 'nasa'); + const doc = await gu.importFixturesDoc('chimpy', 'nasa', 'Horizon', 'Favorite_Films_With_Linked_Ref.grist', false); + await driver.get(`${server.getHost()}/o/nasa/doc/${doc.id}`); + await gu.waitForDocToLoad(); + + await gu.toggleSidePanel('right', 'open'); + await driver.find('.test-config-data').click(); + } + + it('should allow linking section with same table', async function() { + await setup(); + + // open page `All` + await driver.findContentWait('.test-treeview-itemHeader', /All/, 2000).click(); + await gu.waitForDocToLoad(); + + await openSelectByForSection('PERFORMANCES DETAIL'); + + // the dollar in /...record$/ makes sure we match against the table main node and not a ref + // columns such as '...record.Film' + assert.equal(await driver.findContent('.test-select-row', /Performances record$/).isPresent(), true); + }); + + it('should not allow linking same section', async function() { + assert.equal(await driver.findContent('.test-select-row', /Performances detail/i).isPresent(), false); + }); + + it('should allow linking to/from a ref column', async function() { + // Performance record.Film links both from a ref column and to a ref column + assert.equal(await driver.findContent('.test-select-row', /Performances record.*Film/i).isPresent(), true); + }); + + it('should successfully link on select', async function() { + // select a link + await driver.findContent('.test-select-row', /Performances record$/).click(); + await gu.waitForServer(); + + // Check that selections in 1st section are mirrored by the 2nd section. + await gu.getSection('PERFORMANCES RECORD').click(); + await gu.getCell(0, 3).click(); + assert.equal(await driver.find('.g_record_detail_value').getText(), 'Don Rickles'); + }); + + it('should allow to remove link', async function() { + await openSelectByForSection('PERFORMANCES DETAIL'); + await driver.findContent('.test-select-row', /Select Widget/).click(); + await gu.waitForServer(); + + // Check that selections in 1st section are NOT mirrored by the 2nd section. + await gu.getSection('PERFORMANCES RECORD').click(); + await gu.getCell(0, 1).click(); + assert.equal(await driver.find('.g_record_detail_value').getText(), 'Don Rickles'); + + // undo, link is expected to be set for next test + await gu.undo(); + }); + + + it('should disallow creating cycles', async function() { + await openSelectByForSection('PERFORMANCES RECORD'); + assert.equal(await driver.findContent('.test-select-row', /Performances detail/).isPresent(), false); + }); + + + it('should not allow selecting from a chart or custom sections', async function() { + // open the 'Films' page + await driver.findContent('.test-treeview-itemHeader', /Films/).click(); + await gu.waitForDocToLoad(); + + // Adds a chart widget + await gu.addNewSection(/Chart/, /Films/); + + // open `SELECT BY` + await openSelectByForSection('FILMS'); + + // check that there is a chart and we cannot link from it + assert.equal(await gu.getSection('FILMS CHART').isPresent(), true); + assert.equal(await driver.findContent('.test-select-row', /Films chart/).isPresent(), false); + + // undo + await gu.undo(); + }); + + it('should update filter-linking tied to reference when value changes', async function() { + // Add a filter-linked section (of Performances) tied to a Ref column (FRIENDS.Favorite_Film). + await gu.getPageItem('Friends').click(); + await gu.waitForServer(); + await gu.addNewSection(/Table/, /Performances/); + await openSelectByForSection('Performances'); + assert.equal(await driver.findContent('.test-select-row', /FRIENDS.*Favorite Film/).isPresent(), true); + await driver.findContent('.test-select-row', /FRIENDS.*Favorite Film/).click(); + await gu.waitForServer(); + + // Select a row in FRIENDS. + const cell = await gu.getCell({section: 'Friends', col: 'Favorite Film', rowNum: 6}); + assert.equal(await cell.getText(), 'Alien'); + await cell.click(); + + // Check that the linked table reflects the selected row. + assert.deepEqual(await gu.getVisibleGridCells( + {section: 'Performances', cols: ['Actor', 'Film'], rowNums: [1, 2]}), [ + 'Sigourney Weaver', 'Alien', + '', '', + ]); + + // Change a value in FRIENDS.Favorite_Film column. + await gu.sendKeys('Toy'); + await driver.findContent('.test-ref-editor-item', /Toy Story/).click(); + await gu.waitForServer(); + + // Check that the linked table of Performances got updated. + assert.deepEqual(await gu.getVisibleGridCells( + {section: 'Performances', cols: ['Actor', 'Film'], rowNums: [1, 2, 3, 4]}), [ + 'Tom Hanks', 'Toy Story', + 'Tim Allen', 'Toy Story', + 'Don Rickles', 'Toy Story', + '', '' + ]); + + await gu.undo(2); + }); + + it('should update cursor-linking tied to reference when value changes', async function() { + // Add a cursor-linked card widget (of Films) tied to a Ref column (FRIENDS.Favorite_Film). + await gu.getPageItem('Friends').click(); + await gu.waitForServer(); + await gu.addNewSection(/Card/, /Films/); + await openSelectByForSection('Films Card'); + assert.equal(await driver.findContent('.test-select-row', /FRIENDS.*Favorite Film/).isPresent(), true); + await driver.findContent('.test-select-row', /FRIENDS.*Favorite Film/).click(); + await gu.waitForServer(); + + // Select a row in FRIENDS. + const cell = await gu.getCell({section: 'Friends', col: 'Favorite Film', rowNum: 6}); + assert.equal(await cell.getText(), 'Alien'); + await cell.click(); + + // Check that the linked card reflects the selected row. + assert.equal(await driver.find('.g_record_detail_value').getText(), 'Alien'); + assert.equal(await driver.findContent('.g_record_detail_value', /19/).getText(), 'May 25th, 1979'); + + // Change the value in FRIENDS.Favorite_Film column. + await gu.sendKeys('Toy'); + await driver.findContent('.test-ref-editor-item', /Toy Story/).click(); + await gu.waitForServer(); + + // Check that the linked card of Films got updated. + assert.equal(await driver.find('.g_record_detail_value').getText(), 'Toy Story'); + assert.equal(await driver.findContent('.g_record_detail_value', /19/).getText(), 'November 22nd, 1995'); + + // Select the 'new' row in FRIENDS. + const newCell = await gu.getCell({section: 'Friends', col: 'Favorite Film', rowNum: 7}); + assert.equal(await newCell.getText(), ''); + await newCell.click(); + + // Card should have also moved to the 'new' record + const cardFields = await driver.findAll('.g_record_detail_value'); + for (const cardField of cardFields) { + assert.equal(await cardField.getText(), ''); + } + + await gu.undo(2); + }); + + + it('should have linked card for friends', async () => { + // Open the All page. + await driver.findContentWait('.test-treeview-itemHeader', /Linked Friends/, 2000).click(); + await gu.waitForDocToLoad(); + + await driver.findContentWait('.field_clip', /Mary/, 2000).click(); + await gu.waitForServer(); + await driver.findContentWait('.g_record_detail_label', /Title/, 2000).click(); + assert.equal(await gu.getActiveCell().getText(), 'Alien'); + + await driver.findContentWait('.field_clip', /Jarek/, 2000).click(); + await gu.waitForServer(); + await driver.findContentWait('.g_record_detail_label', /Title/, 2000).click(); + assert.equal(await gu.getActiveCell().getText(), ''); + }); + + xit('should list options following the order of the section in the view layout', async function() { + // TODO + }); + +}); + +export async function openSelectByForSection(section: string) { + await gu.getSection(section).click(); + await driver.find('.test-right-select-by').click(); +} diff --git a/test/nbrowser/SelectBy.ts b/test/nbrowser/SelectBy.ts new file mode 100644 index 00000000..d385d10c --- /dev/null +++ b/test/nbrowser/SelectBy.ts @@ -0,0 +1,215 @@ +import mapValues = require('lodash/mapValues'); +import { assert, driver, Key } from 'mocha-webdriver'; +import * as gu from 'test/nbrowser/gristUtils'; +import { server, setupTestSuite } from 'test/nbrowser/testUtils'; + +describe("SelectBy", function() { + this.timeout(20000); + setupTestSuite(); + let doc: any; + + function formatOption(main: string, srcColumn?: string, tgtColumn?: string) { + let ret = main; + ret += srcColumn ? ' \u2022 ' + srcColumn : ''; + ret += tgtColumn ? ' \u2192 ' + tgtColumn : ''; + return ret; + } + + it("should offer correct options", async () => { + await server.simulateLogin("Chimpy", "chimpy@getgrist.com", "nasa"); + doc = await gu.importFixturesDoc('chimpy', 'nasa', 'Horizon', 'selectBy.grist', false); + + // check tables + const api = gu.createHomeApi('Chimpy', 'nasa'); + assert.deepInclude(await api.getTable(doc.id, '_grist_Tables'), { + id: [1, 2, 3, 4], + tableId: ['Table1', 'Table2', 'Table3', 'Table3_summary_A'], + summarySourceTable: [0, 0, 0, 3], + }); + + // check visible columns (no manualSort columns) + const allColumns = await api.getTable(doc.id, '_grist_Tables_column'); + const visibleColumns = mapValues(allColumns, (vals) => vals.filter((v, i) => allColumns.colId[i] !== 'manualSort')); + assert.deepInclude(visibleColumns, { + id: [2, 3, 6, 10, 12, 13, 14, 15, 16], + colId: ['table2_ref', 'table3_ref', 'table3_ref', 'A', 'A', 'table3_ref_2', 'A', 'group', 'count'], + parentId: [1, 1, 2, 3, 1, 1, 4, 4, 4], + type: ['Ref:Table2', 'Ref:Table3', 'Ref:Table3', 'Numeric', 'Text', 'Ref:Table3', 'Numeric', + 'RefList:Table3', 'Int'], + label: ['table2_ref', 'table3_ref', 'table3_ref', 'A', 'A', 'table3_ref_2', 'A', 'group', 'count'], + }); + + // open document + await driver.get(`${server.getHost()}/o/nasa/doc/${doc.id}`); + + // create a new page with table1 and table2 as 2 tables + await gu.addNewPage(/Table/, /Table1/); + await gu.addNewSection(/Table/, /Table2/); + + // beginning adding a new widget to page + await driver.findWait('.test-dp-add-new', 2000).doClick(); + await driver.findWait('.test-dp-add-widget-to-page', 2000).doClick(); + + // select /Table/ /Table1/ and check options of `SELECT BY` drop down + await driver.findContent('.test-wselect-table', /Table1/).doClick(); + await driver.findContent('.test-wselect-type', /Table/).doClick(); + await driver.find('.test-wselect-selectby').doClick(); + assert.deepEqual( + // let's ignore the first option which is an internal added by grainjs + await driver.findAll('.test-wselect-selectby option:not(:first-of-type)', (e) => e.getText()), [ + // note: this is a very contrived example to test various possible links. Real world use + // cases are expected to be simpler, resulting in simpler list of options that are easier to + // navigate for the user than this one (in particular the `->` separator might rarely show + // up). + formatOption('Select Widget'), + formatOption("TABLE1"), + formatOption("TABLE1", 'table2_ref'), + formatOption('TABLE1', 'table3_ref', 'table3_ref'), + formatOption('TABLE1', 'table3_ref', 'table3_ref_2'), + formatOption('TABLE1', 'table3_ref_2', 'table3_ref'), + formatOption('TABLE1', 'table3_ref_2', 'table3_ref_2'), + formatOption('TABLE2'), + formatOption('TABLE2', 'table3_ref', 'table3_ref'), + formatOption('TABLE2', 'table3_ref', 'table3_ref_2'), + ] + ); + + // select Table2 and check options of `SELECT BY` drop down + await driver.findContent('.test-wselect-table', /Table2/).doClick(); + await driver.find('.test-wselect-selectby').doClick(); + assert.deepEqual( + await driver.findAll('.test-wselect-selectby option:not(:first-of-type)', (e) => e.getText()), [ + formatOption('Select Widget'), + formatOption('TABLE1', 'table2_ref'), + formatOption('TABLE1', 'table3_ref'), + formatOption('TABLE1', 'table3_ref_2'), + formatOption('TABLE2'), + formatOption('TABLE2', 'table3_ref') + ] + ); + + // Selecting "New Table" should show no options. + await driver.findContent('.test-wselect-table', /New Table/).doClick(); + assert.equal(await driver.find('.test-wselect-selectby').isPresent(), false); + assert.lengthOf(await driver.findAll('.test-wselect-selectby option'), 0); + // Selecting a regular table should show options again. + await driver.findContent('.test-wselect-table', /Table2/).doClick(); + assert.equal(await driver.find('.test-wselect-selectby').isPresent(), true); + assert.lengthOf(await driver.findAll('.test-wselect-selectby option'), 7); + + + // Create a page with with charts and custom widget and then check that no linking is offered + await gu.addNewPage(/Chart/, /Table1/); + await gu.addNewSection(/Custom/, /Table2/); + + // open add widget to page + await driver.findWait('.test-dp-add-new', 2000).doClick(); + await driver.findWait('.test-dp-add-widget-to-page', 2000).doClick(); + + // select /Table/ /Table1/ and check no options are available + await driver.findContent('.test-wselect-table', /Table1/).doClick(); + await driver.findContent('.test-wselect-type', /Table/).doClick(); + assert.equal(await driver.find('.test-wselect-selectby').isPresent(), false); + + // select Table2 and check no options are available + await driver.findContent('.test-wselect-table', /Table2/).doClick(); + assert.equal(await driver.find('.test-wselect-selectby').isPresent(), false); + }); + + it('should handle summary table correctly', async () => { + + // Notice that table of view 4 is a summary of Table3 + const api = gu.createHomeApi('Chimpy', 'nasa'); + assert.deepInclude((await api.getTable(doc.id, '_grist_Tables')), { + id: [1, 2, 3, 4], + tableId: ['Table1', 'Table2', 'Table3', 'Table3_summary_A'], + summarySourceTable: [0, 0, 0, 3], + }); + + // open Summary page + await driver.get(`${server.getHost()}/o/nasa/doc/${doc.id}/p/4`); + + // add new widget to page + await driver.findWait('.test-dp-add-new', 2000).doClick(); + await driver.findWait('.test-dp-add-widget-to-page', 2000).doClick(); + + // select Table3 and summarize + await driver.findContent('.test-wselect-table', /Table3/).find('.test-wselect-pivot').doClick(); + + // check selectBy options + assert.deepEqual( + await driver.findAll('.test-wselect-selectby option:not(:first-of-type)', (e) => e.getText()), + [], + ); + await driver.sendKeys(Key.ESCAPE); + }); + + it('should show nav buttons for card view linked to its summary', async function() { + // Still on the page with summary of Table3, add a new Card widget linked to the summary + await gu.addNewSection(/Card$/, /Table3/, {selectBy: /TABLE3.*by A/}); + + // Check that we have a card view. + await gu.getCell({section: 'TABLE3 [by A]', rowNum: 1, col: 'A'}).click(); + const section = await gu.getSection('TABLE3 Card'); + assert.equal(await gu.getDetailCell({section, rowNum: 1, col: 'A'}).getText(), '1'); + + // Check there are nav buttons in the card view. + assert.equal(await section.find('.btn.detail-left').isPresent(), true); + assert.equal(await section.find('.btn.detail-right').isPresent(), true); + assert.equal(await section.find('.grist-single-record__menu__count').getText(), '1 OF 1'); + + // Now add a record to the source table using the card view. + await section.find('.btn.detail-add-btn').click(); + assert.equal(await gu.getDetailCell({section, rowNum: 1, col: 'A'}).getText(), ''); + await gu.getDetailCell({section, rowNum: 1, col: 'A'}).click(); + await gu.sendKeys('1', Key.ENTER); + await gu.waitForServer(); + + // Check that this group now has 2 records. + assert.equal(await section.find('.grist-single-record__menu__count').getText(), '2 OF 2'); + + // There is another group that still has one record. + await gu.getCell({section: 'TABLE3 [by A]', rowNum: 2, col: 'A'}).click(); + assert.equal(await section.find('.grist-single-record__menu__count').getText(), '1 OF 1'); + }); + + it('should save link correctly', async () => { + + // create new page with table2 as a table + await gu.addNewPage(/Table/, /Table2/); + + // begin adding table1 as a table to page + await driver.findWait('.test-dp-add-new', 2000).doClick(); + await driver.findWait('.test-dp-add-widget-to-page', 2000).doClick(); + await driver.findContent('.test-wselect-table', /Table1/).doClick(); + + // select link + await driver.find('.test-wselect-selectby').doClick(); + await driver.findContent('.test-wselect-selectby option', /Table2/i).doClick(); + + // click `add to page` btn + await driver.find('.test-wselect-addBtn').doClick(); + await gu.waitForServer(); + + // check new section added and check content + assert.deepEqual(await gu.getVisibleGridCells(1, [1, 2]), ['a', 'b']); + + // select other row in selector section + await gu.getSection('Table2').doClick(); + await gu.getCell({col: 0, rowNum: 2}).doClick(); + + // check that linked section was filterd + await gu.getSection('Table1').doClick(); + assert.deepEqual(await gu.getVisibleGridCells(1, [1, 2]), ['c', 'd']); + + // check that an single undo remove the section + await gu.undo(); + assert.equal(await gu.getSection('Table1').isPresent(), false); + + // check that a single redo add and link the section + await gu.redo(); + await gu.getSection('Table1').doClick(); + assert.deepEqual(await gu.getVisibleGridCells(1, [1, 2]), ['a', 'b']); + }); + +}); diff --git a/test/nbrowser/SelectByRefList.ts b/test/nbrowser/SelectByRefList.ts new file mode 100644 index 00000000..db3c7f48 --- /dev/null +++ b/test/nbrowser/SelectByRefList.ts @@ -0,0 +1,245 @@ +import * as _ from 'lodash'; +import {addToRepl, assert, driver} from 'mocha-webdriver'; +import * as gu from 'test/nbrowser/gristUtils'; +import {server, setupTestSuite} from 'test/nbrowser/testUtils'; +import {openSelectByForSection} from "./RightPanelSelectBy"; + +describe('SelectByRefList', function() { + this.timeout(60000); + setupTestSuite(); + addToRepl('gu2', gu); + gu.bigScreen(); + + async function setup() { + await server.simulateLogin("Chimpy", "chimpy@getgrist.com", 'nasa'); + const doc = await gu.importFixturesDoc('chimpy', 'nasa', 'Horizon', + 'SelectByRefList.grist', false); + await driver.get(`${server.getHost()}/o/nasa/doc/${doc.id}`); + await gu.waitForDocToLoad(); + + await gu.toggleSidePanel('right'); + await driver.find('.test-config-data').click(); + } + + it('should filter a table selected by ref and reflist columns', async function() { + await setup(); + + /* + The fixture document contains the following tables: + - LINKTARGET is the table we 'select by' another table, filtering it. + It has 3 columns: rownum, ref, and reflist + - REFTARGET is the target of almost all ref/reflist columns in the doc, + especially ref and reflist in LINKTARGET. + It has 3 rows and 1 column, the values are just a, b, and c. + - INDIRECTREF has matching rows referencing the rows in REFTARGET. + - REFLISTS has 2 reflist columns: + - reflist points to REFTARGET, similar to LINKTARGET + - LinkTarget_reflist points to LINKTARGET + + checkSelectingRecords selects each of the 3 records in a table, one at a time, + and checks that LINKTARGET is filtered to the corresponding subarray. + + First we test selecting by the ref column of LINKTARGET (2nd column). + Selecting by REFTARGET or INDIRECTREF should give the same result. + Because the values of REFTARGET are [a,b,c], selecting those rows + gives the rows in LINKTARGET with `a` and then `b` in the 2nd column in the first two subarrays. + LINKTARGET doesn't have any references to the last row of REFTARGET (c) + so the last group is empty. + All these groups include the new record row at the end. + */ + + let sourceData = [ + [ + '1', 'a', 'a', + '2', 'a', 'b', + '', '', '', + ], + [ + '3', 'b', 'a\nb', + '4', 'b', '', + '', '', '', + ], + [ + '', '', '', + ], + ]; + // The last row selected has value `c`, so that's the default value for the ref column + let newRow = ['99', 'c', '']; + await checkSelectingRecords('INDIRECTREF • A → ref', sourceData, newRow); + await checkSelectingRecords('REFTARGET → ref', sourceData, newRow); + + // Now selecting based on the reflist column (3rd column) + // gives groups where that column *contains* `a`, then contains `b`, then + // nothing because again LINKTARGET doesn't have references to `c` + sourceData = [ + [ + '1', 'a', 'a', + '3', 'b', 'a\nb', + '', '', '', + ], + [ + '2', 'a', 'b', + '3', 'b', 'a\nb', + '', '', '', + ], + [ + '', '', '', + ], + ]; + // The last row selected has value `c`, so that's the default value for the reflist column + newRow = ['99', '', 'c']; + await checkSelectingRecords('INDIRECTREF • A → reflist', sourceData, newRow); + await checkSelectingRecords('REFTARGET → reflist', sourceData, newRow); + + // This case is quite simple and direct: LINKTARGET should show the rows listed + // in the REFLISTS.LinkTarget_reflist column. The values there are [1], [2], and [3, 4], + // which you can see in the first column below. + sourceData = [ + [ + '1', 'a', 'a', + '', '', '', + ], + [ + '2', 'a', 'b', + '', '', '', + ], + [ + '3', 'b', 'a\nb', + '4', 'b', '', + '', '', '', + ], + ]; + // LINKTARGET is being filtered by the `id` column + // There's no column to set a default value for that would help + // The newly added row disappears immediately + // TODO should we be appending the new row ID to the reflist in the source table? + newRow = ['', '', '']; + await checkSelectingRecords('REFLISTS • LinkTarget_reflist', sourceData, newRow); + + // Similar to the above but indirect. We connect LINKTARGET.ref and REFLISTS.reflist, + // which both point to REFTARGET. This gives rows where LINKTARGET.ref is contained in REFLISTS.reflist + // (in contrast to LINKTARGET.row_id contained in REFLISTS.LinkTarget_reflist). + // The values of REFLISTS.reflist are [a], [b], and [a, b], + // so the values in the second column must be in there. + sourceData = [ + [ + '1', 'a', 'a', + '2', 'a', 'b', + '', '', '', + ], + [ + '3', 'b', 'a\nb', + '4', 'b', '', + '', '', '', + ], + [ + '1', 'a', 'a', + '2', 'a', 'b', + '3', 'b', 'a\nb', + '4', 'b', '', + '', '', '', + ], + ]; + // The last row selected has value [a,b] in REFLISTS.reflist + // LINKTARGET.ref can only take one reference, so it defaults to the first + newRow = ['99', 'a', '']; + await checkSelectingRecords('REFLISTS • reflist → ref', sourceData, newRow); + + // Taking it one step further, connect LINKTARGET.reflist and REFLISTS.reflist. + // Gives rows where the two reflists *intersect*. + // The values of REFLISTS.reflist are [a], [b], and [a, b], + // so the values in the third column must be in there. + sourceData = [ + [ + '1', 'a', 'a', + '3', 'b', 'a\nb', + '', '', '', + ], + [ + '2', 'a', 'b', + '3', 'b', 'a\nb', + '', '', '', + ], + [ + '1', 'a', 'a', + '2', 'a', 'b', + '3', 'b', 'a\nb', + '', '', '', + ], + ]; + // The last row selected has value [a,b] in REFLISTS.reflist + // LINKTARGET.reflist gets that as a default value + newRow = ['99', '', 'a\nb']; + await checkSelectingRecords('REFLISTS • reflist → reflist', sourceData, newRow); + }); +}); + +/** + * Makes LINKTARGET select by selectBy. + * Asserts that clicking each row in the driving table filters LINKTARGET + * to the corresponding subarray of sourceData. + * Then creates a new row in LINKTARGET and asserts that it has values equal to newRow. + * The values will depend on the link and the last row selected in the driving table. + */ +async function checkSelectingRecords(selectBy: string, sourceData: string[][], newRow: string[]) { + await openSelectByForSection('LINKTARGET'); + await driver.findContent('.test-select-row', new RegExp(selectBy + '$')).click(); + await gu.waitForServer(); + + const selectByTable = selectBy.split(' ')[0]; + await gu.getCell({section: selectByTable, col: 0, rowNum: 3}).click(); + + let numSourceRows = 0; + + async function checkSourceGroup(sourceRowIndex: number) { + const sourceGroup = sourceData[sourceRowIndex]; + numSourceRows = sourceGroup.length / 3; + assert.deepEqual( + await gu.getVisibleGridCells({ + section: 'LINKTARGET', + cols: ['rownum', 'ref', 'reflist'], + rowNums: _.range(1, numSourceRows + 1), + }), + sourceGroup + ); + } + + for (let i = 0; i < sourceData.length; i++) { + await gu.getCell({section: selectByTable, col: 0, rowNum: i + 1}).click(); + await checkSourceGroup(i); + } + + // Create a new record with rownum=99 + await gu.getCell({section: 'LINKTARGET', col: 'rownum', rowNum: numSourceRows}).click(); + await gu.enterCell('99'); + + assert.deepEqual( + await gu.getVisibleGridCells({ + section: 'LINKTARGET', + cols: ['rownum', 'ref', 'reflist'], + rowNums: [numSourceRows], + }), + newRow, + ); + + await gu.undo(); + + // Check recursiveMoveToCursorPos + // TODO row number 4 is not tested because sometimes there are no matching source records + // to move the cursor to and we don't have a solution for that case yet + for (let rowNum = 1; rowNum <= 3; rowNum++) { + // Click an anchor link + const anchorCell = gu.getCell({section: "Anchors", rowNum, col: 1}); + await anchorCell.find('.test-tb-link').click(); + + // Check that navigation to the link target worked + assert.equal(await gu.getActiveSectionTitle(), "LINKTARGET"); + assert.equal(await gu.getActiveCell().getText(), String(rowNum)); + + // Check that the link target is still filtered correctly by the link source, + // which should imply that the link source cursor is in the right place + await gu.selectSectionByTitle(selectByTable); + const srcRowNum = await gu.getSelectedRowNum(); + await checkSourceGroup(srcRowNum - 1); + } +} diff --git a/test/nbrowser/SelectByRightPanel.ts b/test/nbrowser/SelectByRightPanel.ts new file mode 100644 index 00000000..091dc4b2 --- /dev/null +++ b/test/nbrowser/SelectByRightPanel.ts @@ -0,0 +1,65 @@ +import { getColValues } from 'app/common/DocActions'; +import { UserAPI } from 'app/common/UserAPI'; +import { assert, driver } from 'mocha-webdriver'; +import * as gu from 'test/nbrowser/gristUtils'; +import { server, setupTestSuite } from 'test/nbrowser/testUtils'; + +/** + * This is test for a bug that was on the Right Panel. [Select by] dropdown wasn't updated + * properly when summary tables (or linking in general) were updated. + */ + +describe("SelectByRightPanel", function() { + this.timeout(20000); + setupTestSuite(); + let docId: string; + let api: UserAPI; + + before(async () => { + await server.simulateLogin("Chimpy", "chimpy@getgrist.com", "nasa"); + docId = await gu.createNewDoc('chimpy', 'nasa', 'Horizon', 'Test22.grist'); + api = gu.createHomeApi('Chimpy', 'nasa'); + await driver.get(`${server.getHost()}/o/nasa/doc/${docId}`); + await gu.waitForDocToLoad(); + await api.applyUserActions(docId, [ + ['UpdateRecord', '_grist_Tables_column', 2, { label: 'Company' }], + ['UpdateRecord', '_grist_Tables_column', 3, { label: 'Category' }], + ['UpdateRecord', '_grist_Tables_column', 4, { label: 'Month' }], + ['AddVisibleColumn', 'Table1', 'Date', {}], + ['AddVisibleColumn', 'Table1', 'Value', {}], + ]); + // Add some dummy data. + await api.applyUserActions(docId, [ + ['BulkAddRecord', 'Table1', new Array(7).fill(null), getColValues([ + { Company: 'Mic', Category: 'Sales', Month: 1, Date: 1, Value: 100 }, + { Company: 'Mic', Category: 'Sales', Month: 1, Date: 2, Value: 100 }, + { Company: 'Mic', Category: 'Cloud', Month: 1, Date: 3, Value: 300 }, + { Company: 'Gog', Category: 'Sales', Month: 4, Date: 4, Value: 100 }, + { Company: 'Gog', Category: 'Adv', Month: 4, Date: 4, Value: 100 }, + { Company: 'Gog', Category: 'Adv', Month: 3, Date: 5, Value: 100 }, + { Company: 'Tes', Category: 'Sales', Month: 2, Date: 6, Value: 100 }, + ])], + ]); + }); + + it("selects by right panel for", async () => { + // Add first summary table by Company + await gu.addNewSection('Table', 'Table1', { summarize: ['Company'] }); + // Add second one by Category, we will update selection later using data selection on right panel + await gu.addNewSection('Table', 'Table1', { summarize: ['Category'] }); + // Add Company to this table, select by should be filled with the new option. + await gu.toggleSidePanel('right', 'open'); + await driver.find('.test-right-tab-pagewidget').click(); + await driver.find('.test-config-data').click(); + await driver.find('.test-pwc-editDataSelection').click(); + await driver.findContent('.test-wselect-column', /Company/).doClick(); + await driver.find('.test-wselect-addBtn').click(); + await gu.waitForServer(); + // Test that we have new option. + await driver.find('.test-right-select-by').click(); + await driver.findContentWait('.test-select-menu li', "TABLE1 [by Company]", 200).click(); + await gu.waitForServer(); + assert.deepEqual(await gu.getVisibleGridCells('Company', [1, 2]), ['Mic', 'Mic']); + assert.deepEqual(await gu.getVisibleGridCells('Category', [1, 2]), ['Sales', 'Cloud']); + }); +}); diff --git a/test/nbrowser/SelectBySummary.ts b/test/nbrowser/SelectBySummary.ts new file mode 100644 index 00000000..74987814 --- /dev/null +++ b/test/nbrowser/SelectBySummary.ts @@ -0,0 +1,275 @@ +import * as _ from 'lodash'; +import {addToRepl, assert, driver} from 'mocha-webdriver'; +import {enterRulePart, findDefaultRuleSet} from 'test/nbrowser/aclTestUtils'; +import * as gu from 'test/nbrowser/gristUtils'; +import {server, setupTestSuite} from 'test/nbrowser/testUtils'; +import {openSelectByForSection} from "./RightPanelSelectBy"; + +describe('SelectBySummary', function() { + this.timeout(50000); + setupTestSuite(); + addToRepl('gu2', gu); + gu.bigScreen(); + + before(async function() { + await server.simulateLogin("Chimpy", "chimpy@getgrist.com", 'nasa'); + const doc = await gu.importFixturesDoc('chimpy', 'nasa', 'Horizon', + 'SelectBySummary.grist', false); + await driver.get(`${server.getHost()}/o/nasa/doc/${doc.id}`); + await gu.waitForDocToLoad(); + + await gu.toggleSidePanel('right', 'open'); + await driver.find('.test-config-data').click(); + }); + + it('should filter a source table selected by a summary table', async function() { + await checkSelectingRecords( + ['onetwo'], + [ + '1', '16', + '2', '20', + ], + [ + [ + '1', 'a', '1', + '1', 'b', '3', + '1', 'a\nb', '5', + '1', '', '7', + ], + [ + '2', 'a', '2', + '2', 'b', '4', + '2', 'a\nb', '6', + '2', '', '8', + ], + ], + ); + + await checkSelectingRecords( + ['choices'], + [ + 'a', '14', + 'b', '18', + '', '15', + ], + [ + [ + '1', 'a', '1', + '2', 'a', '2', + '1', 'a\nb', '5', + '2', 'a\nb', '6', + ], + [ + '1', 'b', '3', + '2', 'b', '4', + '1', 'a\nb', '5', + '2', 'a\nb', '6', + ], + [ + '1', '', '7', + '2', '', '8', + ], + ], + ); + + + await checkSelectingRecords( + ['onetwo', 'choices'], + [ + '1', 'a', '6', + '2', 'a', '8', + '1', 'b', '8', + '2', 'b', '10', + '1', '', '7', + '2', '', '8', + ], + [ + [ + '1', 'a', '1', + '1', 'a\nb', '5', + ], + [ + '2', 'a', '2', + '2', 'a\nb', '6', + ], + [ + '1', 'b', '3', + '1', 'a\nb', '5', + ], + [ + '2', 'b', '4', + '2', 'a\nb', '6', + ], + [ + '1', '', '7', + ], + [ + '2', '', '8', + ], + ], + ); + + }); + + it('should create new rows in the source table (link target) with correct default values', + gu.revertChanges(async function() { + // Select the record with ['2', 'a'] in the summary table + // so those values will be used as defaults in the source table + await gu.getCell({section: 'TABLE1 [by onetwo, choices]', col: 'rownum', rowNum: 2}).click(); + + // Create a new record with rownum=99 + await gu.getCell({section: 'TABLE1', col: 'rownum', rowNum: 3}).click(); + await gu.enterCell('99'); + + assert.deepEqual( + await gu.getVisibleGridCells({ + section: 'TABLE1', + cols: ['onetwo', 'choices', 'rownum'], + rowNums: [3], + }), + ['2', 'a', '99'], + ); + }) + ); + + it('should filter a summary table selected by a less detailed summary table', async function() { + // Delete the Table1 widget so that we can hide the table in ACL without hiding the whole page. + const menu = await gu.openSectionMenu('viewLayout', 'TABLE1'); + await menu.findContent('.test-cmd-name', 'Delete widget').click(); + await gu.waitForServer(); + + // Open the ACL UI + await driver.find('.test-tools-access-rules').click(); + await driver.findWait('.test-rule-set', 2000); // Wait for initialization fetch to complete. + + // Deny all access to Table1. + await driver.findContentWait('button', /Add Table Rules/, 2000).click(); + await driver.findContentWait('.grist-floating-menu li', /Table1/, 3000).click(); + const ruleSet = findDefaultRuleSet(/Table1/); + await enterRulePart(ruleSet, 1, null, 'Deny All'); + await driver.find('.test-rules-save').click(); + await gu.waitForServer(); + + // Go back to the main page. + await gu.getPageItem('Table1').click(); + + // Now check filter linking, but with the detailed summary 'TABLE1 [by onetwo, choices]' as the target, + // selecting by the two less detailed summaries. + // There was a bug previously that this would not work while the summary source table (Table1) was hidden. + await checkSelectingRecords( + ['onetwo'], + [ + '1', '16', + '2', '20', + ], + [ + [ + '1', 'a', '6', + '1', 'b', '8', + '1', '', '7', + ], + [ + '2', 'a', '8', + '2', 'b', '10', + '2', '', '8', + ], + ], + // This argument was not used in the previous test, as TABLE1 is the default. + 'TABLE1 [by onetwo, choices]', + ); + + await checkSelectingRecords( + ['choices'], + [ + 'a', '14', + 'b', '18', + '', '15', + ], + [ + [ + '1', 'a', '6', + '2', 'a', '8', + ], + [ + '1', 'b', '8', + '2', 'b', '10', + ], + [ + '1', '', '7', + '2', '', '8', + ], + ], + 'TABLE1 [by onetwo, choices]', + ); + }); + +}); + +/** + * Makes `targetSection` select by the existing summary table grouped by groubyColumns. + * Asserts that the summary table has the data summaryData under groubyColumns and rownum. + * Asserts that clicking each row in the summary table filters the target section + * to the corresponding subarray of `targetData`. + */ +async function checkSelectingRecords( + groubyColumns: string[], + summaryData: string[], + targetData: string[][], + targetSection: string = 'TABLE1', +) { + const summarySection = `TABLE1 [by ${groubyColumns.join(', ')}]`; + + await openSelectByForSection(targetSection); + await driver.findContent('.test-select-row', summarySection).click(); + await gu.waitForServer(); + + assert.deepEqual( + await gu.getVisibleGridCells({ + section: summarySection, + cols: [...groubyColumns, 'rownum'], + rowNums: _.range(1, targetData.length + 1) + }), + summaryData, + ); + + async function checkTargetGroup(targetGroupIndex: number) { + const targetGroup = targetData[targetGroupIndex]; + const countCell = await gu.getCell({section: summarySection, col: 'count', rowNum: targetGroupIndex + 1}); + const numTargetRows = targetGroup.length / 3; + if (targetSection === 'TABLE1') { + assert.equal(await countCell.getText(), numTargetRows.toString()); + } + await countCell.click(); + assert.deepEqual( + await gu.getVisibleGridCells({ + section: targetSection, + cols: ['onetwo', 'choices', 'rownum'], + rowNums: _.range(1, numTargetRows + 1), + }), + targetGroup + ); + } + + for (let i = 0; i < targetData.length; i++) { + await checkTargetGroup(i); + } + + if (targetSection === 'TABLE1') { + // Check recursiveMoveToCursorPos + for (let rowNum = 1; rowNum <= 8; rowNum++) { + // Click an anchor link + const anchorCell = gu.getCell({section: "Anchors", rowNum, col: 1}); + await anchorCell.find('.test-tb-link').click(); + + // Check that navigation to the link target worked + assert.equal(await gu.getActiveSectionTitle(), "TABLE1"); + assert.equal(await gu.getActiveCell().getText(), String(rowNum)); + + // Check that the link target is still filtered correctly by the link source, + // which should imply that the link source cursor is in the right place + await gu.selectSectionByTitle(summarySection); + const summaryRowNum = await gu.getSelectedRowNum(); + await checkTargetGroup(summaryRowNum - 1); + } + } +} diff --git a/test/nbrowser/SelectBySummaryRef.ts b/test/nbrowser/SelectBySummaryRef.ts new file mode 100644 index 00000000..2e41321a --- /dev/null +++ b/test/nbrowser/SelectBySummaryRef.ts @@ -0,0 +1,192 @@ +import {addToRepl, assert, driver, Key} from 'mocha-webdriver'; +import * as gu from 'test/nbrowser/gristUtils'; +import {server, setupTestSuite} from 'test/nbrowser/testUtils'; +import {openSelectByForSection} from "./RightPanelSelectBy"; + +describe('SelectBySummaryRef', function() { + this.timeout(20000); + setupTestSuite(); + addToRepl('gu2', gu); + + before(async function(){ + await server.simulateLogin("Chimpy", "chimpy@getgrist.com", 'nasa'); + const doc = await gu.importFixturesDoc('chimpy', 'nasa', 'Horizon', + 'SelectBySummaryRef.grist', false); + await driver.get(`${server.getHost()}/o/nasa/doc/${doc.id}`); + await gu.waitForDocToLoad(); + + await gu.toggleSidePanel('right', 'open'); + await driver.find('.test-config-data').click(); + }); + + it('should give correct options when linking with a summary table with ref/reflist columns', async () => { + /* + The doc has 3 tables on one page with these columns: + 1. Source: + - 'Other ref' is a reflist to 'Other' + 2. Summary (a summary table of Source): + - 'Other ref' is the groupby column, so now it's a *ref* to 'Other', hence the column name in Source + - 'Source ref' is a ref to Source + - 'Source reflist' is a reflist to Source + - 'group' is the usual group column in summary tables (a reflist to Source) which is hidden from the options. + 3. Other: + - 'Text' which won't be mentioned again since it's not a reference or anything. + - 'Source ref' is a ref to Source + */ + + const sourceOptions = [ + 'Other', + 'Other • Source ref', + 'Summary', + 'Summary • Other ref', + 'Summary • Source ref', + 'Summary • Source reflist', + ]; + const summaryOptions = [ + 'Source → Source ref', + 'Source → Source reflist', + 'Source • Other ref', + 'Other', + 'Other • Source ref → Source ref', + 'Other • Source ref → Source reflist', + ]; + const otherOptions = [ + 'Source', + 'Source • Other ref', + 'Summary • Other ref', + 'Summary → Source ref', + 'Summary • Source ref', + 'Summary • Source reflist', + ]; + await checkRightPanelSelectByOptions('Source', sourceOptions); + await checkRightPanelSelectByOptions('Other', otherOptions); + await checkRightPanelSelectByOptions('Summary', summaryOptions); + + // Detach the summary table + await driver.find('.test-detach-button').click(); + await gu.waitForServer(); + + // Each widget now has an option to select by the `group` reflist column of Summary + // in place of selecting by 'summaryness'. + const sourceOptionsWithGroup = [...sourceOptions, 'Summary • group']; + assert.deepEqual(sourceOptionsWithGroup.splice(2, 1), ['Summary']); + + const otherOptionsWithGroup = [...otherOptions, 'Summary • group']; + assert.deepEqual(otherOptionsWithGroup.splice(3, 1), ['Summary → Source ref']); + + // The summary table has also gained new options to select by the group column. + // There were no corresponding 'summaryness' options before because a summary table can't select by its source table + // (based purely on summaryness), only the other way around. + // Same for selecting by a reference to the source table. + // Such options are theoretically possible but are disabled because they're a bit weird, + // usually filter linking to a single row when cursor linking would make more sense and still not be very useful. + const summaryOptionsWithGroup = [...summaryOptions, 'Other • Source ref → group']; + summaryOptionsWithGroup.splice(2, 0, 'Source → group'); + + await checkRightPanelSelectByOptions('Source', sourceOptionsWithGroup); + await checkRightPanelSelectByOptions('Other', otherOptionsWithGroup); + await checkRightPanelSelectByOptions('Summary', summaryOptionsWithGroup); + + // Undo detaching the summary table + await gu.undo(); + }); + + it('should give correct options when adding a new summary table', async () => { + // Go to the second page in the document, which only has a widget for the 'Other' table + await gu.getPageItem("Other").click(); + + await gu.openAddWidgetToPage(); + + // Sanity check for the select by options of the plain table + await gu.selectWidget('Table', 'Other', {dontAdd: true}); + await checkAddWidgetSelectByOptions([ + 'Other', + 'Other • Source ref', + ]); + + // Select by options for summary tables of Other only exist when grouping by Source ref + await gu.selectWidget('Table', 'Other', {dontAdd: true, summarize: []}); + await checkAddWidgetSelectByOptions(null); + await gu.selectWidget('Table', 'Other', {dontAdd: true, summarize: ['Text']}); + await checkAddWidgetSelectByOptions(null); + await gu.selectWidget('Table', 'Other', {dontAdd: true, summarize: ['Source ref']}); + // Note that in this case we are inferring options for a table that doesn't exist anywhere yet + await checkAddWidgetSelectByOptions([ + 'Other • Source ref', + ]); + + // Actually add the summary table in the last case above, selected by the only option + await gu.selectWidget('Table', 'Other', + {selectBy: 'Other • Source ref', summarize: ['Source ref']}); + + // Check that the link is actually there in the right panel and that the options are the same as when adding. + await checkCurrentSelectBy('Other • Source ref'); + await checkRightPanelSelectByOptions('OTHER [by Source ref]', [ + 'Other • Source ref', + ]); + + // Undo adding the summary table + await gu.undo(); + }); + + it('should give correct options when adding an existing summary table', async () => { + // Go to the second page in the document, which only has a widget for the 'Other' table + await gu.getPageItem("Other").click(); + + await gu.openAddWidgetToPage(); + + // Sanity check for the select by options of the plain table + await gu.selectWidget('Table', 'Source', {dontAdd: true}); + await checkAddWidgetSelectByOptions([ + 'Other', + 'Other • Source ref', + ]); + + // No select by options for summary table without groupby columns + await gu.selectWidget('Table', 'Source', {dontAdd: true, summarize: []}); + await checkAddWidgetSelectByOptions(null); + + // This summary table already exists on the first page. + // '→ Source ref' and '→ Source reflist' refer to formula columns in the summary table that + // don't exist by default. + await gu.selectWidget('Table', 'Source', {dontAdd: true, summarize: ['Other ref']}); + await checkAddWidgetSelectByOptions([ + 'Other', + 'Other • Source ref → Source ref', + 'Other • Source ref → Source reflist', + ]); + + // Actually add the summary table in the last case above, selected by the second option + await gu.selectWidget('Table', 'Source', + {selectBy: 'Other • Source ref → Source ref', summarize: ['Other ref']}); + + // Check that the link is actually there in the right panel and that the options are the same as when adding. + await checkCurrentSelectBy('Other • Source ref → Source ref'); + await checkRightPanelSelectByOptions('SOURCE [by Other ref]', [ + 'Other', + 'Other • Source ref → Source ref', + 'Other • Source ref → Source reflist', + ]); + }); + +}); + + +// Check that the 'Select by' menu in the right panel for the section has the expected options +async function checkRightPanelSelectByOptions(section: string, expected: string[]) { + await openSelectByForSection(section); + + const actual = await driver.findAll('.test-select-menu .test-select-row', (e) => e.getText()); + assert.deepEqual(actual, ['Select Widget', ...expected]); + await gu.sendKeys(Key.ESCAPE); +} + +async function checkAddWidgetSelectByOptions(expected: string[]|null) { + const actual = await driver.findAll('.test-wselect-selectby option', (e) => e.getText()); + assert.deepEqual(actual, expected === null ? [] : ['', 'Select Widget', ...expected]); +} + +async function checkCurrentSelectBy(expected: string) { + const actual = await driver.find('.test-right-select-by').getText(); + assert.equal(actual, expected); +} diff --git a/test/nbrowser/aclTestUtils.ts b/test/nbrowser/aclTestUtils.ts new file mode 100644 index 00000000..5d83d1aa --- /dev/null +++ b/test/nbrowser/aclTestUtils.ts @@ -0,0 +1,204 @@ +import { assert, driver, Key, stackWrapOwnMethods, WebElement } from 'mocha-webdriver'; +import * as gu from 'test/nbrowser/gristUtils'; + +// tslint:disable:no-namespace +// Wrap in a namespace so that we can apply stackWrapOwnMethods to all the exports together. +namespace gristUtils { + + /** + * Find .test-rule-table element for the given tableId. + */ + export function findTable(tableId: RegExp|'*'): WebElement { + const header = driver.findContent('.test-rule-table-header', tableId === '*' ? 'Default Rules' : tableId); + return header.findClosest('.test-rule-table'); + } + + /** + * Remove any rules within a .test-rule-table element, by hitting the trash buttons. + */ + export async function removeTable(tableId: RegExp|'*'): Promise { + const header = driver.findContent('.test-rule-table-header', tableId === '*' ? 'Default Rules' : tableId); + if (await header.isPresent()) { + const table = header.findClosest('.test-rule-table'); + await removeRules(table); + } + } + + /** + * Remove any rules within an element, by hitting the trash button. + */ + export async function removeRules(el: WebElement): Promise { + while (true) { // eslint-disable-line no-constant-condition + const remove = el.find('.test-rule-remove'); + if (!await remove.isPresent()) { break; } + await remove.click(); + } + } + + /** + * Find .test-rule-set for the default rule set of the given tableId. + */ + export function findDefaultRuleSet(tableId: RegExp|'*'): WebElement { + const table = findTable(tableId); + const cols = table.findContent('.test-rule-resource', /All/); + return cols.findClosest('.test-rule-set'); + } + + /** + * Find a .test-rule-set at the given 1-based index, among the rule sets for the given tableId. + */ + export function findRuleSet(tableId: RegExp|'*', ruleNum: number): WebElement { + const table = findTable(tableId); + // Add one to skip table header element. + return table.find(`.test-rule-set:nth-child(${ruleNum + 1})`); + } + + /** + * PartNum should be 1-based. Permissions is either the text of an option in the permission + * widget's dropdown menu (e.g. "Allow All") or a mapping of single-character bit to desired + * state, e.g. {R: 'deny', U: 'allow', C: ''}. + */ + export async function enterRulePart( + ruleSet: WebElement, + partNum: number, + aclFormula: string|null, + permissions: string|{[bit: string]: string}, + memo?: string + ) { + const part = ruleSet.find(`.test-rule-part-and-memo:nth-child(${partNum}) .test-rule-part`); + if (aclFormula !== null) { + await part.findWait('.test-rule-acl-formula .ace_editor', 500); + await part.find('.test-rule-acl-formula').doClick(); + await driver.findWait('.test-rule-acl-formula .ace_focus', 500); + await gu.sendKeys(Key.HOME, Key.chord(Key.SHIFT, Key.END), Key.DELETE); // Clear formula + await gu.sendKeys(aclFormula, Key.ENTER); + } + if (typeof permissions === 'string') { + await part.find('.test-rule-permissions .test-permissions-dropdown').click(); + await driver.findContent('.grist-floating-menu li', permissions).click(); + } else { + for (const [bit, desired] of Object.entries(permissions)) { + const elem = await part.findContent('.test-rule-permissions div', bit); + if (!await elem.matches(`[class$=-${desired}]`)) { + await elem.click(); + if (!await elem.matches(`[class$=-${desired}]`)) { + await elem.click(); + if (!await elem.matches(`[class$=-${desired}]`)) { + throw new Error(`Can't set permission bit ${bit} to ${desired}`); + } + } + } + } + } + if (memo) { + const memoEditorPromise = ruleSet.find(`.test-rule-part-and-memo:nth-child(${partNum}) .test-rule-memo-editor`); + if (await memoEditorPromise.isPresent()) { + await memoEditorPromise.click(); + await gu.clearInput(); + } else { + await part.find('.test-rule-memo-add').click(); + } + await gu.sendKeys(memo, Key.ENTER); + } + } + + /** + * Enters formula in the ACL condition editor to trigger the autocomplete dropdown. + * @param ruleSet Rule set dom (for a table or default) + * @param partNum Index of the condition + * @param aclFormula Formula to enter + */ + export async function triggerAutoComplete( + ruleSet: WebElement, partNum: number, aclFormula: string + ) { + const part = ruleSet.find(`.test-rule-part-and-memo:nth-child(${partNum}) .test-rule-part`); + if (aclFormula !== null) { + await part.findWait('.test-rule-acl-formula .ace_editor', 500); + await part.find('.test-rule-acl-formula').doClick(); + await driver.findWait('.test-rule-acl-formula .ace_focus', 500); + await gu.sendKeys(Key.HOME, Key.chord(Key.SHIFT, Key.END), Key.DELETE); // Clear formula + await gu.sendKeys(aclFormula); + } + } + + /** + * Fetch rule text from an element. Uses Ace text if that is non-empty, in order + * to get complete text of long rules. If Ace text is empty, returns any plain + * text (e.g. "Everyone Else"). + */ + export async function getRuleText(el: WebElement) { + const plainText = await el.getText(); + const aceText = await gu.getAceText(el); + return aceText || plainText; + } + + /** + * Read the rules within an element in a format that is easy to + * compare with. + */ + export async function getRules(el: WebElement): Promise> { + const ruleSets = await el.findAll('.test-rule-set'); + const results: Array<{formula: string, perm: string, + res?: string, + memo?: string}> = []; + for (const ruleSet of ruleSets) { + const scope = ruleSet.find('.test-rule-resource'); + const res = (await scope.isPresent()) ? (await scope.getText()) : undefined; + const parts = await ruleSet.findAll('.test-rule-part-and-memo'); + for (const part of parts) { + const formula = await getRuleText(await part.find('.test-rule-acl-formula')); + const perms = await part.find('.test-rule-permissions').findAll('div'); + const permParts: Array = []; + for (const perm of perms) { + const content = await perm.getText(); + if (content.length !== 1) { continue; } + const classes = await perm.getAttribute('class'); + const prefix = classes.includes('-deny') ? '-' : + (classes.includes('-allow') ? '+' : ''); + permParts.push(prefix ? (prefix + content) : ''); + } + const hasMemo = await part.find('.test-rule-memo').isPresent(); + const memo = hasMemo ? await part.find('.test-rule-memo input').value() : undefined; + results.push({formula, perm: permParts.join(''), + ...(memo ? {memo} : {}), + ...(res ? {res} : {}) + }); + } + } + return results; + } + + /** + * Check if there is an extra "add" button compared to the number of rules + * within an element. + */ + export async function hasExtraAdd(el: WebElement): Promise { + const parts = await el.findAll('.test-rule-part-and-memo'); + const adds = await el.findAll('.test-rule-add'); + return adds.length === parts.length + 1; + } + + /** + * Assert that the Save button is currently disabled because the rules are + * saved. + */ + export async function assertSaved() { + assert.equal(await driver.find('.test-rules-non-save').getText(), 'Saved'); + assert.equal(await driver.find('.test-rules-save').getText(), ''); + } + + /** + * Assert that the Save button is currently enabled because the rules have + * changed. + */ + export async function assertChanged() { + assert.equal(await driver.find('.test-rules-save').getText(), 'Save'); + assert.equal(await driver.find('.test-rules-non-save').getText(), ''); + } +} // end of namespace aclTestUtils + +stackWrapOwnMethods(gristUtils); +export = gristUtils;