diff --git a/test/fixtures/docs/Pages.grist b/test/fixtures/docs/Pages.grist new file mode 100644 index 00000000..616e2fef Binary files /dev/null and b/test/fixtures/docs/Pages.grist differ diff --git a/test/nbrowser/Pages.ts b/test/nbrowser/Pages.ts new file mode 100644 index 00000000..9237fa43 --- /dev/null +++ b/test/nbrowser/Pages.ts @@ -0,0 +1,475 @@ +import {DocCreationInfo} from 'app/common/DocListAPI'; +import {UserAPI} from 'app/common/UserAPI'; +import {assert, driver, Key} from 'mocha-webdriver'; +import {Session} from 'test/nbrowser/gristUtils'; +import * as gu from 'test/nbrowser/gristUtils'; +import {server, setupTestSuite} from 'test/nbrowser/testUtils'; +import values = require('lodash/values'); + +describe('Pages', function() { + this.timeout(20000); + setupTestSuite(); + let doc: DocCreationInfo; + let api: UserAPI; + let session: Session; + const cleanup = setupTestSuite({team: true}); + + before(async () => { + session = await gu.session().teamSite.login(); + doc = await session.tempDoc(cleanup, 'Pages.grist'); + api = session.createHomeApi(); + }); + + it('should list all pages in document', async () => { + + // check content of _girst_Pages and _grist_Views + assert.deepInclude(await api.getTable(doc.id, '_grist_Pages'), { + viewRef: [1, 2, 3, 4, 5], + pagePos: [1, 2, 1.5, 3, 4], + indentation: [0, 0, 1, 1, 1], + }); + assert.deepInclude(await api.getTable(doc.id, '_grist_Views'), { + name: ['Interactions', 'People', 'Documents', 'User & Leads', 'Overview'], + id: [1, 2, 3, 4, 5], + }); + + // load page and check all pages are listed + await driver.get(`${server.getHost()}/o/test-grist/doc/${doc.id}`); + await driver.findWait('.test-treeview-container', 1000); + assert.deepEqual(await gu.getPageNames(), ['Interactions', 'Documents', 'People', 'User & Leads', 'Overview']); + }); + + it('should select correct page if /p/ in the url', async () => { + + // show page with viewRef 2 + await gu.loadDoc(`/o/test-grist/doc/${doc.id}/p/2`); + assert.deepEqual(await gu.getPageNames(), ['Interactions', 'Documents', 'People', 'User & Leads', 'Overview']); + assert.match(await driver.find('.test-treeview-itemHeader.selected').getText(), /People/); + assert.match(await gu.getActiveSectionTitle(), /People/i); + }); + + it('should select first page if /p/ is omitted in the url', async () => { + + await driver.get(`${server.getHost()}/o/test-grist/doc/${doc.id}`); + await driver.findWait('.test-treeview-container', 1000); + assert.match(await driver.find('.test-treeview-itemHeader').getText(), /Interactions/); + assert.match(await driver.find('.test-treeview-itemHeader.selected').getText(), /Interactions/); + assert.match(await gu.getActiveSectionTitle(), /Interactions/i); + + // Check also that this did NOT cause a redirect to include /p/ in the URL. + assert.notMatch(await driver.getCurrentUrl(), /\/p\//); + }); + + it('clicking page should set /p/ in the url', async () => { + await driver.get(`${server.getHost()}/o/test-grist/doc/${doc.id}`); + + // Wait for data to load. + assert.equal(await driver.findWait('.viewsection_title', 3000).isDisplayed(), true); + await gu.waitForServer(); + + // Click on a page; check the URL, selected item, and the title of the view section. + await driver.findContent('.test-treeview-itemHeader', /Documents/).doClick(); + assert.match(await driver.getCurrentUrl(), /\/p\/3/); + assert.match(await driver.find('.test-treeview-itemHeader.selected').getText(), /Documents/); + assert.match(await gu.getActiveSectionTitle(), /Documents/i); + + // Click on another page; check the URL, selected item, and the title of the view section. + await driver.findContent('.test-treeview-itemHeader', /People/).doClick(); + assert.match(await driver.getCurrentUrl(), /\/p\/2/); + assert.match(await driver.find('.test-treeview-itemHeader.selected').getText(), /People/); + assert.match(await gu.getActiveSectionTitle(), /People/i); + + // TODO: Add a check that open-in-new-tab works too. + }); + + it('should allow renaming table', async () => { + + // open dots menu and click rename + await gu.openPageMenu('People'); + await driver.find('.test-docpage-rename').doClick(); + + // do rename + await driver.find('.test-docpage-editor').sendKeys('PeopleRenamed', Key.ENTER); + await gu.waitForServer(); + + assert.deepEqual( + await gu.getPageNames(), + ['Interactions', 'Documents', 'PeopleRenamed', 'User & Leads', 'Overview'] + ); + + // Test that we can delete after remove (there was a bug related to this). + await gu.removePage('PeopleRenamed'); + + assert.deepEqual(await gu.getPageNames(), ['Interactions', 'Documents', 'User & Leads', 'Overview']); + + // revert changes + await gu.undo(2); + assert.deepEqual(await gu.getPageNames(), ['Interactions', 'Documents', 'People', 'User & Leads', 'Overview']); + }); + + it('should not allow blank page name', async () => { + // Begin renaming of People page + await gu.openPageMenu('People'); + await driver.find('.test-docpage-rename').doClick(); + + // Delete page name and check editor's value equals '' + await driver.find('.test-docpage-editor').sendKeys(Key.DELETE); + assert.equal(await driver.find('.test-docpage-editor').value(), ''); + + // Save blank name + await driver.sendKeys(Key.ENTER); + await gu.waitForServer(); + + // Check name is still People + assert.include(await gu.getPageNames(), 'People'); + }); + + it('should not change page when clicking the input while renaming page', async () => { + // check that initially People is selected + assert.match(await driver.find('.test-treeview-itemHeader.selected').getText(), /People/); + + // start renaming Documents and click the input + await gu.openPageMenu('Documents'); + await driver.find('.test-docpage-rename').doClick(); + await driver.find('.test-docpage-editor').click(); + + // check that People is still the selected page. + assert.match(await driver.find('.test-treeview-itemHeader.selected').getText(), /People/); + + // abord renaming + await driver.find('.test-docpage-editor').sendKeys(Key.ESCAPE); + }); + + it('should allow moving pages', async () => { + + // check initial state + assert.deepEqual(await gu.getPageNames(), ['Interactions', 'Documents', 'People', 'User & Leads', 'Overview']); + + // move page + await movePage(/User & Leads/, {after: /Overview/}); + await gu.waitForServer(); + + assert.deepEqual(await gu.getPageNames(), ['Interactions', 'Documents', 'People', 'Overview', 'User & Leads']); + + // revert changes + await gu.undo(); + assert.deepEqual(await gu.getPageNames(), ['Interactions', 'Documents', 'People', 'User & Leads', 'Overview']); + + }); + + it('moving a page should not extend collapsed page', async () => { + /** + * Here what is really being tested is that TreeModelRecord correctly reuses TreeModelItem, + * because if it wasn't the case, TreeViewComponent would not be able to reuse dom and would + * rebuild dom for all pages causing all page to be expanded. + */ + + // let's collapse Interactions + await driver.findContent('.test-treeview-itemHeader', /Interactions/).find('.test-treeview-itemArrow').doClick(); + assert.deepEqual(await gu.getPageNames(), ['Interactions', '', 'People', 'User & Leads', 'Overview']); + + // let's move + await movePage(/User & Leads/, {after: /Overview/}); + await gu.waitForServer(); + + // check that pages has moved and Interactions remained collapsed + assert.deepEqual(await gu.getPageNames(), ['Interactions', '', 'People', 'Overview', 'User & Leads']); + + // revert changes + await gu.undo(); + await driver.findContent('.test-treeview-itemHeader', /Interactions/).find('.test-treeview-itemArrow').doClick(); + assert.deepEqual(await gu.getPageNames(), ['Interactions', 'Documents', 'People', 'User & Leads', 'Overview']); + }); + + it('should allow to cycle though pages using shortcuts', async () => { + + function nextPage() { + return driver.find('body').sendKeys(Key.chord(Key.ALT, Key.DOWN)); + } + + function prevPage() { + return driver.find('body').sendKeys(Key.chord(Key.ALT, Key.UP)); + } + + function selectedPage() { + return driver.find('.test-treeview-itemHeader.selected').getText(); + } + + // goto page 'Interactions' + await driver.findContent('.test-treeview-itemHeader', /Interactions/).doClick(); + + // check selected page + assert.match(await selectedPage(), /Interactions/); + + // prev page + await prevPage(); + + // check selecte page + assert.match(await selectedPage(), /Overview/); + + // prev page + await prevPage(); + + // check selecte page + assert.match(await selectedPage(), /User & Leads/); + + // next page + await nextPage(); + + // check selected page + assert.match(await selectedPage(), /Overview/); + + + // next page + await nextPage(); + + // check selected page + assert.match(await selectedPage(), /Interactions/); + + }); + + it('undo/redo should update url', async () => { + + // goto page 'Interactions' and send keys + await driver.findContent('.test-treeview-itemHeader', /Interactions/).doClick(); + await driver.findContentWait('.gridview_data_row_num', /1/, 2000); + await driver.sendKeys(Key.ENTER, 'Foo', Key.ENTER); + await gu.waitForServer(); + assert.deepEqual(await gu.getVisibleGridCells(0, [1]), ['Foo']); + + // goto page 'People' and click undo + await driver.findContent('.test-treeview-itemHeader', /People/).doClick(); + await gu.waitForDocToLoad(); + await gu.waitForUrl(/\/p\/2\b/); // check that url match p/2 + + await gu.undo(); + await gu.waitForDocToLoad(); + await gu.waitForUrl(/\/p\/1\b/); // check that url match p/1 + + // check that "Interactions" page is selected + assert.match(await driver.find('.test-treeview-itemHeader.selected').getText(), /Interactions/); + + // check that undo worked + assert.deepEqual(await gu.getVisibleGridCells(0, [1]), ['']); + }); + + it('Add new page should update url', async () => { + // goto page 'Interactions' and check that url updated + await driver.findContent('.test-treeview-itemHeader', /Interactions/).doClick(); + await gu.waitForUrl(/\/p\/1\b/); + + // Add new Page, check that url updated and page is selected + await gu.addNewPage(/Table/, /New Table/); + await gu.waitForUrl(/\/p\/6\b/); + assert.match(await driver.find('.test-treeview-itemHeader.selected').getText(), /Table1/); + + // goto page 'Interactions' and check that url updated and page selectd + await driver.findContent('.test-treeview-itemHeader', /Interactions/).doClick(); + await gu.waitForUrl(/\/p\/1\b/); + assert.match(await driver.find('.test-treeview-itemHeader.selected').getText(), /Interactions/); + }); + + it('Removing a page should work', async () => { + + // Create and open new document + const docId = await session.tempNewDoc(cleanup, "test-page-removal"); + await driver.get(`${server.getHost()}/o/test-grist/doc/${docId}`); + await gu.waitForUrl('test-page-removal'); + + // Add a new page using Table1 + await gu.addNewPage(/Table/, /Table1/); + assert.deepInclude(await api.getTable(docId, '_grist_Tables'), { + tableId: ['Table1'], + primaryViewId: [1], + }); + assert.deepInclude(await api.getTable(docId, '_grist_Views'), { + name: ['Table1', 'New page'], + id: [1, 2], + }); + assert.deepEqual(await gu.getPageNames(), ['Table1', 'New page']); + + // check that the new page is now selected + await gu.waitForUrl(/\/p\/2\b/); + assert.match(await driver.find('.test-treeview-itemHeader.selected').getText(), /New page/); + + // remove new page + await gu.removePage(/New page/); + + // check that url has no p/<...> and 'Table1' is now selected + await driver.wait(async () => !/\/p\//.test(await driver.getCurrentUrl()), 2000); + assert.match(await driver.find('.test-treeview-itemHeader.selected').getText(), /Table1/); + + // check that corresponding view is removed + assert.deepInclude(await api.getTable(docId, '_grist_Tables'), { + tableId: ['Table1'], + primaryViewId: [1], + }); + assert.deepInclude(await api.getTable(docId, '_grist_Views'), { + name: ['Table1'], + id: [1], + }); + assert.deepEqual(await gu.getPageNames(), ['Table1']); + + // create table Foo and 1 new page using Foo + await api.applyUserActions(docId, [['AddTable', 'Foo', [{id: null, isFormula: true}]]]); + await driver.findContentWait('.test-treeview-itemHeader', /Foo/, 2000); + await gu.addNewPage(/Table/, /Foo/); + assert.deepInclude(await api.getTable(docId, '_grist_Tables'), { + tableId: ['Table1', 'Foo'], + primaryViewId: [1, 2], + }); + assert.deepInclude(await api.getTable(docId, '_grist_Views'), { + name: ['Table1', 'Foo', 'New page'], + id: [1, 2, 3], + }); + assert.deepEqual(await gu.getPageNames(), ['Table1', 'Foo', 'New page']); + + // check that last page is now selected + await gu.waitForUrl(/\/p\/3\b/); + assert.match(await driver.find('.test-treeview-itemHeader.selected').getText(), /New page/); + + // remove table and make sure pages are also removed. + await gu.removeTable('Foo'); + + // check that Foo and page are removed + assert.deepInclude(await api.getTable(docId, '_grist_Tables'), { + tableId: ['Table1'], + primaryViewId: [1], + }); + assert.deepInclude(await api.getTable(docId, '_grist_Views'), { + name: ['Table1'], + id: [1], + }); + assert.deepEqual(await gu.getPageNames(), ['Table1']); + }); + + it('Remove should be disabled for last page', async () => { + // check that Remove is disabled on Table1 + assert.isFalse(await gu.canRemovePage('Table1')); + + // Adds a new page using Table1 + await gu.addNewPage(/Table/, /Table1/); + assert.deepEqual(await gu.getPageNames(), ['Table1', 'New page']); + + // Add a new table too. + await gu.addNewTable(); + assert.deepEqual(await gu.getPageNames(), ['Table1', 'New page', 'Table2']); + + // The "Remove" options should now be available on all three items. + assert.isTrue(await gu.canRemovePage('Table1')); + assert.isTrue(await gu.canRemovePage('New page')); + assert.isTrue(await gu.canRemovePage('Table2')); + + // Add Table2 to "New page" (so that it can remain after Table1 is removed below). + await gu.getPageItem('New page').click(); + await gu.addNewSection(/Table/, /Table2/); + + // Now remove Table1. + await gu.removeTable('Table1'); + assert.deepEqual(await gu.getPageNames(), ['New page', 'Table2']); + + // Both pages should be removable still. + assert.isTrue(await gu.canRemovePage('New page')); + assert.isTrue(await gu.canRemovePage('Table2')); + + // Remove New Page + await gu.removePage('New page'); + + // Now Table2 should not be removable (since it is the last page). + assert.isFalse(await gu.canRemovePage('Table2')); + }); + + it('should not throw JS errors when removing the current page without a slug', async () => { + // Create and open new document + const docId = await session.tempNewDoc(cleanup, "test-page-removal-js-error") + await driver.get(`${server.getHost()}/o/test-grist/doc/${docId}`); + await gu.waitForUrl('test-page-removal-js-error'); + + // Add two additional tables + await gu.addNewTable(); + await gu.addNewTable(); + assert.deepEqual(await gu.getPageNames(), ['Table1', 'Table2', 'Table3']); + + // Open the default page (no p/<...> in the URL) + await driver.get(`${server.getHost()}/o/test-grist/doc/${docId}`); + + // Check that Table1 is now selected + await driver.findContentWait('.test-treeview-itemHeader.selected', /Table1/, 2000); + assert.match(await driver.find('.test-treeview-itemHeader.selected').getText(), /Table1/); + + // Remove page Table1 + await gu.removePage('Table1'); + assert.deepEqual(await gu.getPageNames(), ['Table2', 'Table3']); + + // Now check that Table2 is selected + assert.match(await driver.find('.test-treeview-itemHeader.selected').getText(), /Table2/); + + // Remove page Table2 + await gu.removePage('Table2'); + assert.deepEqual(await gu.getPageNames(), ['Table3']); + + // Check that Table3 is the only page remaining + assert.deepInclude(await api.getTable(docId, '_grist_Views'), { + name: ['Table3'], + id: [3], + }); + + // Check that no JS errors were thrown + await gu.checkForErrors(); + }); + + it('should offer a way to delete last tables', async () => { + // Create and open new document + const docId = await session.tempNewDoc(cleanup, "prompts") + await driver.get(`${server.getHost()}/o/test-grist/doc/${docId}`); + await gu.waitForUrl('prompts'); + + // Add two additional tables, with custom names. + await gu.addNewTable('Table B'); + await gu.addNewTable('Table C'); + await gu.addNewTable('Table Last'); + assert.deepEqual(await gu.getPageNames(), ['Table1', 'Table B', 'Table C', 'Table Last']); + await gu.getPageItem('Table C').click(); + + // In Table C add Table D (a new one) and Table1 widget (existing); + await gu.addNewSection(/Table/, /New Table/, { tableName: "Table D"}); + await gu.addNewSection(/Table/, "Table1"); + // New table should not be added as a page + assert.deepEqual(await gu.getPageNames(), ['Table1', 'Table B', 'Table C', 'Table Last']); + // Make sure we see proper sections. + assert.deepEqual(await gu.getSectionTitles(), ['TABLE C', 'TABLE D', 'TABLE1']); + + const revert = await gu.begin(); + // Now removing Table1 page should be done without a prompt (since it is also on Table C) + await gu.removePage("Table1", { expectPrompt : false }); + assert.deepEqual(await gu.getPageNames(), ['Table B', 'Table C', 'Table Last']); + + // Removing Table B should show prompt (since it is last page) + await gu.removePage("Table B", { expectPrompt : true, tables: ['Table B'] }); + assert.deepEqual(await gu.getPageNames(), ['Table C', 'Table Last']); + + // Removing page Table C should also show prompt (it is last page for Table1,Table D and TableC) + await gu.getPageItem('Table C').click(); + assert.deepEqual(await gu.getSectionTitles(), ['TABLE C', 'TABLE D', 'TABLE1' ]); + await gu.removePage("Table C", { expectPrompt : true, tables: ['Table D', 'Table C', 'Table1'] }); + assert.deepEqual(await gu.getPageNames(), ['Table Last']); + await revert(); + + assert.deepEqual(await gu.getPageNames(), ['Table1', 'Table B', 'Table C', 'Table Last']); + assert.deepEqual(await gu.getSectionTitles(), ['TABLE C', 'TABLE D', 'TABLE1' ]); + }); + +}); + +async function movePage(page: RegExp, target: {before: RegExp}|{after: RegExp}) { + const targetReg = values(target)[0]; + await driver.withActions(actions => actions + .move({origin: driver.findContent('.test-treeview-itemHeader', page)}) + .move({origin: driver.findContent('.test-treeview-itemHeaderWrapper', page) + .find('.test-treeview-handle')}) + .press() + .move({origin: driver.findContent('.test-treeview-itemHeader', targetReg), + y: 'after' in target ? 1 : -1 + }) + .release()); +}