diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 394b8589..c8b2370b 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -45,7 +45,7 @@ jobs: run: VERBOSE=1 DEBUG=1 MOCHA_WEBDRIVER_HEADLESS=1 yarn run test:smoke - name: Run main tests - run: VERBOSE=1 DEBUG=1 MOCHA_WEBDRIVER_HEADLESS=1 yarn run test + run: MOCHA_WEBDRIVER_HEADLESS=1 yarn run test - name: Update candidate branch if: ${{ github.event_name == 'push' }} diff --git a/.gitignore b/.gitignore index b8fb846e..3f5275ca 100644 --- a/.gitignore +++ b/.gitignore @@ -4,6 +4,9 @@ /static/*.bundle.js.map /static/bundle.css /static/browser-check.js +/static/*.bundle.js.*.txt +/grist-sessions.db +/landing.db # Build helper files. /.build* diff --git a/README.md b/README.md index c9a5a18c..565dd0f2 100644 --- a/README.md +++ b/README.md @@ -28,6 +28,7 @@ Here are some specific feature highlights of Grist: - [References](https://support.getgrist.com/col-refs/#creating-a-new-reference-list-column) and reference lists, for cross-referencing records in other tables. - [Attachments](https://support.getgrist.com/col-types/#attachment-columns), to include media or document files in records. - Dates and times, toggles, and special numerics such as currency all have specialized editors and formatting options. + - [Conditional Formatting](https://support.getgrist.com/conditional-formatting/), letting you control the style of cells with formulas, to draw attention to important information. * Great for dashboards, visualizations, and data entry. - [Charts](https://support.getgrist.com/widget-chart/) for visualization. - [Summary tables](https://support.getgrist.com/summary-tables/) for summing and counting across groups. @@ -134,9 +135,12 @@ docker run --env GRIST_DEFAULT_EMAIL=my@email -p 8484:8484 -v $PWD/persist:/pers You can change your name in `Profile Settings` in the [User Menu](https://support.getgrist.com/glossary/#user-menu). -For multi-user operation, and/or if you wish to access Grist across the -public internet, you'll want to connect it to your own single sign-in service -[SAML](https://github.com/gristlabs/grist-core/blob/main/app/server/lib/SamlConfig.ts). + +For multi-user operation, or if you wish to access Grist across the +public internet, you'll want to connect it to your own single sign-in service. +There's a `docker-compose` template at https://community.getgrist.com/t/a-template-for-self-hosting-grist-with-traefik-and-docker-compose/856 +covering using Let's Encrypt for certificates and Google for sign-ins. +You can also use [SAML](https://github.com/gristlabs/grist-core/blob/main/app/server/lib/SamlConfig.ts). Grist has been tested with [Authentik](https://goauthentik.io/) and [Auth0](https://auth0.com/). ## Why free and open source software diff --git a/app/server/lib/ActiveDocImport.ts b/app/server/lib/ActiveDocImport.ts index 4329f007..90b0604f 100644 --- a/app/server/lib/ActiveDocImport.ts +++ b/app/server/lib/ActiveDocImport.ts @@ -607,8 +607,6 @@ export class ActiveDocImport { const srcColIds = srcCols.map(c => c.id as string); for (const {id, fields} of targetCols) { - if (fields.isFormula === true || fields.formula !== '') { continue; } - destCols.push({ colId: destTableId ? id as string : null, label: fields.label as string, diff --git a/package.json b/package.json index 5117134e..051194f3 100644 --- a/package.json +++ b/package.json @@ -12,7 +12,7 @@ "install:python3": "buildtools/prepare_python3.sh", "build:prod": "buildtools/build.sh", "start:prod": "sandbox/run.sh", - "test": "GRIST_SESSION_COOKIE=grist_test_cookie GRIST_TEST_LOGIN=1 TEST_SUPPORT_API_KEY=api_key_for_support TEST_CLEAN_DATABASE=true NODE_PATH=_build:_build/stubs:_build/ext mocha -g ${GREP_TEST:-''} _build/test/nbrowser/*.js _build/test/server/**/*.js _build/test/gen-server/**/*.js", + "test": "GRIST_SESSION_COOKIE=grist_test_cookie GRIST_TEST_LOGIN=1 TEST_SUPPORT_API_KEY=api_key_for_support TEST_CLEAN_DATABASE=true NODE_PATH=_build:_build/stubs:_build/ext mocha -g ${GREP_TESTS:-''} _build/test/nbrowser/*.js _build/test/server/**/*.js _build/test/gen-server/**/*.js", "test:server": "GRIST_SESSION_COOKIE=grist_test_cookie NODE_PATH=_build:_build/stubs:_build/ext mocha _build/test/server/**/*.js _build/test/gen-server/**/*.js", "test:smoke": "NODE_PATH=_build:_build/stubs:_build/ext mocha _build/test/nbrowser/Smoke.js", "test:docker": "./test/test_under_docker.sh", diff --git a/test/fixtures/docs/Ref-AC-Test.grist b/test/fixtures/docs/Ref-AC-Test.grist new file mode 100644 index 00000000..dfcab356 Binary files /dev/null and b/test/fixtures/docs/Ref-AC-Test.grist differ diff --git a/test/fixtures/docs/Ref-List-AC-Test.grist b/test/fixtures/docs/Ref-List-AC-Test.grist new file mode 100644 index 00000000..e1483567 Binary files /dev/null and b/test/fixtures/docs/Ref-List-AC-Test.grist differ diff --git a/test/nbrowser/ChoiceList.ts b/test/nbrowser/ChoiceList.ts new file mode 100644 index 00000000..8cbb434e --- /dev/null +++ b/test/nbrowser/ChoiceList.ts @@ -0,0 +1,961 @@ +import {assert, driver, Key, stackWrapFunc, WebElement} from 'mocha-webdriver'; +import * as gu from 'test/nbrowser/gristUtils'; +import {setupTestSuite} from 'test/nbrowser/testUtils'; + +const normalFont = { bold: false, underline: false, italic: false, strikethrough: false}; +const bold = true; +const underline = true; +const italic = true; +const strikethrough = true; + +function getEditorTokens() { + return driver.findAll('.cell_editor .test-tokenfield .test-tokenfield-token', el => el.getText()); +} + +function getEditorTokenStyles() { + return driver.findAll( + '.cell_editor .test-tokenfield .test-tokenfield-token', + async el => { + const classList = await el.getAttribute("class"); + return { + fillColor: await el.getCssValue('background-color'), + textColor: await el.getCssValue('color'), + boxShadow: await el.getCssValue('box-shadow'), + bold: classList.includes("font-bold"), + italic: classList.includes("font-italic"), + underline: classList.includes("font-underline"), + strikethrough: classList.includes("font-strikethrough"), + }; + } + ); +} + +function getCellTokens(cell: WebElement) { + return cell.getText(); +} + +function getCellTokenStyles(cell: WebElement) { + return cell.findAll( + '.test-choice-list-cell-token', + async el => { + const classList = await el.getAttribute("class"); + return { + fillColor: await el.getCssValue('background-color'), + textColor: await el.getCssValue('color'), + boxShadow: await el.getCssValue('box-shadow'), + bold: classList.includes("font-bold"), + italic: classList.includes("font-italic"), + underline: classList.includes("font-underline"), + strikethrough: classList.includes("font-strikethrough"), + }; + } + ); +} + +function getChoiceLabels() { + return driver.findAll('.test-right-panel .test-choice-list-entry-label', el => el.getText()); +} + +function getChoiceColors() { + return driver.findAll( + '.test-right-panel .test-choice-list-entry-color', + el => el.getCssValue('background-color') + ); +} + +function getEditModeChoiceLabels() { + return driver.findAll('.test-right-panel .test-tokenfield-token input', el => el.value()); +} + +function getEditModeFillColors() { + return driver.findAll( + '.test-right-panel .test-tokenfield-token .test-color-button', + el => el.getCssValue('background-color') + ); +} + +function getEditModeTextColors() { + return driver.findAll( + '.test-right-panel .test-tokenfield-token .test-color-button', + el => el.getCssValue('color') + ); +} + +function getEditModeFontOptions() { + return driver.findAll( + '.test-right-panel .test-tokenfield-token .test-color-button', + async el => { + const classes = await el.getAttribute("class"); + const options: any = {}; + if (classes.includes("font-bold")) { + options['bold'] = true; + } + if (classes.includes("font-underline")) { + options['underline'] = true; + } + if (classes.includes("font-italic")) { + options['italic'] = true; + } + if (classes.includes("font-strikethrough")) { + options['strikethrough'] = true; + } + return options; + } + ); +} + +function getEditorTokensIsInvalid() { + return driver.findAll('.cell_editor .test-tokenfield .test-tokenfield-token', el => el.matches('[class*=-invalid]')); +} + +function getEditorInput() { + return driver.find('.cell_editor .test-tokenfield .test-tokenfield-input'); +} + +async function editChoiceEntries() { + await driver.find(".test-choice-list-entry").click(); + await gu.waitAppFocus(false); +} + +async function renameEntry(from: string, to: string) { + await clickEntry(from); + await gu.sendKeys(to); + await gu.sendKeys(Key.ENTER); +} + + +async function clickEntry(label: string) { + const entry = await driver.findWait(`.test-choice-list-entry .test-token-label[value='${label}']`, 100); + await entry.click(); +} + +async function saveChoiceEntries() { + await driver.find(".test-choice-list-entry-save").click(); + await gu.waitForServer(); +} + +describe('ChoiceList', function() { + this.timeout(20000); + const cleanup = setupTestSuite(); + + const WHITE_FILL = 'rgba(255, 255, 255, 1)'; + const UNSET_FILL = WHITE_FILL; + const INVALID_FILL = WHITE_FILL; + const DEFAULT_FILL = 'rgba(232, 232, 232, 1)'; + const GREEN_FILL = 'rgba(225, 254, 222, 1)'; + const DARK_GREEN_FILL = 'rgba(18, 110, 14, 1)'; + const BLUE_FILL = 'rgba(204, 254, 254, 1)'; + const BLACK_FILL = 'rgba(0, 0, 0, 1)'; + const BLACK_TEXT = 'rgba(0, 0, 0, 1)'; + const WHITE_TEXT = 'rgba(255, 255, 255, 1)'; + const APRICOT_FILL = 'rgba(254, 204, 129, 1)'; + const APRICOT_TEXT = 'rgba(70, 13, 129, 1)'; + const DEFAULT_TEXT = BLACK_TEXT; + const INVALID_TEXT = BLACK_TEXT; + const VALID_CHOICE = { + boxShadow: 'none', + ...normalFont, + }; + const INVALID_CHOICE = { + fillColor: INVALID_FILL, + textColor: INVALID_TEXT, + boxShadow: 'rgb(208, 2, 27) 0px 0px 0px 1px inset', + ...normalFont, + }; + + afterEach(() => gu.checkForErrors()); + + it('should support basic editing', async () => { + const mainSession = await gu.session().teamSite.login(); + const api = mainSession.createHomeApi(); + const docId = await mainSession.tempNewDoc(cleanup, 'FormulaCounts', {load: true}); + + // Make a ChoiceList column and add some data. + await api.applyUserActions(docId, [ + ['ModifyColumn', 'Table1', 'B', { + type: 'ChoiceList', + widgetOptions: JSON.stringify({ + choices: ['Green', 'Blue', 'Black'], + choiceOptions: { + 'Green': { + fillColor: '#e1fede', + textColor: '#000000', + fontBold: true + }, + 'Blue': { + fillColor: '#ccfefe', + textColor: '#000000' + }, + 'Black': { + fillColor: '#000000', + textColor: '#ffffff' + } + } + }) + }], + ['BulkAddRecord', 'Table1', [null, null, null], {}], + ]); + + // Enter by typing into an empty cell: valid value, invalue value, then check the editor. + await gu.getCell({rowNum: 1, col: 'B'}).click(); + await driver.sendKeys('Gre', Key.ENTER); + await driver.sendKeys('fake', Key.ENTER); + assert.deepEqual(await getEditorTokens(), ['Green', 'fake']); + assert.deepEqual(await getEditorTokensIsInvalid(), [false, true]); + assert.deepEqual( + await getEditorTokenStyles(), + [ + {fillColor: GREEN_FILL, textColor: BLACK_TEXT, ...VALID_CHOICE, bold}, + INVALID_CHOICE, + ] + ); + + // Escape to cancel; check nothing got saved. + await driver.sendKeys(Key.ESCAPE); + await gu.waitForServer(); + assert.equal(await driver.find('.cell_editor').isPresent(), false); + assert.equal(await gu.getCell({rowNum: 1, col: 'B'}).getText(), ''); + + // Type invalid value, then select from dropdown valid + await gu.getCell({rowNum: 1, col: 'B'}).click(); + await driver.sendKeys('fake', Key.ENTER); + await getEditorInput().click(); + const blueChoice = await driver.findContent('.test-autocomplete li', /Blue/); + assert.equal( + await blueChoice.find('.test-choice-list-editor-item-label').getCssValue('background-color'), + BLUE_FILL + ); + assert.equal( + await blueChoice.find('.test-choice-list-editor-item-label').getCssValue('color'), + BLACK_TEXT + ); + await blueChoice.click(); + + // Type another valid, check what's in editor + await driver.sendKeys('black', Key.ENTER); + assert.deepEqual(await getEditorTokens(), ['fake', 'Blue', 'Black']); + assert.deepEqual(await getEditorTokensIsInvalid(), [true, false, false]); + assert.deepEqual( + await getEditorTokenStyles(), + [ + INVALID_CHOICE, + {fillColor: BLUE_FILL, textColor: BLACK_TEXT, ...VALID_CHOICE}, + {fillColor: BLACK_FILL, textColor: WHITE_TEXT, ...VALID_CHOICE} + ] + ); + + // Enter to save; check values got saved. + await driver.sendKeys(Key.ENTER); + await gu.waitForServer(); + assert.equal(await driver.find('.cell_editor').isPresent(), false); + assert.equal(await getCellTokens(await gu.getCell({rowNum: 1, col: 'B'})), 'fake\nBlue\nBlack'); + assert.deepEqual( + await getCellTokenStyles(await gu.getCell({rowNum: 1, col: 'B'})), + [ + INVALID_CHOICE, + {fillColor: BLUE_FILL, textColor: BLACK_TEXT, ...VALID_CHOICE}, + {fillColor: BLACK_FILL, textColor: WHITE_TEXT, ...VALID_CHOICE} + ] + ); + + // Enter to edit. Enter token, remove two tokens, with a key and with an x-click. + await gu.getCell({rowNum: 1, col: 'B'}).click(); + await driver.sendKeys(Key.ENTER); + assert.deepEqual(await getEditorTokens(), ['fake', 'Blue', 'Black']); + await driver.sendKeys('Gre', Key.TAB); + assert.deepEqual(await getEditorTokens(), ['fake', 'Blue', 'Black', 'Green']); + await driver.sendKeys(Key.LEFT, Key.LEFT, Key.BACK_SPACE); + assert.deepEqual(await getEditorTokens(), ['fake', 'Blue', 'Green']); + const tok1 = driver.findContent('.cell_editor .test-tokenfield .test-tokenfield-token', /fake/); + await tok1.mouseMove(); + await tok1.find('.test-tokenfield-delete').click(); + assert.deepEqual(await getEditorTokens(), ['Blue', 'Green']); + + // Enter to save; check values got saved. + await driver.sendKeys(Key.ENTER); + await gu.waitForServer(); + assert.equal(await driver.find('.cell_editor').isPresent(), false); + assert.equal(await getCellTokens(gu.getCell({rowNum: 1, col: 'B'})), 'Blue\nGreen'); + + // Start typing to replace content with a token; check values. + await gu.getCell({rowNum: 1, col: 'B'}).click(); + await driver.sendKeys('foo'); + assert.deepEqual(await getEditorTokens(), []); + assert.equal(await getEditorInput().value(), 'foo'); + await driver.sendKeys(Key.TAB); + assert.deepEqual(await getEditorTokens(), ['foo']); + + // Escape to cancel; check nothing got saved. + await driver.sendKeys(Key.ESCAPE); + await gu.waitForServer(); + assert.equal(await driver.find('.cell_editor').isPresent(), false); + assert.equal(await gu.getCell({rowNum: 1, col: 'B'}).getText(), 'Blue\nGreen'); + + // Double-click to open dropdown and select a token. + await driver.withActions(a => a.doubleClick(gu.getCell({rowNum: 1, col: 'B'}))); + await driver.findContent('.test-autocomplete li', /Black/).click(); + assert.deepEqual(await getEditorTokens(), ['Blue', 'Green', 'Black']); + + // Click away to save: new token should be added. + await gu.getCell({rowNum: 2, col: 'B'}).click(); + await gu.waitForServer(); + assert.equal(await driver.find('.cell_editor').isPresent(), false); + assert.equal(await gu.getCell({rowNum: 1, col: 'B'}).getText(), 'Blue\nGreen\nBlack'); + }); + + it('should be visible in formulas', async () => { + // Add a formula that returns tokens reversed + await gu.getCell({rowNum: 1, col: 'C'}).click(); + await gu.enterFormula('":".join($B)'); + + // Check value + assert.deepEqual(await gu.getVisibleGridCells({rowNums: [1, 2, 3], cols: ['B', 'C']}), [ + 'Blue\nGreen\nBlack', 'Blue:Green:Black', + '', '', + '', '', + ]); + + // Hit enter, click to delete a token, save. + await gu.getCell({rowNum: 1, col: 'B'}).click(); + await driver.sendKeys(Key.ENTER); + await driver.sendKeys(Key.BACK_SPACE); + await driver.sendKeys(Key.ENTER); + await gu.waitForServer(); + + // Check formula got updated + assert.deepEqual(await gu.getVisibleGridCells({rowNums: [1], cols: ['B', 'C']}), [ + 'Blue\nGreen', 'Blue:Green', + ]); + + // Type a couple new tokens. + await gu.getCell({rowNum: 1, col: 'B'}).click(); + await driver.sendKeys(Key.ENTER); + await driver.sendKeys('fake', Key.TAB, 'Bla', Key.TAB); + + // Enter to save; check formula got updated + await driver.sendKeys(Key.ENTER); + await gu.waitForServer(); + assert.deepEqual(await gu.getVisibleGridCells({rowNums: [1], cols: ['B', 'C']}), [ + 'Blue\nGreen\nfake\nBlack', 'Blue:Green:fake:Black', + ]); + + // Hit delete. ChoiceList cell and formula should clear. + await gu.getCell({rowNum: 1, col: 'B'}).click(); + await driver.sendKeys(Key.DELETE); + await gu.waitForServer(); + assert.deepEqual(await gu.getVisibleGridCells({rowNums: [1, 2, 3], cols: ['B', 'C']}), [ + '', '', + '', '', + '', '', + ]); + }); + + it('should allow adding new values', async () => { + // Check what choices are configured. + await gu.toggleSidePanel('right', 'open'); + await driver.find('.test-right-tab-field').click(); + assert.deepEqual(await getChoiceLabels(), ['Green', 'Blue', 'Black']); + assert.deepEqual( + await getChoiceColors(), + [GREEN_FILL, BLUE_FILL, BLACK_FILL] + ); + + // Select a token from autocomplete + const cell = gu.getCell({rowNum: 1, col: 'B'}); + assert.equal(await cell.getText(), ''); + await cell.click(); + await driver.sendKeys(Key.ENTER); + await driver.findContent('.test-autocomplete li', /Green/).click(); + assert.deepEqual(await getEditorTokens(), ['Green']); + + // Type token that's not in autocomplete + await driver.sendKeys("Orange"); + + // Enter should add invalid token. + await driver.sendKeys(Key.ENTER); + assert.deepEqual(await getEditorTokens(), ['Green', 'Orange']); + assert.deepEqual(await getEditorTokensIsInvalid(), [false, true]); + + // Type another token, and click the "+" button in autocomplete. New token should be valid. + await driver.sendKeys("Apricot"); + const newChoice = await driver.find('.test-autocomplete .test-choice-list-editor-new-item'); + assert.equal(await newChoice.getText(), 'Apricot'); + assert.equal( + await newChoice.find('.test-choice-list-editor-item-label').getCssValue('background-color'), + DEFAULT_FILL + ); + assert.equal( + await newChoice.find('.test-choice-list-editor-item-label').getCssValue('color'), + DEFAULT_TEXT + ); + await driver.find('.test-autocomplete .test-choice-list-editor-new-item').click(); + assert.deepEqual(await getEditorTokens(), ['Green', 'Orange', 'Apricot']); + assert.deepEqual(await getEditorTokensIsInvalid(), [false, true, false]); + assert.deepEqual( + await getEditorTokenStyles(), + [ + {fillColor: GREEN_FILL, textColor: BLACK_TEXT, ...VALID_CHOICE, bold}, + INVALID_CHOICE, + {fillColor: DEFAULT_FILL, textColor: DEFAULT_TEXT, ...VALID_CHOICE} + ] + ); + + // Save: check tokens + await driver.sendKeys(Key.ENTER); + await gu.waitForServer(); + assert.equal(await getCellTokens(gu.getCell({rowNum: 1, col: 'B'})), 'Green\nOrange\nApricot'); + assert.deepEqual( + await getCellTokenStyles(gu.getCell({rowNum: 1, col: 'B'})), + [ + {fillColor: GREEN_FILL, textColor: BLACK_TEXT, ...VALID_CHOICE, bold}, + INVALID_CHOICE, + {fillColor: DEFAULT_FILL, textColor: DEFAULT_TEXT, ...VALID_CHOICE} + ] + ); + + // New option should be listed in config. + assert.deepEqual(await getChoiceLabels(), ['Green', 'Blue', 'Black', 'Apricot']); + assert.deepEqual( + await getChoiceColors(), + [GREEN_FILL, BLUE_FILL, BLACK_FILL, UNSET_FILL] + ); + }); + + const convertColumn = stackWrapFunc(async function(typeRe: RegExp) { + await gu.setType(typeRe); + await gu.waitForServer(); + await driver.findContent('.type_transform_prompt button', /Apply/).click(); + await gu.waitForServer(); + }); + + it('should allow reasonable conversions between ChoiceList and other types', async function() { + await gu.enterGridRows({rowNum: 1, col: 'A'}, + [['Hello'], ['World'], [' Foo,Bar;Baz!,']]); + await testTextChoiceListConversions(); + }); + + it('should allow ChoiceList conversions for column used in summary', async function() { + // Add a widget with a summary on column A. + await gu.addNewSection(/Table/, /Table1/, {summarize: [/^A$/]}); + await testTextChoiceListConversions(); + await gu.undo(); + }); + + async function testTextChoiceListConversions() { + await gu.getCell({section: 'TABLE1', rowNum: 3, col: 'A'}).click(); + + // Convert this text column to ChoiceList. + await gu.toggleSidePanel('right', 'open'); + await driver.find('.test-right-tab-field').click(); + await convertColumn(/Choice List/); + + // Check that choices got populated. + await driver.find('.test-right-tab-field').click(); + assert.deepEqual(await getChoiceLabels(), ['Hello', 'World', 'Foo', 'Bar;Baz!']); + assert.deepEqual( + await getChoiceColors(), + [UNSET_FILL, UNSET_FILL, UNSET_FILL, UNSET_FILL] + ); + + // Check that the result contains the right tags. + assert.deepEqual(await gu.getVisibleGridCells({rowNums: [1, 2, 3], cols: ['A']}), [ + 'Hello', + 'World', + 'Foo\nBar;Baz!' + ]); + await gu.checkForErrors(); + + // Check that the result contains the right colors. + for (const rowNum of [1, 2]) { + assert.deepEqual( + await getCellTokenStyles(await gu.getCell({rowNum, col: 'A'})), + [{fillColor: DEFAULT_FILL, textColor: DEFAULT_TEXT, ...VALID_CHOICE}] + ); + } + assert.deepEqual( + await getCellTokenStyles(await gu.getCell({rowNum: 3, col: 'A'})), + [ + {fillColor: DEFAULT_FILL, textColor: DEFAULT_TEXT, ...VALID_CHOICE}, + {fillColor: DEFAULT_FILL, textColor: DEFAULT_TEXT, ...VALID_CHOICE}, + ] + ); + + // Open a cell to see the actual tags. + await gu.getCell({rowNum: 3, col: 'A'}).click(); + await driver.sendKeys(Key.ENTER); + assert.deepEqual(await getEditorTokens(), ['Foo', 'Bar;Baz!']); + assert.deepEqual(await getEditorTokensIsInvalid(), [false, false]); + assert.deepEqual( + await getEditorTokenStyles(), + [ + {fillColor: DEFAULT_FILL, textColor: DEFAULT_TEXT, ...VALID_CHOICE}, + {fillColor: DEFAULT_FILL, textColor: DEFAULT_TEXT, ...VALID_CHOICE} + ] + ); + await driver.sendKeys("hooray", Key.TAB, Key.ENTER); + await gu.waitForServer(); + await gu.checkForErrors(); + + // Convert back to text. + await convertColumn(/Text/); + + // Check that values turn into comma-separated values. + assert.deepEqual(await gu.getVisibleGridCells({rowNums: [1, 2, 3], cols: ['A']}), [ + 'Hello', + 'World', + 'Foo, Bar;Baz!, hooray' + ]); + + // Undo the cell change and both conversions (back to ChoiceList, back to Text), and check + // that UNDO also works correctly and without errors. + await gu.undo(3); + assert.deepEqual(await gu.getVisibleGridCells({rowNums: [1, 2, 3], cols: ['A']}), [ + 'Hello', + 'World', + ' Foo,Bar;Baz!,', // That's the text originally entered into this Text cell. + ]); + } + + it('should keep choices when converting between Choice and ChoiceList', async function() { + // Column B starts off as ChoiceList with the following choices. + await gu.getCell({rowNum: 1, col: 'B'}).click(); + await driver.find('.test-right-tab-field').click(); + assert.deepEqual(await getChoiceLabels(), ['Green', 'Blue', 'Black', 'Apricot']); + + // Add some more values to this columm. + await gu.getCell({rowNum: 2, col: 'B'}).click(); + await driver.sendKeys('Black', Key.ENTER, Key.ENTER); + await gu.waitForServer(); + await driver.sendKeys('Green', Key.ENTER, Key.ENTER); + await gu.waitForServer(); + assert.deepEqual(await gu.getVisibleGridCells({rowNums: [1, 2, 3], cols: ['B']}), [ + "Green\nOrange\nApricot", + "Black", + "Green", + ]); + + // Convert to Choice. Configured Choices should stay the same. + await convertColumn(/^Choice$/); + assert.deepEqual(await getChoiceLabels(), ['Green', 'Blue', 'Black', 'Apricot']); + assert.deepEqual(await getChoiceColors(), [GREEN_FILL, BLUE_FILL, BLACK_FILL, UNSET_FILL]); + + // Cells which contain multiple choices become CSVs. + assert.deepEqual(await gu.getVisibleGridCells({rowNums: [1, 2, 3], cols: ['B']}), [ + "Green, Orange, Apricot", + "Black", + "Green", + ]); + const cell1 = gu.getCell('B', 1); + assert.equal(await cell1.find('.field_clip').matches('.invalid'), false); + assert.equal(await cell1.find('.test-choice-token').getCssValue('background-color'), INVALID_FILL); + assert.equal(await cell1.find('.test-choice-token').getCssValue('color'), INVALID_TEXT); + const cell2 = gu.getCell('B', 2); + assert.equal(await cell2.find('.field_clip').matches('.invalid'), false); + assert.equal(await cell2.find('.test-choice-token').getCssValue('background-color'), BLACK_FILL); + assert.equal(await cell2.find('.test-choice-token').getCssValue('color'), WHITE_TEXT); + + // Convert back to ChoiceList. Choices should stay the same. + await convertColumn(/Choice List/); + assert.deepEqual(await getChoiceLabels(), ['Green', 'Blue', 'Black', 'Apricot']); + assert.deepEqual(await getChoiceColors(), [GREEN_FILL, BLUE_FILL, BLACK_FILL, UNSET_FILL]); + + // Cell and editor data should be restored too. + assert.deepEqual(await gu.getVisibleGridCells({rowNums: [1, 2, 3], cols: ['B']}), [ + "Green\nOrange\nApricot", + "Black", + "Green", + ]); + assert.deepEqual( + await getCellTokenStyles(await gu.getCell({rowNum: 1, col: 'B'})), + [ + {fillColor: GREEN_FILL, textColor: BLACK_TEXT, ...VALID_CHOICE, bold}, + INVALID_CHOICE, + {fillColor: DEFAULT_FILL, textColor: DEFAULT_TEXT, ...VALID_CHOICE}, + ] + ); + await gu.getCell({rowNum: 1, col: 'B'}).click(); + await driver.sendKeys(Key.ENTER); + assert.deepEqual(await getEditorTokens(), ['Green', 'Orange', 'Apricot']); + assert.deepEqual(await getEditorTokensIsInvalid(), [false, true, false]); + assert.deepEqual( + await getEditorTokenStyles(), + [ + {fillColor: GREEN_FILL, textColor: BLACK_TEXT, ...VALID_CHOICE, bold}, + INVALID_CHOICE, + {fillColor: DEFAULT_FILL, textColor: DEFAULT_TEXT, ...VALID_CHOICE}, + ] + ); + await driver.sendKeys(Key.ESCAPE); + }); + + it('should allow setting choice style', async function() { + // Open the choice editor. + await driver.find('.test-choice-list-entry').click(); + await gu.waitAppFocus(false); + + // Change 'Apricot' to a light shade of orange with purple text. + const [greenColorBtn, , , apricotColorBtn] = await driver + .findAll('.test-tokenfield .test-color-button'); + await apricotColorBtn.click(); + await gu.setColor(driver.find('.test-fill-input'), 'rgb(254, 204, 129)'); + await gu.setColor(driver.find('.test-text-input'), 'rgb(70, 13, 129)'); + await gu.setFont('bold', true); + await gu.setFont('italic', true); + await driver.sendKeys(Key.ENTER); + + // Change 'Green' to a darker shade with white text. + await greenColorBtn.click(); + await gu.setColor(driver.find('.test-fill-input'), 'rgb(18, 110, 14)'); + await gu.setColor(driver.find('.test-text-input'), 'rgb(255, 255, 255)'); + await gu.setFont('strikethrough', true); + await gu.setFont('underline', true); + await driver.sendKeys(Key.ENTER); + + // Check that the old colors are still being used in the grid + assert.deepEqual( + await getCellTokenStyles(await gu.getCell({rowNum: 1, col: 'B'})), + [ + {fillColor: GREEN_FILL, textColor: BLACK_TEXT, ...VALID_CHOICE, bold}, + INVALID_CHOICE, + {fillColor: DEFAULT_FILL, textColor: DEFAULT_TEXT, ...VALID_CHOICE} + ] + ); + assert.deepEqual( + await getCellTokenStyles(await gu.getCell({rowNum: 3, col: 'B'})), + [ + {fillColor: GREEN_FILL, textColor: BLACK_TEXT, ...VALID_CHOICE, bold} + ] + ); + + // Click save, and check that the new colors are now used in the grid + await driver.find('.test-choice-list-entry-save').click(); + await gu.waitForServer(); + assert.deepEqual( + await getCellTokenStyles(await gu.getCell({rowNum: 1, col: 'B'})), + [ + {fillColor: DARK_GREEN_FILL, textColor: WHITE_TEXT, ...VALID_CHOICE, strikethrough, underline, bold}, + INVALID_CHOICE, + {fillColor: APRICOT_FILL, textColor: APRICOT_TEXT, ...VALID_CHOICE, bold, italic} + ] + ); + assert.deepEqual( + await getCellTokenStyles(await gu.getCell({rowNum: 3, col: 'B'})), + [ + {fillColor: DARK_GREEN_FILL, textColor: WHITE_TEXT, ...VALID_CHOICE, + strikethrough, underline, bold} + ] + ); + + // Open the editor again to make another change. + await driver.find('.test-choice-list-entry').click(); + await gu.waitAppFocus(false); + + // Delete 'Apricot', then cancel the change by pressing Escape. + await gu.sendKeys(Key.BACK_SPACE); + assert.deepEqual(await getEditModeChoiceLabels(), ['Green', 'Blue', 'Black']); + await gu.sendKeys(Key.ESCAPE); + + // Check that 'Apricot' is still there and the change wasn't saved. + assert.deepEqual(await getChoiceLabels(), ['Green', 'Blue', 'Black', 'Apricot']); + }); + + it('should support undo/redo shortcuts in the choice config editor', async function() { + // Open the choice editor. + await driver.find('.test-choice-list-entry').click(); + await gu.waitAppFocus(false); + + // Add a few choices. + await driver.sendKeys('Foo', Key.ENTER, 'Bar', Key.ENTER, 'Baz', Key.ENTER); + assert.deepEqual(await getEditModeChoiceLabels(), ['Green', 'Blue', 'Black', 'Apricot', 'Foo', 'Bar', 'Baz']); + + // Undo, verifying the contents of the choice config editor are correct after each invocation. + const modKey = await gu.modKey(); + await gu.sendKeys(Key.chord(modKey, 'z')); + assert.deepEqual(await getEditModeChoiceLabels(), ['Green', 'Blue', 'Black', 'Apricot', 'Foo', 'Bar']); + await gu.sendKeys(Key.chord(modKey, 'z')); + assert.deepEqual(await getEditModeChoiceLabels(), ['Green', 'Blue', 'Black', 'Apricot', 'Foo']); + await gu.sendKeys(Key.chord(modKey, 'z')); + assert.deepEqual(await getEditModeChoiceLabels(), ['Green', 'Blue', 'Black', 'Apricot']); + + // Redo, then undo, verifying at each step. + await gu.sendKeys(Key.chord(Key.CONTROL, 'y')); + assert.deepEqual(await getEditModeChoiceLabels(), ['Green', 'Blue', 'Black', 'Apricot', 'Foo']); + await gu.sendKeys(Key.chord(modKey, 'z')); + assert.deepEqual(await getEditModeChoiceLabels(), ['Green', 'Blue', 'Black', 'Apricot']); + + // Change the color of 'Apricot' to white with black text, and modify font options + const [, , , apricotColorBtn] = await driver + .findAll('.test-tokenfield .test-color-button'); + await apricotColorBtn.click(); + await gu.setColor(driver.find('.test-fill-input'), 'rgb(255, 255, 255)'); + await gu.setColor(driver.find('.test-text-input'), 'rgb(0, 0, 0)'); + await gu.setFont('bold', false); + await gu.setFont('italic', false); + await gu.setFont('underline', true); + + await driver.sendKeys(Key.ENTER); + assert.deepEqual(await getEditModeFillColors(), [DARK_GREEN_FILL, BLUE_FILL, BLACK_FILL, WHITE_FILL]); + assert.deepEqual(await getEditModeTextColors(), [WHITE_TEXT, BLACK_TEXT, WHITE_TEXT, BLACK_TEXT]); + assert.deepEqual(await getEditModeFontOptions(), [{bold, underline, strikethrough}, {}, {}, {underline}]); + + // Undo, then re-do, verifying after each invocation + await driver.find('.test-choice-list-entry .test-tokenfield .test-tokenfield-input').click(); + await gu.sendKeys(Key.chord(modKey, 'z')); + assert.deepEqual(await getEditModeFillColors(), [DARK_GREEN_FILL, BLUE_FILL, BLACK_FILL, APRICOT_FILL]); + assert.deepEqual(await getEditModeTextColors(), [WHITE_TEXT, BLACK_TEXT, WHITE_TEXT, APRICOT_TEXT]); + assert.deepEqual(await getEditModeFontOptions(), [{bold, underline, strikethrough}, {}, {}, {bold, italic}]); + await gu.sendKeys(Key.chord(Key.CONTROL, 'y')); + assert.deepEqual(await getEditModeFillColors(), [DARK_GREEN_FILL, BLUE_FILL, BLACK_FILL, WHITE_FILL]); + assert.deepEqual(await getEditModeTextColors(), [WHITE_TEXT, BLACK_TEXT, WHITE_TEXT, BLACK_TEXT]); + assert.deepEqual(await getEditModeFontOptions(), [{bold, underline, strikethrough}, {}, {}, {underline}]); + }); + + it('should support rich copy/paste in the choice config editor', async function() { + // Remove all choices + const modKey = await gu.modKey(); + await gu.sendKeys(Key.chord(modKey, 'a'), Key.BACK_SPACE); + + // Add a few new choices + await gu.sendKeys('Choice 1', Key.ENTER, 'Choice 2', Key.ENTER, 'Choice 3', Key.ENTER); + + // Copy all the choices + await gu.sendKeys(Key.chord(modKey, 'a'), await gu.copyKey()); + + // Delete all the choices, then paste them back and verify no data was lost. + await driver.sendKeys(Key.BACK_SPACE); + assert.deepEqual(await getEditModeChoiceLabels(), []); + await gu.sendKeys(await gu.pasteKey()); + assert.deepEqual(await getEditModeChoiceLabels(), ['Choice 1', 'Choice 2', 'Choice 3']); + + // In Jenkins, clipboard contents are pasted from the system clipboard, which only copies + // choices as newline-separated labels. For this reason, we can't check that the color + // information also got pasted, because the data is stored elsewhere. In actual use, the + // workflow above would copy all the choice data as well, and use it for pasting in the editor. + }); + + it('should add a new element on a fresh ChoiceList column', async function() { + await gu.addColumn("ChoiceList"); + await gu.setType(gu.exactMatch("Choice List")); + const cell = await gu.getCell("ChoiceList", 1); + await cell.click(); + await gu.sendKeys("foo"); + const plus = await driver.findWait(".test-choice-list-editor-new-item", 100); + await plus.click(); + await gu.sendKeys(Key.ENTER); + await gu.waitForServer(); + assert.equal(await cell.getText(), "foo"); + }); + + it('should add a new element on a fresh Choice column', async function() { + await gu.addColumn("Choice"); + await gu.setType(gu.exactMatch("Choice")); + const cell = await gu.getCell("Choice", 1); + await cell.click(); + await gu.sendKeys("foo"); + const plus = await driver.findWait(".test-choice-editor-new-item", 100); + await plus.click(); + await gu.waitForServer(); + assert.equal(await cell.getText(), "foo"); + }); + + for (const columnName of ["ChoiceList", "Choice"]) { + it(`should allow renaming tokens on ${columnName} column`, gu.revertChanges(async function () { + // Helper that converts ChoiceList to choice-list + const editorDashedName = columnName.toLowerCase().replace(/list/, "-list"); + // Add two new options: one, two. + await gu.getCell(columnName, 2).click(); + await gu.sendKeys("one"); + await driver.findWait(`.test-${editorDashedName}-editor-new-item`, 300).click(); + if (columnName === "ChoiceList") { + await gu.sendKeys(Key.ENTER); + } + await gu.waitForServer(); + await gu.getCell(columnName, 3).click(); + await gu.sendKeys("two"); + await driver.findWait(`.test-${editorDashedName}-editor-new-item`, 300).click(); + if (columnName === "ChoiceList") { + await gu.sendKeys(Key.ENTER); + } + await gu.waitForServer(); + + // Make sure right panel is open and has right focus. + await gu.toggleSidePanel("right", "open"); + await driver.find(".test-right-tab-field").click(); + // Rename one to three. + await editChoiceEntries(); + await renameEntry("one", "three"); + await saveChoiceEntries(); + assert.deepEqual(await gu.getVisibleGridCells({ rowNums: [1, 2, 3], cols: [columnName] }), [ + "foo", + "three", + "two", + ]); + + // Rename foo to bar, two to four, and three to eight, press undo 3 times + // and make sure nothing changes. + await editChoiceEntries(); + await renameEntry("foo", "bar"); + await renameEntry("three", "eight"); + await renameEntry("two", "four"); + assert.deepEqual(await getEditModeChoiceLabels(), ["bar", "eight", "four"]); + const undoKey = Key.chord(await gu.modKey(), "z"); + await gu.sendKeys(undoKey); + await gu.sendKeys(undoKey); + await gu.sendKeys(undoKey); + assert.deepEqual(await getEditModeChoiceLabels(), ["foo", "three", "two"]); + + // Make sure we can copy and paste without adding new item + await clickEntry('foo'); + await gu.sendKeys(await gu.cutKey()); + await gu.sendKeys(await gu.pasteKey()); + await gu.sendKeys(await gu.pasteKey()); + await gu.sendKeys(Key.ENTER); + await clickEntry('three'); + await gu.sendKeys(await gu.copyKey()); + await clickEntry('two'); + await gu.sendKeys(Key.ARROW_RIGHT); + await gu.sendKeys(await gu.pasteKey()); + await gu.sendKeys(Key.ENTER); + assert.deepEqual(await getEditModeChoiceLabels(), ["foofoo", "three", "twothree"]); + await saveChoiceEntries(); + assert.deepEqual(await gu.getVisibleGridCells({ rowNums: [1, 2, 3], cols: [columnName] }), [ + "foofoo", + "three", + "twothree", + ]); + + // Rename to bar, four and eight and do the change. + await editChoiceEntries(); + await renameEntry("foofoo", "bar"); + await renameEntry("twothree", "four"); + await renameEntry("three", "eight"); + await saveChoiceEntries(); + assert.deepEqual(await gu.getVisibleGridCells({ rowNums: [1, 2, 3], cols: [columnName] }), [ + "bar", + "eight", + "four", + ]); + + // Add color to bar, save it, change to foo and make sure the color is still there. + await editChoiceEntries(); + const [barColor] = await driver.findAll(".test-tokenfield .test-color-button"); + await barColor.click(); + await gu.setColor(driver.find(".test-text-input"), "rgb(70, 13, 129)"); + await driver.sendKeys(Key.ENTER); + await renameEntry("bar", "foo"); + await saveChoiceEntries(); + await editChoiceEntries(); + const [fooColorText] = await getEditModeTextColors(); + assert.equal(fooColorText, "rgba(70, 13, 129, 1)"); + + // Start renaming, but cancel out of the editor with two presses of the Escape key; + // a previous bug caused focus to be lost after the first Escape, making it impossible + // to close the editor with a subsequent press of Escape. + await editChoiceEntries(); + await clickEntry("foo"); + await gu.sendKeys("food"); + await gu.sendKeys(Key.ESCAPE, Key.ESCAPE); + assert.isFalse(await driver.find(".test-choice-list-entry-save").isPresent()); + + }, + // Test if the column is reverted to state before the test + () => gu.getVisibleGridCells({rowNums: [1, 2, 3], cols: [columnName]}))); + } + + it('should allow renaming multiple tokens on ChoiceList', gu.revertChanges(async function() { + // Work on ChoiceList column, add one new option "one" + await gu.getCell("ChoiceList", 2).click(); + await gu.sendKeys("one"); + await driver.findWait(`.test-choice-list-editor-new-item`, 300).click(); + await gu.sendKeys(Key.ENTER); + await gu.waitForServer(); + await gu.getCell("ChoiceList", 3).click(); + await gu.sendKeys("one", Key.ENTER, "foo", Key.ENTER); + await gu.waitForServer(); + + // Make sure right panel is open and has right focus. + await gu.toggleSidePanel('right', 'open'); + await driver.find('.test-right-tab-field').click(); + + // rename one to three + await editChoiceEntries(); + await renameEntry("one", "three"); + await renameEntry("foo", "four"); + await saveChoiceEntries(); + assert.deepEqual(await gu.getVisibleGridCells({rowNums: [1, 2, 3], cols: ['ChoiceList']}), [ + 'four', + 'three', + 'three\nfour', + ]); + }, + // Test if the column is reverted to state before the test + () => gu.getVisibleGridCells({rowNums: [1, 2, 3], cols: ['ChoiceList']}))); + + it('should rename saved filters', gu.revertChanges(async function() { + // Make sure right panel is open and has focus. + await gu.toggleSidePanel('right', 'open'); + await driver.find('.test-right-tab-field').click(); + + // Work on ChoiceList column, add two new options: one, two + await gu.getCell("ChoiceList", 2).click(); + await gu.sendKeys("one"); + await driver.findWait(`.test-choice-list-editor-new-item`, 300).click(); + await gu.sendKeys(Key.ENTER); + await gu.getCell("ChoiceList", 3).click(); + await gu.sendKeys("one", Key.ENTER, "foo", Key.ENTER, Key.ENTER); + await gu.waitForServer(); + // Make sure column looks like this: + assert.deepEqual(await gu.getVisibleGridCells({rowNums: [1, 2, 3, 4], cols: ['ChoiceList']}), [ + 'foo', + 'one', + 'one\nfoo', + '' // add row + ]); + + // Filter by single value and save. + await gu.filterBy('ChoiceList', true, [/one/]); + + // Duplicate page, to make sure filters are also renamed in a new section. + await gu.openPageMenu('Table1'); + await driver.find('.test-docpage-duplicate').click(); + await driver.find('.test-modal-confirm').click(); + await driver.findContentWait('.test-docpage-label', /copy/, 2000); + await gu.waitForServer(); + + // Go back to Table1 + await gu.getPageItem('Table1').click(); + // Make sure grid is filtered + assert.deepEqual(await gu.getVisibleGridCells({rowNums: [1, 2, 3], cols: ['ChoiceList']}), [ + 'one', + 'one\nfoo', + '' // new row + ]); + // Rename one to five, foo to bar + await editChoiceEntries(); + await renameEntry("one", "five"); + await renameEntry("foo", "bar"); + await saveChoiceEntries(); + // Make sure that there are still two records - filter should be changed to new values. + assert.deepEqual(await gu.getVisibleGridCells({rowNums: [1, 2, 3], cols: ['ChoiceList']}), [ + 'five', + 'five\nbar', + '' // new row + ]); + // Make sure that it also renamed filters in diffrent section. + await gu.getPageItem('Table1 (copy)').click(); + assert.deepEqual(await gu.getVisibleGridCells({rowNums: [1, 2, 3], cols: ['ChoiceList']}), [ + 'five', + 'five\nbar', + '' // new row + ]); + // Go back to previous names, filter still should work. + await gu.undo(); + assert.deepEqual(await gu.getVisibleGridCells({rowNums: [1, 2, 3], cols: ['ChoiceList']}), [ + 'one', + 'one\nfoo', + '' // new row + ]); + }, + // Test if the column is reverted to state before the test + () => gu.getVisibleGridCells({rowNums: [1, 2, 3], cols: ['ChoiceList']}))); +}); diff --git a/test/nbrowser/ReferenceColumns.ts b/test/nbrowser/ReferenceColumns.ts new file mode 100644 index 00000000..5f9d09ca --- /dev/null +++ b/test/nbrowser/ReferenceColumns.ts @@ -0,0 +1,569 @@ +import {assert, driver, Key, stackWrapFunc} from 'mocha-webdriver'; +import * as gu from 'test/nbrowser/gristUtils'; +import {Session} from 'test/nbrowser/gristUtils'; +import {server, setupTestSuite} from 'test/nbrowser/testUtils'; + +describe('ReferenceColumns', function() { + this.timeout(20000); + setupTestSuite(); + let session: Session; + const cleanup = setupTestSuite({team: true}); + + describe('rendering', function() { + before(async function() { + session = await gu.session().teamSite.login(); + await session.tempDoc(cleanup, 'Favorite_Films.grist'); + + await gu.toggleSidePanel('right'); + await driver.find('.test-config-data').click(); + }); + + it('should render Row ID values as TableId[RowId]', async function() { + await driver.find('.test-right-tab-field').click(); + await driver.find('.mod-add-column').click(); + await gu.waitForServer(); + await gu.setType(/Reference/); + await gu.waitForServer(); + await gu.enterGridRows({col: 3, rowNum: 1}, [['1'], ['2'], ['3'], ['4']]); + assert.deepEqual(await gu.getVisibleGridCells(3, [1, 2, 3, 4, 5, 6]), + ['Films[1]', 'Films[2]', 'Films[3]', 'Films[4]', '', '']); + await driver.find('.test-fbuilder-ref-table-select').click(); + await driver.findContent('.test-select-row', /Friends/).click(); + await gu.waitForServer(); + + // These are now invalid cells containing AltText such as 'Films[1]' + // We don't simply convert Films[1] -> Friends[1] + assert.deepEqual(await gu.getVisibleGridCells(3, [1, 2, 3, 4, 5, 6]), + ['Films[1]', 'Films[2]', 'Films[3]', 'Films[4]', '', '']); + + await driver.find('.test-type-transform-apply').click(); + await gu.waitForServer(); + await driver.find('.test-fbuilder-ref-col-select').click(); + await driver.findContent('.test-select-row', /Name/).click(); + await gu.waitForServer(); + assert.deepEqual(await gu.getVisibleGridCells(3, [1, 2, 3, 4, 5, 6]), + ['Films[1]', 'Films[2]', 'Films[3]', 'Films[4]', '', '']); + await gu.getCell(3, 5).click(); + await driver.sendKeys('Roger'); + await driver.sendKeys(Key.ENTER); + await gu.waitForServer(); + + // 'Roger' is an actual reference + assert.deepEqual(await gu.getVisibleGridCells(3, [1, 2, 3, 4, 5, 6]), + ['Films[1]', 'Films[2]', 'Films[3]', 'Films[4]', 'Roger', '']); + + await driver.find('.test-fbuilder-ref-col-select').click(); + await driver.findContent('.test-select-row', /Row ID/).click(); + await gu.waitForServer(); + + // 'Friends[1]' is an actual reference, the rest are invalid + assert.deepEqual(await gu.getVisibleGridCells(3, [1, 2, 3, 4, 5, 6]), + ['Films[1]', 'Films[2]', 'Films[3]', 'Films[4]', 'Friends[1]', '']); + + await driver.find('.test-fbuilder-ref-col-select').click(); + await driver.findContent('.test-select-row', /Name/).click(); + await gu.waitForServer(); + assert.deepEqual(await gu.getVisibleGridCells(3, [1, 2, 3, 4, 5, 6]), + ['Films[1]', 'Films[2]', 'Films[3]', 'Films[4]', 'Roger', '']); + + await gu.undo(); + }); + + it('should allow entering numeric id before target table is loaded', async function() { + if (server.isExternalServer()) { + this.skip(); + } + // Refresh the document. + await driver.navigate().refresh(); + await gu.waitForDocToLoad(); + + // Now pause the server + const cell = gu.getCell({col: 'A', rowNum: 1}); + await server.pauseUntil(async () => { + assert.equal(await cell.getText(), 'Films[1]'); + await cell.click(); + await gu.sendKeys('5'); + // Check that the autocomplete has no items yet. + assert.isEmpty(await driver.findAll('.test-autocomplete .test-ref-editor-new-item')); + await gu.sendKeys(Key.ENTER); + }); + await gu.waitForServer(); + assert.equal(await cell.getText(), 'Friends[5]'); + + await gu.undo(); + assert.equal(await cell.getText(), 'Films[1]'); + + // Once server is responsive, a valid value should not offer a "new item". + await cell.click(); + await gu.sendKeys('5'); + await driver.findWait('.test-ref-editor-item', 500); + assert.isFalse(await driver.find('.test-ref-editor-new-item').isPresent()); + await gu.sendKeys(Key.ENTER); + await gu.waitForServer(); + assert.equal(await cell.getText(), 'Friends[5]'); + }); + + it(`should show '[Blank]' if the referenced item is blank`, async function() { + // Open the All page. + await driver.findContentWait('.test-treeview-itemHeader', /All/, 2000).click(); + await gu.waitForDocToLoad(); + + // Clear the cells in Films record containing Avatar and Alien. + await gu.getCell('Title', 3, 'Films record').doClick(); + await gu.sendKeys(Key.BACK_SPACE); + await gu.waitForServer(); + await gu.sendKeys(Key.ARROW_DOWN, Key.BACK_SPACE); + await gu.waitForServer(); + + // Check that all references to Avatar and Alien now show '[Blank]'. + assert.deepEqual( + await gu.getVisibleGridCells('Favorite Film', [1, 2, 3, 4, 5, 6], 'Friends record'), + [ + 'Forrest Gump', + 'Toy Story', + '[Blank]', + 'The Dark Knight', + 'Forrest Gump', + '[Blank]' + ] + ); + + // Check that '[Blank]' is not shown when the reference editor is open. + await gu.getCell('Favorite Film', 3, 'Friends record').doClick(); + await gu.sendKeys(Key.ENTER); + assert.equal(await driver.find('.celleditor_text_editor').value(), ''); + await gu.sendKeys(Key.ESCAPE); + + // Undo (twice), and check that it shows Avatar and Alien again. + await gu.undo(2); + assert.deepEqual( + await gu.getVisibleGridCells('Favorite Film', [1, 2, 3, 4, 5, 6], 'Friends record'), + [ + 'Forrest Gump', + 'Toy Story', + 'Avatar', + 'The Dark Knight', + 'Forrest Gump', + 'Alien' + ] + ); + }); + }); + + describe('autocomplete', function() { + const getACOptions = stackWrapFunc(async (limit?: number) => { + await driver.findWait('.test-ref-editor-item', 1000); + return (await driver.findAll('.test-ref-editor-item', el => el.getText())).slice(0, limit); + }); + + before(async function() { + await session.tempDoc(cleanup, 'Ref-AC-Test.grist'); + await gu.toggleSidePanel('right', 'close'); + }); + + it('should open to correct item selected, and leave it unchanged on Enter', async function() { + const checkRefCell = stackWrapFunc(async (col: string, rowNum: number, expValue: string) => { + // Click cell and open for editing. + const cell = await gu.getCell({section: 'References', col, rowNum}).doClick(); + assert.equal(await cell.getText(), expValue); + await driver.sendKeys(Key.ENTER); + // Wait for expected value to appear in the list; check that it's selected. + const match = await driver.findContentWait('.test-ref-editor-item', expValue, 1000); + assert.equal(await match.matches('.selected'), true); + // Save the value. + await driver.sendKeys(Key.ENTER); + await gu.waitForServer(); + assert.equal(await cell.getText(), expValue); + // Assert that the undo is disabled, i.e. no action was generated. + assert.equal(await driver.find('.test-undo').matches('[class*=-disabled]'), true); + }); + await checkRefCell('Color', 1, 'Dark Slate Blue'); + await checkRefCell('ColorCode', 2, '#808080'); + await checkRefCell('XNum', 3, '2019-11-05'); + await checkRefCell('School', 1, 'TECHNOLOGY, ARTS AND SCIENCES STUDIO'); + }); + + it('should render first items when opening empty cell', async function() { + await driver.sendKeys(Key.HOME); + + let cell = await gu.getCell({section: 'References', col: 'Color', rowNum: 4}).doClick(); + assert.equal(await cell.getText(), ''); + await driver.sendKeys(Key.ENTER); + // Check the first few items. + assert.deepEqual(await getACOptions(3), ["Alice Blue", "Añil", "Aqua"]); + // No item is selected. + assert.equal(await driver.find('.test-ref-editor-item.selected').isPresent(), false); + await driver.sendKeys(Key.ESCAPE); + + cell = await gu.getCell({section: 'References', col: 'School', rowNum: 6}).doClick(); + assert.equal(await cell.getText(), ''); + await driver.sendKeys(Key.ENTER); + // Check the first few items; should be sorted alphabetically. + assert.deepEqual(await getACOptions(3), + ["2 SCHOOL", "4 SCHOOL", "47 AMER SIGN LANG & ENG LOWER "]); + // No item is selected. + assert.equal(await driver.find('.test-ref-editor-item.selected').isPresent(), false); + await driver.sendKeys(Key.ESCAPE); + }); + + it('should save correct item on click', async function() { + await driver.sendKeys(Key.HOME); + + // Edit a cell by double-clicking. + let cell = await gu.getCell({section: 'References', col: 'Color', rowNum: 2}).doClick(); + await driver.withActions(a => a.doubleClick(cell)); + assert.equal(await driver.findWait('.test-ref-editor-item.selected', 1000).getText(), 'Red'); + + // Scroll to another item and click it. + let item = driver.findContent('.test-ref-editor-item', 'Rosy Brown'); + await gu.scrollIntoView(item); + await item.click(); + + // It should get saved; and undo should restore the previous value. + await gu.waitForServer(); + assert.equal(await cell.getText(), 'Rosy Brown'); + await gu.undo(); + assert.equal(await cell.getText(), 'Red'); + + // Edit another cell by starting to type. + cell = await gu.getCell({section: 'References', col: 'Color', rowNum: 4}).doClick(); + await driver.sendKeys("gr"); + await driver.findWait('.test-ref-editor-item', 1000); + item = driver.findContent('.test-ref-editor-item', 'Medium Sea Green'); + await gu.scrollIntoView(item); + await item.click(); + + // It should get saved; and undo should restore the previous value. + await gu.waitForServer(); + assert.equal(await cell.getText(), 'Medium Sea Green'); + await gu.undo(); + assert.equal(await cell.getText(), ''); + }); + + it('should save correct item after selecting with arrow keys', async function() { + // Same as the previous test, but instead of clicking items, select item using arrow keys. + + // Edit a cell by double-clicking. + let cell = await gu.getCell({section: 'References', col: 'Color', rowNum: 2}).doClick(); + await driver.withActions(a => a.doubleClick(cell)); + assert.equal(await driver.findWait('.test-ref-editor-item.selected', 1000).getText(), 'Red'); + + // Move to another item and hit Enter + await driver.sendKeys(Key.DOWN, Key.DOWN, Key.DOWN, Key.DOWN, Key.DOWN); + assert.equal(await driver.findWait('.test-ref-editor-item.selected', 1000).getText(), 'Pale Violet Red'); + await driver.sendKeys(Key.ENTER); + + // It should get saved; and undo should restore the previous value. + await gu.waitForServer(); + assert.equal(await cell.getText(), 'Pale Violet Red'); + await gu.undo(); + assert.equal(await cell.getText(), 'Red'); + + // Edit another cell by starting to type. + cell = await gu.getCell({section: 'References', col: 'Color', rowNum: 4}).doClick(); + await driver.sendKeys("gr"); + await driver.findWait('.test-ref-editor-item', 1000); + await driver.sendKeys(Key.UP, Key.UP, Key.UP, Key.UP, Key.UP); + assert.equal(await driver.findWait('.test-ref-editor-item.selected', 1000).getText(), 'Chocolate'); + await driver.sendKeys(Key.ENTER); + + // It should get saved; and undo should restore the previous value. + await gu.waitForServer(); + assert.equal(await cell.getText(), 'Chocolate'); + await gu.undo(); + assert.equal(await cell.getText(), ''); + }); + + it('should return to text-as-typed when nothing is selected', async function() { + const cell = await gu.getCell({section: 'References', col: 'Color', rowNum: 2}).doClick(); + await driver.sendKeys("da"); + assert.deepEqual(await getACOptions(2), ["Dark Blue", "Dark Cyan"]); + + // Check that the first item is highlighted by default. + assert.equal(await driver.find('.celleditor_text_editor').value(), 'da'); + assert.equal(await driver.find('.test-ref-editor-item.selected').getText(), 'Dark Blue'); + + // Select second item. Both the textbox and the dropdown show the selection. + await driver.sendKeys(Key.DOWN); + assert.equal(await driver.find('.celleditor_text_editor').value(), 'Dark Cyan'); + assert.equal(await driver.find('.test-ref-editor-item.selected').getText(), 'Dark Cyan'); + + // Move back to no-selection state. + await driver.sendKeys(Key.UP, Key.UP); + assert.equal(await driver.find('.celleditor_text_editor').value(), 'da'); + assert.equal(await driver.find('.test-ref-editor-item.selected').isPresent(), false); + + // Mouse over an item. + await driver.findContent('.test-ref-editor-item', /Dark Gray/).mouseMove(); + assert.equal(await driver.find('.celleditor_text_editor').value(), 'Dark Gray'); + assert.equal(await driver.find('.test-ref-editor-item.selected').getText(), 'Dark Gray'); + + // Mouse back out of the dropdown + await driver.find('.celleditor_text_editor').mouseMove(); + assert.equal(await driver.find('.celleditor_text_editor').value(), 'da'); + assert.equal(await driver.find('.test-ref-editor-item.selected').isPresent(), false); + + // Click away to save the typed-in text. + await gu.getCell({section: 'References', col: 'Color', rowNum: 1}).doClick(); + await gu.waitForServer(); + assert.equal(await cell.getText(), "da"); + assert.equal(await cell.find('.field_clip').matches('.invalid'), true); + + await gu.undo(); + assert.equal(await cell.getText(), "Red"); + assert.equal(await cell.find('.field_clip').matches('.invalid'), false); + }); + + it('should save text as typed when nothing is selected', async function() { + const cell = await gu.getCell({section: 'References', col: 'Color', rowNum: 1}).doClick(); + await driver.sendKeys("lavender ", Key.ENTER); + await gu.waitForServer(); + assert.equal(await cell.getText(), "Lavender"); + await gu.undo(); + assert.equal(await cell.getText(), "Dark Slate Blue"); + }); + + it('should offer an add-new option when no good match', async function() { + const cell = await gu.getCell({section: 'References', col: 'Color', rowNum: 2}).doClick(); + await driver.sendKeys("pinkish"); + // There are inexact matches. + assert.deepEqual(await getACOptions(3), + ["Pink", "Deep Pink", "Hot Pink"]); + // Nothing is selected, and the "add new" item is present. + assert.equal(await driver.find('.test-ref-editor-item.selected').isPresent(), false); + assert.equal(await driver.find('.test-ref-editor-new-item').getText(), "pinkish"); + + // Click the "add new" item. The new value should be saved, and should not appear invalid. + await driver.find('.test-ref-editor-new-item').click(); + await gu.waitForServer(); + assert.equal(await cell.getText(), "pinkish"); + assert.equal(await cell.find('.field_clip').matches('.invalid'), false); + + // Requires 2 undos, because adding the "pinkish" record is a separate action. TODO these + // actions should be bundled. + await gu.undo(2); + assert.equal(await cell.getText(), "Red"); + }); + + it('should offer an add-new option when opening alt-text', async function() { + const cell = await gu.getCell({section: 'References', col: 'Color', rowNum: 2}).doClick(); + + // Enter and invalid value and save without clicking "add new". + await driver.sendKeys("super pink", Key.ENTER); + + // It should be saved but appear invalid (as alt-text). + await gu.waitForServer(); + assert.equal(await cell.getText(), "super pink"); + assert.equal(await cell.find('.field_clip').matches('.invalid'), true); + + // Open the cell again. The "Add New" option should be there. + await driver.withActions(a => a.doubleClick(cell)); + assert.equal(await driver.find('.test-ref-editor-new-item').getText(), "super pink"); + assert.equal(await driver.find('.test-ref-editor-item.selected').isPresent(), false); + + // Select "add new" (this time with arrow keys), and save. + await driver.sendKeys(Key.UP); + assert.equal(await driver.find('.test-ref-editor-new-item').matches('.selected'), true); + await driver.sendKeys(Key.ENTER); + + // Once "add new" is clicked, the "super pink" no longer appears as invalid. + await gu.waitForServer(); + assert.equal(await cell.getText(), "super pink"); + assert.equal(await cell.find('.field_clip').matches('.invalid'), false); + + await gu.undo(3); + assert.equal(await cell.getText(), "Red"); + }); + + it('should not offer an add-new option when target is a formula', async function() { + // Click on an alt-text cell. + const cell = await gu.getCell({section: 'References', col: 'Color', rowNum: 3}).doClick(); + assert.equal(await cell.getText(), "hello"); + assert.equal(await cell.find('.field_clip').matches('.invalid'), true); + + await driver.sendKeys(Key.ENTER); + assert.equal(await driver.find('.test-ref-editor-new-item').getText(), "hello"); + await driver.sendKeys(Key.ESCAPE); + + // Change the visible column to the formula column "C2". + await gu.toggleSidePanel('right', 'open'); + await driver.find('.test-right-tab-field').click(); + await driver.find('.test-fbuilder-ref-col-select').click(); + await driver.findContent('.test-select-row', /C2/).click(); + await gu.waitForServer(); + + // Check that for the same cell, the dropdown no longer has an "add new" option. + await cell.click(); + await driver.sendKeys(Key.ENTER); + assert.equal(await driver.find('.celleditor_text_editor').value(), 'hello'); + await driver.findWait('.test-ref-editor-item', 1000); + assert.equal(await driver.find('.test-ref-editor-item.selected').isPresent(), false); + assert.equal(await driver.find('.test-ref-editor-new-item').isPresent(), false); + await driver.sendKeys(Key.ESCAPE); + + await gu.undo(); + await gu.toggleSidePanel('right', 'close'); + }); + + it('should offer items ordered by best match', async function() { + let cell = await gu.getCell({section: 'References', col: 'Color', rowNum: 1}).doClick(); + assert.equal(await cell.getText(), 'Dark Slate Blue'); + await driver.sendKeys(Key.ENTER); + assert.deepEqual(await getACOptions(4), + ['Dark Slate Blue', 'Dark Slate Gray', 'Slate Blue', 'Medium Slate Blue']); + await driver.sendKeys(Key.ESCAPE); + + await driver.sendKeys('blac'); + assert.deepEqual(await getACOptions(6), + ['Black', 'Blanched Almond', 'Blue', 'Blue Violet', 'Alice Blue', 'Cadet Blue']); + await driver.sendKeys(Key.ESCAPE); + + cell = await gu.getCell({section: 'References', col: 'Color', rowNum: 3}).doClick(); + assert.equal(await cell.getText(), 'hello'); // Alt-text + await driver.sendKeys(Key.ENTER); + assert.deepEqual(await getACOptions(2), + ['Honeydew', 'Hot Pink']); + await driver.sendKeys(Key.ESCAPE); + + cell = await gu.getCell({section: 'References', col: 'ColorCode', rowNum: 2}).doClick(); + assert.equal(await cell.getText(), '#808080'); + await driver.sendKeys(Key.ENTER); + assert.deepEqual(await getACOptions(5), + ['#808080', '#808000', '#800000', '#800080', '#87CEEB']); + await driver.sendKeys(Key.ESCAPE); + + cell = await gu.getCell({section: 'References', col: 'XNum', rowNum: 2}).doClick(); + assert.equal(await cell.getText(), '2019-04-29'); + await driver.sendKeys(Key.ENTER); + assert.deepEqual(await getACOptions(4), + ['2019-04-29', '2020-04-29', '2019-11-05', '2020-04-28']); + await driver.sendKeys(Key.ESCAPE); + }); + + it('should update choices as user types into textbox', async function() { + let cell = await gu.getCell({section: 'References', col: 'School', rowNum: 1}).doClick(); + assert.equal(await cell.getText(), 'TECHNOLOGY, ARTS AND SCIENCES STUDIO'); + await driver.sendKeys(Key.ENTER); + assert.deepEqual(await getACOptions(3), [ + 'TECHNOLOGY, ARTS AND SCIENCES STUDIO', + 'SCIENCE AND TECHNOLOGY ACADEMY', + 'SCHOOL OF SCIENCE AND TECHNOLOGY', + ]); + await driver.sendKeys(Key.ESCAPE); + cell = await gu.getCell({section: 'References', col: 'School', rowNum: 2}).doClick(); + await driver.sendKeys('stuy'); + assert.deepEqual(await getACOptions(3), [ + 'STUYVESANT HIGH SCHOOL', + 'BEDFORD STUY COLLEGIATE CHARTER SCH', + 'BEDFORD STUY NEW BEGINNINGS CHARTER', + ]); + await driver.sendKeys(Key.BACK_SPACE); + assert.deepEqual(await getACOptions(3), [ + 'STUART M TOWNSEND MIDDLE SCHOOL', + 'STUDIO SCHOOL (THE)', + 'STUYVESANT HIGH SCHOOL', + ]); + await driver.sendKeys(' bre'); + assert.equal(await driver.find('.celleditor_text_editor').value(), 'stu bre'); + assert.deepEqual(await getACOptions(3), [ + 'ST BRENDAN SCHOOL', + 'BRONX STUDIO SCHOOL-WRITERS-ARTISTS', + 'BROOKLYN STUDIO SECONDARY SCHOOL', + ]); + + await driver.sendKeys(Key.DOWN, Key.ENTER); + await gu.waitForServer(); + assert.equal(await cell.getText(), 'ST BRENDAN SCHOOL'); + await gu.undo(); + assert.equal(await cell.getText(), ''); + }); + + it('should highlight matching parts of items', async function() { + await driver.sendKeys(Key.HOME); + + let cell = await gu.getCell({section: 'References', col: 'Color', rowNum: 2}).doClick(); + assert.equal(await cell.getText(), 'Red'); + await driver.sendKeys(Key.ENTER); + await driver.findWait('.test-ref-editor-item', 1000); + assert.deepEqual( + await driver.findContent('.test-ref-editor-item', /Dark Red/).findAll('span', e => e.getText()), + ['Red']); + assert.deepEqual( + await driver.findContent('.test-ref-editor-item', /Rebecca Purple/).findAll('span', e => e.getText()), + ['Re']); + await driver.sendKeys(Key.ESCAPE); + + cell = await gu.getCell({section: 'References', col: 'School', rowNum: 1}).doClick(); + await driver.sendKeys('br tech'); + assert.deepEqual( + await driver.findContentWait('.test-ref-editor-item', /BROOKLYN TECH/, 1000).findAll('span', e => e.getText()), + ['BR', 'TECH']); + assert.deepEqual( + await driver.findContent('.test-ref-editor-item', /BUFFALO.*TECHNOLOGY/).findAll('span', e => e.getText()), + ['B', 'TECH']); + assert.deepEqual( + await driver.findContent('.test-ref-editor-item', /ENERGY TECH/).findAll('span', e => e.getText()), + ['TECH']); + await driver.sendKeys(Key.ESCAPE); + }); + + it('should reflect changes to the target column', async function() { + await driver.sendKeys(Key.HOME); + + const cell = await gu.getCell({section: 'References', col: 'Color', rowNum: 4}).doClick(); + assert.equal(await cell.getText(), ''); + await driver.sendKeys(Key.ENTER); + assert.deepEqual(await getACOptions(2), ['Alice Blue', 'Añil']); + await driver.sendKeys(Key.ESCAPE); + + // Change a color + await gu.getCell({section: 'Colors', col: 'Color Name', rowNum: 1}).doClick(); + await driver.sendKeys('HAZELNUT', Key.ENTER); + await gu.waitForServer(); + + // See that the old value is gone from the autocomplete, and the new one is present. + await cell.click(); + await driver.sendKeys(Key.ENTER); + assert.deepEqual(await getACOptions(2), ['Añil', 'Aqua']); + await driver.sendKeys('H'); + assert.deepEqual(await getACOptions(2), ['HAZELNUT', 'Honeydew']); + await driver.sendKeys(Key.ESCAPE); + + // Delete a row. + await gu.getCell({section: 'Colors', col: 'Color Name', rowNum: 1}).doClick(); + await driver.find('body').sendKeys(Key.chord(await gu.modKey(), '-')); + await gu.waitForServer(); + + // See that the value is gone from the autocomplete. + await cell.click(); + await driver.sendKeys('H'); + assert.deepEqual(await getACOptions(2), ['Honeydew', 'Hot Pink']); + await driver.sendKeys(Key.ESCAPE); + + // Add a row. + await gu.getCell({section: 'Colors', col: 'Color Name', rowNum: 1}).doClick(); + await driver.find('body').sendKeys(Key.chord(await gu.modKey(), '=')); + await gu.waitForServer(); + await driver.sendKeys('HELIOTROPE', Key.ENTER); + await gu.waitForServer(); + + // See that the new value is visible in the autocomplete. + await cell.click(); + await driver.sendKeys('H'); + assert.deepEqual(await getACOptions(2), ['HELIOTROPE', 'Honeydew']); + await driver.sendKeys(Key.BACK_SPACE); + assert.deepEqual(await getACOptions(2), ['Añil', 'Aqua']); + await driver.sendKeys(Key.ESCAPE); + + // Undo all the changes. + await gu.undo(4); + + await cell.click(); + await driver.sendKeys('H'); + assert.deepEqual(await getACOptions(2), ['Honeydew', 'Hot Pink']); + await driver.sendKeys(Key.BACK_SPACE); + assert.deepEqual(await getACOptions(2), ['Alice Blue', 'Añil']); + await driver.sendKeys(Key.ESCAPE); + }); + }); +}); diff --git a/test/nbrowser/ReferenceList.ts b/test/nbrowser/ReferenceList.ts new file mode 100644 index 00000000..2a43ce63 --- /dev/null +++ b/test/nbrowser/ReferenceList.ts @@ -0,0 +1,865 @@ +import {assert, driver, Key, stackWrapFunc} from 'mocha-webdriver'; +import * as gu from 'test/nbrowser/gristUtils'; +import {server, setupTestSuite} from 'test/nbrowser/testUtils'; +import {Session} from 'test/nbrowser/gristUtils'; + +describe('ReferenceList', function() { + this.timeout(20000); + setupTestSuite(); + let session: Session; + const cleanup = setupTestSuite({team: true}); + + before(async function() { + session = await gu.session().teamSite.login(); + await session.tempDoc(cleanup, 'Favorite_Films.grist'); + + await gu.toggleSidePanel('right'); + await driver.find('.test-config-data').click(); + }); + + describe('transforms', function() { + afterEach(() => gu.checkForErrors()); + + it('should correctly transform references to reference lists', async function() { + // Open the Friends page. + await driver.findContentWait('.test-treeview-itemHeader', /Friends/, 2000).click(); + await gu.waitForDocToLoad(); + + // Change the column type of Favorite Film to Reference List. + await gu.getCell({col: 'Favorite Film', rowNum: 1}).doClick(); + await gu.setType(/Reference List/); + + // Check that the column preview shows valid reference lists. + assert.deepEqual( + await gu.getVisibleGridCells('Favorite Film', [1, 2, 3, 4, 5, 6, 7]), + [ + 'Forrest Gump', + 'Toy Story', + 'Avatar', + 'The Dark Knight', + 'Forrest Gump', + 'Alien', + '' + ] + ); + + // Apply the conversion. + await driver.findContent('.type_transform_prompt button', /Apply/).click(); + await gu.waitForServer(); + + // Check that Favorite Film now contains reference lists of length 1. + assert.deepEqual( + await gu.getVisibleGridCells('Favorite Film', [1, 2, 3, 4, 5, 6, 7]), + [ + 'Forrest Gump', + 'Toy Story', + 'Avatar', + 'The Dark Knight', + 'Forrest Gump', + 'Alien', + '' + ] + ); + }); + }); + + describe('rendering', function() { + afterEach(() => gu.checkForErrors()); + + it('should reflect the current values from the referenced column', async function() { + // Open the All page. + await driver.findContentWait('.test-treeview-itemHeader', /All/, 2000).click(); + await gu.waitForDocToLoad(); + + // Add additional favorite films to a few rows in Friends. + await gu.getCell('Favorite Film', 1, 'Friends record').doClick(); + await gu.sendKeys(Key.ENTER, 'Alien', Key.ENTER, Key.ENTER); + await gu.sendKeys(Key.ENTER, 'Avatar', Key.ENTER, 'The Avengers', Key.ENTER, Key.ENTER); + await gu.sendKeys(Key.ARROW_DOWN, Key.ENTER, 'The Avengers', Key.ENTER, Key.ENTER); + + // Check that the cells are rendered correctly. + assert.deepEqual(await gu.getVisibleGridCells('Favorite Film', [1, 2, 3, 4, 5, 6]), + [ + 'Forrest Gump\nAlien', + 'Toy Story\nAvatar\nThe Avengers', + 'Avatar', + 'The Dark Knight\nThe Avengers', + 'Forrest Gump', + 'Alien' + ] + ); + + // Change a few of the film titles. + await gu.getCell('Title', 1, 'Films record').doClick(); + await gu.sendKeys('Toy Story 2', Key.ENTER); + await gu.sendKeys(Key.ARROW_DOWN, 'Aliens', Key.ENTER); + await gu.sendKeys(Key.ARROW_DOWN, 'The Dark Knight Rises', Key.ENTER); + + // Check that the Favorite Film column reflects the new titles. + assert.deepEqual( + await gu.getVisibleGridCells('Favorite Film', [1, 2, 3, 4, 5, 6], 'Friends record'), + [ + 'Forrest Gump\nAliens', + 'Toy Story 2\nAvatar\nThe Avengers', + 'Avatar', + 'The Dark Knight Rises\nThe Avengers', + 'Forrest Gump', + 'Aliens' + ] + ); + }); + + it(`should show '[Blank]' if the referenced item is blank`, async function() { + // Clear the cell in Films record containing Avatar. + await gu.getCell('Title', 4, 'Films record').doClick(); + await gu.sendKeys(Key.BACK_SPACE); + await gu.waitForServer(); + + // Check that all references to Avatar now show '[Blank]'. + assert.deepEqual( + await gu.getVisibleGridCells('Favorite Film', [1, 2, 3, 4, 5, 6], 'Friends record'), + [ + 'Forrest Gump\nAliens', + 'Toy Story 2\n[Blank]\nThe Avengers', + '[Blank]', + 'The Dark Knight Rises\nThe Avengers', + 'Forrest Gump', + 'Aliens' + ] + ); + + // Check that a '[Blank]' token is shown when the reference list editor is open. + await gu.getCell('Favorite Film', 2, 'Friends record').doClick(); + await gu.sendKeys(Key.ENTER); + assert.deepEqual( + await driver.findAll('.cell_editor .test-tokenfield .test-tokenfield-token', el => el.getText()), + ['Toy Story 2', '[Blank]', 'The Avengers'] + ); + await gu.sendKeys(Key.ESCAPE); + + // Undo, and check that it shows Avatar again. + await gu.undo(); + assert.deepEqual( + await gu.getVisibleGridCells('Favorite Film', [1, 2, 3, 4, 5, 6], 'Friends record'), + [ + 'Forrest Gump\nAliens', + 'Toy Story 2\nAvatar\nThe Avengers', + 'Avatar', + 'The Dark Knight Rises\nThe Avengers', + 'Forrest Gump', + 'Aliens' + ] + ); + + // Now delete the row containing Avatar. + await gu.getCell('Title', 4, 'Films record').doClick(); + await gu.sendKeys(Key.chord(await gu.modKey(), '-')); + await gu.waitForServer(); + + // Check that all references to Avatar are deleted. + assert.deepEqual( + await gu.getVisibleGridCells('Favorite Film', [1, 2, 3, 4, 5, 6], 'Friends record'), + [ + 'Forrest Gump\nAliens', + 'Toy Story 2\nThe Avengers', + '', + 'The Dark Knight Rises\nThe Avengers', + 'Forrest Gump', + 'Aliens' + ] + ); + + await gu.undo(); + }); + + it('should still work after renaming visible column', async function() { + // Check that we have a Ref:Films column displaying Title. + await gu.getCell({section: 'Friends record', col: 'Favorite Film', rowNum: 2}).doClick(); + assert.equal(await driver.find('.test-fbuilder-ref-table-select .test-select-row').getText(), 'Films'); + assert.equal(await driver.find('.test-fbuilder-ref-col-select .test-select-row').getText(), 'Title'); + + // Rename the Title column in Films, to TitleX. + // In browser tests, first record is hidden, we need to scroll first. + await gu.selectSectionByTitle('Films record'); + await gu.scrollActiveView(0, -100); + await gu.getCell({section: 'Films record', col: 'Title', rowNum: 1}).doClick(); + await driver.find('.test-field-label').click(); + await gu.sendKeys(await gu.selectAllKey(), 'TitleX', Key.ENTER); + await gu.waitForServer(); + + // Check that the Ref:Films column shows TitleX and is still correct. + await gu.getCell({section: 'Friends record', col: 'Favorite Film', rowNum: 2}).doClick(); + await driver.find('.test-fbuilder-ref-table-select').click(); + assert.equal(await driver.find('.test-fbuilder-ref-table-select .test-select-row').getText(), 'Films'); + assert.equal(await driver.find('.test-fbuilder-ref-col-select .test-select-row').getText(), 'TitleX'); + assert.deepEqual( + await gu.getVisibleGridCells('Favorite Film', [1, 2, 3, 4, 5, 6]), + [ + 'Forrest Gump\nAliens', + 'Toy Story 2\nAvatar\nThe Avengers', + 'Avatar', + 'The Dark Knight Rises\nThe Avengers', + 'Forrest Gump', + 'Aliens' + ] + ); + + // Undo and verify again. + await gu.undo(); + await gu.getCell({section: 'Friends record', col: 'Favorite Film', rowNum: 2}).doClick(); + assert.equal(await driver.find('.test-fbuilder-ref-col-select .test-select-row').getText(), 'Title'); + assert.deepEqual( + await gu.getVisibleGridCells('Favorite Film', [1, 2, 3, 4, 5, 6]), + [ + 'Forrest Gump\nAliens', + 'Toy Story 2\nAvatar\nThe Avengers', + 'Avatar', + 'The Dark Knight Rises\nThe Avengers', + 'Forrest Gump', + 'Aliens' + ] + ); + }); + + it('should switch to rowId if the selected visible column is deleted', async function() { + // Delete the Title column from Films. + await gu.getCell({section: 'Films record', col: 'Title', rowNum: 1}).doClick(); + await gu.sendKeys(Key.chord(Key.ALT, '-')); + await gu.waitForServer(); + + // Check that Favorite Film switched to showing RowID. + await gu.getCell({section: 'Friends record', col: 'Favorite Film', rowNum: 2}).doClick(); + assert.equal(await driver.find('.test-fbuilder-ref-table-select .test-select-row').getText(), 'Films'); + assert.equal(await driver.find('.test-fbuilder-ref-col-select .test-select-row').getText(), 'Row ID'); + assert.deepEqual( + await gu.getVisibleGridCells('Favorite Film', [1, 2, 3, 4, 5, 6]), + [ + 'Films[2]\nFilms[3]', + 'Films[1]\nFilms[4]\nFilms[6]', + 'Films[4]', + 'Films[5]\nFilms[6]', + 'Films[2]', + 'Films[3]' + ] + ); + + await gu.undo(); + }); + + it('should render Row ID values as TableId[RowId]', async function() { + await driver.findContentWait('.test-treeview-itemHeader', /Friends/, 2000).click(); + await gu.waitForDocToLoad(); + + // Create a new Reference List column. + await driver.find('.test-right-tab-field').click(); + await driver.find('.mod-add-column').click(); + await gu.waitForServer(); + await gu.setType(/Reference List/); + await gu.waitForServer(); + + // Populate the first few rows of the new column with some references. + await gu.getCell({rowNum: 1, col: 'A'}).click(); + await driver.sendKeys('1', Key.ENTER, '2', Key.ENTER, Key.ENTER); + await driver.sendKeys('2', Key.ENTER, Key.ENTER); + await driver.sendKeys('3', Key.ENTER, '4', Key.ENTER, '5', Key.ENTER, Key.ENTER); + + // Check that the cells render their tokens as TableId[RowId]. + assert.deepEqual(await gu.getVisibleGridCells(3, [1, 2, 3, 4, 5, 6]), + ['Friends[1]\nFriends[2]', 'Friends[2]', 'Friends[3]\nFriends[4]\nFriends[5]', '', '', '']); + + // Check that switching Shown Column to Name works correctly. + await driver.find('.test-fbuilder-ref-col-select').click(); + await driver.findContent('.test-select-row', /Name/).click(); + await gu.waitForServer(); + assert.deepEqual(await gu.getVisibleGridCells(3, [1, 2, 3, 4, 5, 6]), + ['Roger\nTom', 'Tom', 'Sydney\nBill\nEvan', '', '', '']); + + // Add a new reference. + await gu.getCell(3, 5).click(); + await driver.sendKeys('Roger'); + await driver.sendKeys(Key.ENTER, Key.ENTER); + await gu.waitForServer(); + + // Check that switching between Row ID and Name still works correctly. + assert.deepEqual(await gu.getVisibleGridCells(3, [1, 2, 3, 4, 5, 6]), + ['Roger\nTom', 'Tom', 'Sydney\nBill\nEvan', '', 'Roger', '']); + await driver.find('.test-fbuilder-ref-col-select').click(); + await driver.findContent('.test-select-row', /Row ID/).click(); + await gu.waitForServer(); + assert.deepEqual(await gu.getVisibleGridCells(3, [1, 2, 3, 4, 5, 6]), + ['Friends[1]\nFriends[2]', 'Friends[2]', 'Friends[3]\nFriends[4]\nFriends[5]', '', 'Friends[1]', '']); + await driver.find('.test-fbuilder-ref-col-select').click(); + await driver.findContent('.test-select-row', /Name/).click(); + await gu.waitForServer(); + assert.deepEqual(await gu.getVisibleGridCells(3, [1, 2, 3, 4, 5, 6]), + ['Roger\nTom', 'Tom', 'Sydney\nBill\nEvan', '', 'Roger', '']); + + await gu.undo(); + }); + + it('should allow entering numeric id before target table is loaded', async function() { + if (server.isExternalServer()) { + this.skip(); + } + // Refresh the document. + await driver.navigate().refresh(); + await gu.waitForDocToLoad(); + + // Now pause the server. + const cell = gu.getCell({col: 'A', rowNum: 1}); + await server.pauseUntil(async () => { + assert.equal(await cell.getText(), 'Friends[1]\nFriends[2]'); + await cell.click(); + await gu.sendKeys('5'); + // Check that the autocomplete has no items yet. + assert.isEmpty(await driver.findAll('.test-autocomplete .test-ref-editor-new-item')); + await gu.sendKeys(Key.ENTER, Key.ENTER); + }); + await gu.waitForServer(); + assert.equal(await cell.getText(), 'Friends[5]'); + + await gu.undo(); + assert.equal(await cell.getText(), 'Friends[1]\nFriends[2]'); + + // Once server is responsive, a valid value should not offer a "new item". + await cell.click(); + await gu.sendKeys('5'); + await driver.findWait('.test-ref-editor-item', 500); + assert.isFalse(await driver.find('.test-ref-editor-new-item').isPresent()); + await gu.sendKeys(Key.ENTER, Key.ENTER); + await gu.waitForServer(); + assert.equal(await cell.getText(), 'Friends[5]'); + }); + }); + + describe('sorting', function() { + afterEach(() => gu.checkForErrors()); + + it('should sort by the display values of the referenced column', async function() { + this.timeout(10000); + await driver.findContentWait('.test-treeview-itemHeader', /All/, 2000).click(); + await gu.waitForDocToLoad(); + await gu.getCell('Favorite Film', 1, 'Friends record').doClick(); + + await driver.find('.test-right-tab-pagewidget').click(); + await driver.find('.test-config-sortAndFilter').click(); + + // Sort the Favorite Film column. + await driver.find('.test-vconfigtab-sort-add').click(); + await driver.findContent('.test-vconfigtab-sort-add-menu-row', /Favorite_Film/).click(); + await driver.find('.test-vconfigtab-sort-save').click(); + + // Check that the records are sorted by display value. + assert.deepEqual( + await gu.getVisibleGridCells('Favorite Film', [1, 2, 3, 4, 5, 6], 'Friends record'), + [ + 'Aliens', + 'Avatar', + 'Forrest Gump', + 'Forrest Gump\nAliens', + 'The Dark Knight Rises\nThe Avengers', + 'Toy Story 2\nAvatar\nThe Avengers' + ] + ); + }); + + it("should update sort when display column is changed", async function() { + // Change a film title to cause the sort order to change. + await gu.getCell('Title', 5, 'Films record').doClick(); + await gu.sendKeys('Batman Begins', Key.ENTER); + await gu.waitForServer(); + + // Check that the updated sort order is correct. + assert.deepEqual( + await gu.getVisibleGridCells('Favorite Film', [1, 2, 3, 4, 5, 6], 'Friends record'), + [ + 'Aliens', + 'Avatar', + 'Batman Begins\nThe Avengers', + 'Forrest Gump', + 'Forrest Gump\nAliens', + 'Toy Story 2\nAvatar\nThe Avengers' + ] + ); + + // Clear a film title to cause the sort order to change. + await gu.getCell('Title', 2, 'Films record').doClick(); + await gu.sendKeys(Key.BACK_SPACE); + await gu.waitForServer(); + + // Check that the updated sort order is correct. + assert.deepEqual( + await gu.getVisibleGridCells('Favorite Film', [1, 2, 3, 4, 5, 6], 'Friends record'), + [ + '[Blank]', + '[Blank]\nAliens', + 'Aliens', + 'Avatar', + 'Batman Begins\nThe Avengers', + 'Toy Story 2\nAvatar\nThe Avengers' + ] + ); + + // Clear a film reference to cause the sort order to change. + await gu.getCell('Favorite Film', 4, 'Friends record').doClick(); + await gu.sendKeys(Key.BACK_SPACE); + await gu.waitForServer(); + + // Check that the updated sort order is correct. + assert.deepEqual( + await gu.getVisibleGridCells('Favorite Film', [1, 2, 3, 4, 5, 6], 'Friends record'), + [ + '', + '[Blank]', + '[Blank]\nAliens', + 'Aliens', + 'Batman Begins\nThe Avengers', + 'Toy Story 2\nAvatar\nThe Avengers' + ] + ); + }); + + it("should sort consistently when column contains AltText", async function() { + // Enter an invalid reference in Favorite Film. + await gu.getCell('Favorite Film', 4, 'Friends record').doClick(); + await gu.sendKeys('Aliens 4', Key.ENTER, Key.ENTER); + await gu.waitForServer(); + + // Check that the updated sort order is correct. + // Accept '[u\'Aliens 4\']' as a py2 variant of '[\'Aliens 4\']' + const variant = await gu.getCell('Favorite Film', 1, 'Friends record').getText(); + assert.deepEqual( + await gu.getVisibleGridCells('Favorite Film', [1, 2, 3, 4, 5, 6], 'Friends record'), + [ + variant.startsWith('[u') ? '[u\'Aliens 4\']' : '[\'Aliens 4\']', + '', + '[Blank]', + '[Blank]\nAliens', + 'Batman Begins\nThe Avengers', + 'Toy Story 2\nAvatar\nThe Avengers' + ] + ); + }); + }); + + describe('autocomplete', function() { + const getACOptions = stackWrapFunc(async (limit?: number) => { + await driver.findWait('.test-ref-editor-item', 1000); + return (await driver.findAll('.test-ref-editor-item', el => el.getText())).slice(0, limit); + }); + + before(async function() { + await session.tempDoc(cleanup, 'Ref-List-AC-Test.grist'); + await gu.toggleSidePanel('right', 'close'); + }); + + afterEach(() => gu.checkForErrors()); + + it('should render first items when opening empty cell', async function() { + await driver.sendKeys(Key.HOME); + + let cell = await gu.getCell({section: 'References', col: 'Colors', rowNum: 4}).doClick(); + assert.equal(await cell.getText(), ''); + await driver.sendKeys(Key.ENTER); + // Check the first few items. + assert.deepEqual(await getACOptions(3), ["Alice Blue", "Añil", "Aqua"]); + // No item is selected. + assert.equal(await driver.find('.test-ref-editor-item.selected').isPresent(), false); + await driver.sendKeys(Key.ESCAPE); + + cell = await gu.getCell({section: 'References', col: 'Schools', rowNum: 6}).doClick(); + assert.equal(await cell.getText(), ''); + await driver.sendKeys(Key.ENTER); + // Check the first few items; should be sorted alphabetically. + assert.deepEqual(await getACOptions(3), + ["2 SCHOOL", "4 SCHOOL", "47 AMER SIGN LANG & ENG LOWER "]); + // No item is selected. + assert.equal(await driver.find('.test-ref-editor-item.selected').isPresent(), false); + await driver.sendKeys(Key.ESCAPE); + }); + + it('should save correct item on click', async function() { + await driver.sendKeys(Key.HOME); + + // Edit a cell by double-clicking. + let cell = await gu.getCell({section: 'References', col: 'Colors', rowNum: 2}).doClick(); + await driver.withActions(a => a.doubleClick(cell)); + + // Scroll to another item and click it. + await gu.sendKeys('ro'); + let item = driver.findContent('.test-ref-editor-item', 'Rosy Brown'); + await gu.scrollIntoView(item); + await item.click(); + + // It should get added; and undo should revert adding it. + assert.deepEqual( + await driver.findAll('.cell_editor .test-tokenfield .test-tokenfield-token', el => el.getText()), + ['Red', 'Rosy Brown'] + ); + await gu.sendKeys(Key.chord(await gu.modKey(), 'z')); + assert.deepEqual( + await driver.findAll('.cell_editor .test-tokenfield .test-tokenfield-token', el => el.getText()), + ['Red'] + ); + await gu.sendKeys(Key.ESCAPE); + assert.equal(await cell.getText(), 'Red'); + + // Edit another cell by starting to type. + cell = await gu.getCell({section: 'References', col: 'Colors', rowNum: 4}).doClick(); + await driver.sendKeys("gr"); + await driver.findWait('.test-ref-editor-item', 1000); + item = driver.findContent('.test-ref-editor-item', 'Medium Sea Green'); + await gu.scrollIntoView(item); + await item.click(); + await gu.sendKeys(Key.ENTER); + + // It should get saved; and undo should restore the previous value. + await gu.waitForServer(); + assert.equal(await cell.getText(), 'Medium Sea Green'); + await gu.undo(); + assert.equal(await cell.getText(), ''); + }); + + it('should save correct item after selecting with arrow keys', async function() { + // Same as the previous test, but instead of clicking items, select item using arrow keys. + + // Edit a cell by double-clicking. + let cell = await gu.getCell({section: 'References', col: 'Colors', rowNum: 2}).doClick(); + await driver.withActions(a => a.doubleClick(cell)); + + // Move to another item and hit Enter + await gu.sendKeys('pa'); + await driver.sendKeys(Key.DOWN, Key.DOWN, Key.DOWN); + assert.equal(await driver.findWait('.test-ref-editor-item.selected', 1000).getText(), 'Pale Violet Red'); + await driver.sendKeys(Key.ENTER); + + // It should get added; and undo should revert adding it. + assert.deepEqual( + await driver.findAll('.cell_editor .test-tokenfield .test-tokenfield-token', el => el.getText()), + ['Red', 'Pale Violet Red'] + ); + await gu.sendKeys(Key.chord(await gu.modKey(), 'z')); + assert.deepEqual( + await driver.findAll('.cell_editor .test-tokenfield .test-tokenfield-token', el => el.getText()), + ['Red'] + ); + await gu.sendKeys(Key.ESCAPE); + assert.equal(await cell.getText(), 'Red'); + + // Edit another cell by starting to type. + cell = await gu.getCell({section: 'References', col: 'Colors', rowNum: 4}).doClick(); + await driver.sendKeys("gr"); + await driver.findWait('.test-ref-editor-item', 1000); + await driver.sendKeys(Key.UP, Key.UP, Key.UP, Key.UP, Key.UP); + assert.equal(await driver.findWait('.test-ref-editor-item.selected', 1000).getText(), 'Chocolate'); + await driver.sendKeys(Key.ENTER, Key.ENTER); + + // It should get saved; and undo should restore the previous value. + await gu.waitForServer(); + assert.equal(await cell.getText(), 'Chocolate'); + await gu.undo(); + assert.equal(await cell.getText(), ''); + }); + + it('should return to text-as-typed when nothing is selected', async function() { + const cell = await gu.getCell({section: 'References', col: 'Colors', rowNum: 2}).doClick(); + await driver.sendKeys("da"); + assert.deepEqual(await getACOptions(2), ["Dark Blue", "Dark Cyan"]); + + // Check that the first item is highlighted by default. + assert.equal(await driver.find('.cell_editor .test-tokenfield .test-tokenfield-input').value(), 'da'); + assert.equal(await driver.find('.test-ref-editor-item.selected').getText(), 'Dark Blue'); + + // Select second item. Both the textbox and the dropdown show the selection. + await driver.sendKeys(Key.DOWN); + assert.equal(await driver.find('.cell_editor .test-tokenfield .test-tokenfield-input').value(), 'Dark Cyan'); + assert.equal(await driver.find('.test-ref-editor-item.selected').getText(), 'Dark Cyan'); + + // Move back to no-selection state. + await driver.sendKeys(Key.UP, Key.UP); + assert.equal(await driver.find('.cell_editor .test-tokenfield .test-tokenfield-input').value(), 'da'); + assert.equal(await driver.find('.test-ref-editor-item.selected').isPresent(), false); + + // Mouse over an item. + await driver.findContent('.test-ref-editor-item', /Dark Gray/).mouseMove(); + assert.equal(await driver.find('.cell_editor .test-tokenfield .test-tokenfield-input').value(), 'Dark Gray'); + assert.equal(await driver.find('.test-ref-editor-item.selected').getText(), 'Dark Gray'); + + // Mouse back out of the dropdown + await driver.find('.cell_editor .test-tokenfield .test-tokenfield-input').mouseMove(); + assert.equal(await driver.find('.cell_editor .test-tokenfield .test-tokenfield-input').value(), 'da'); + assert.equal(await driver.find('.test-ref-editor-item.selected').isPresent(), false); + + // Click away and check the cell is now empty since no reference items were added. + await gu.getCell({section: 'References', col: 'Colors', rowNum: 1}).doClick(); + await gu.waitForServer(); + assert.equal(await cell.getText(), ""); + assert.equal(await cell.find('.field_clip').matches('.invalid'), false); + + await gu.undo(); + assert.equal(await cell.getText(), "Red"); + assert.equal(await cell.find('.field_clip').matches('.invalid'), false); + }); + + it('should save text as typed when nothing is selected', async function() { + const cell = await gu.getCell({section: 'References', col: 'Colors', rowNum: 1}).doClick(); + await driver.sendKeys("lavender ", Key.ENTER, Key.ENTER); + await gu.waitForServer(); + assert.equal(await cell.getText(), "Lavender"); + await gu.undo(); + assert.equal(await cell.getText(), "Dark Slate Blue"); + }); + + it('should offer an add-new option when no good match', async function() { + const cell = await gu.getCell({section: 'References', col: 'Colors', rowNum: 2}).doClick(); + await driver.sendKeys("pinkish"); + // There are inexact matches. + assert.deepEqual(await getACOptions(3), + ["Pink", "Deep Pink", "Hot Pink"]); + // Nothing is selected, and the "add new" item is present. + assert.equal(await driver.find('.test-ref-editor-item.selected').isPresent(), false); + assert.equal(await driver.find('.test-ref-editor-new-item').getText(), "pinkish"); + + // Click the "add new" item. The new value should be added, and should not appear invalid. + await driver.find('.test-ref-editor-new-item').click(); + assert.deepEqual( + await driver.findAll('.cell_editor .test-tokenfield .test-tokenfield-token', el => el.getText()), + ['pinkish'] + ); + assert.deepEqual( + await driver.findAll( + '.cell_editor .test-tokenfield .test-tokenfield-token', + el => el.matches('[class*=-invalid]') + ), + [false] + ); + + // Add another new item (with the keyboard), and check that it also appears correctly. + await driver.sendKeys("almost pink", Key.ARROW_UP, Key.ENTER); + assert.deepEqual( + await driver.findAll('.cell_editor .test-tokenfield .test-tokenfield-token', el => el.getText()), + ['pinkish', 'almost pink'] + ); + assert.deepEqual( + await driver.findAll( + '.cell_editor .test-tokenfield .test-tokenfield-token', + el => el.matches('[class*=-invalid]') + ), + [false, false] + ); + + // Save the changes to the cell. + await gu.sendKeys(Key.ENTER); + await gu.waitForServer(); + assert.equal(await cell.getText(), "pinkish\nalmost pink"); + + // Check that the referenced table now has "pinkish" and "almost pink". + await driver.findContentWait('.test-treeview-itemHeader', /Colors/, 2000).click(); + await gu.waitForDocToLoad(); + await gu.sendKeys(Key.chord(await gu.modKey(), Key.ARROW_DOWN)); + assert.deepEqual( + await gu.getVisibleGridCells('Color Name', [146, 147]), + ['pinkish', 'almost pink'] + ); + assert.deepEqual( + await gu.getVisibleGridCells('C2', [146, 147]), + ['pinkish', 'almost pink'] + ); + + // Requires 2 undos, because adding the "pinkish" and "almost pink" records is a separate action. TODO these + // actions should be bundled. + await gu.undo(2); + assert.equal(await gu.getCell({section: 'References', col: 'Colors', rowNum: 2}).getText(), 'Red'); + }); + + it('should not offer an add-new option when target is a formula', async function() { + // Click on an alt-text cell. + const cell = await gu.getCell({section: 'References', col: 'Colors', rowNum: 3}).doClick(); + assert.equal(await cell.getText(), "hello"); + assert.equal(await cell.find('.field_clip').matches('.invalid'), true); + + await driver.sendKeys(Key.ENTER, 'hello'); + assert.equal(await driver.find('.test-ref-editor-new-item').getText(), "hello"); + await driver.sendKeys(Key.ESCAPE); + + // Change the visible column to the formula column "C2". + await gu.toggleSidePanel('right', 'open'); + await driver.find('.test-right-tab-field').click(); + await driver.find('.test-fbuilder-ref-col-select').click(); + await driver.findContent('.test-select-row', /C2/).click(); + await gu.waitForServer(); + + // Check that for the same cell, the dropdown no longer has an "add new" option. + await cell.click(); + await driver.sendKeys(Key.ENTER, 'hello'); + await driver.findWait('.test-ref-editor-item', 1000); + assert.equal(await driver.find('.test-ref-editor-item.selected').isPresent(), false); + assert.equal(await driver.find('.test-ref-editor-new-item').isPresent(), false); + await driver.sendKeys(Key.ESCAPE); + + await gu.undo(); + await gu.toggleSidePanel('right', 'close'); + }); + + it('should offer items ordered by best match', async function() { + let cell = await gu.getCell({section: 'References', col: 'Colors', rowNum: 1}).doClick(); + assert.equal(await cell.getText(), 'Dark Slate Blue'); + await driver.sendKeys(Key.ENTER, 'Dark Slate Blue'); + assert.deepEqual(await getACOptions(4), + ['Dark Slate Blue', 'Dark Slate Gray', 'Slate Blue', 'Medium Slate Blue']); + await driver.sendKeys(Key.ESCAPE); + + await driver.sendKeys('blac'); + assert.deepEqual(await getACOptions(6), + ['Black', 'Blanched Almond', 'Blue', 'Blue Violet', 'Alice Blue', 'Cadet Blue']); + await driver.sendKeys(Key.ESCAPE); + + cell = await gu.getCell({section: 'References', col: 'Colors', rowNum: 3}).doClick(); + assert.equal(await cell.getText(), 'hello'); // Alt-text + await driver.sendKeys('hello'); + assert.deepEqual(await getACOptions(2), + ['Honeydew', 'Hot Pink']); + await driver.sendKeys(Key.ESCAPE); + + cell = await gu.getCell({section: 'References', col: 'ColorCodes', rowNum: 2}).doClick(); + assert.equal(await cell.getText(), '#808080'); + await driver.sendKeys('#808080'); + assert.deepEqual(await getACOptions(5), + ['#808080', '#808000', '#800000', '#800080', '#87CEEB']); + await driver.sendKeys(Key.ESCAPE); + + cell = await gu.getCell({section: 'References', col: 'XNums', rowNum: 2}).doClick(); + assert.equal(await cell.getText(), '2019-04-29'); + await driver.sendKeys('2019-04-29'); + assert.deepEqual(await getACOptions(4), + ['2019-04-29', '2020-04-29', '2019-11-05', '2020-04-28']); + await driver.sendKeys(Key.ESCAPE); + }); + + it('should update choices as user types into textbox', async function() { + let cell = await gu.getCell({section: 'References', col: 'Schools', rowNum: 1}).doClick(); + assert.equal(await cell.getText(), 'TECHNOLOGY, ARTS AND SCIENCES STUDIO'); + await driver.sendKeys('TECHNOLOGY, ARTS AND SCIENCES STUDIO'); + assert.deepEqual(await getACOptions(3), [ + 'TECHNOLOGY, ARTS AND SCIENCES STUDIO', + 'SCIENCE AND TECHNOLOGY ACADEMY', + 'SCHOOL OF SCIENCE AND TECHNOLOGY', + ]); + await driver.sendKeys(Key.ESCAPE); + cell = await gu.getCell({section: 'References', col: 'Schools', rowNum: 2}).doClick(); + await driver.sendKeys('stuy'); + assert.deepEqual(await getACOptions(3), [ + 'STUYVESANT HIGH SCHOOL', + 'BEDFORD STUY COLLEGIATE CHARTER SCH', + 'BEDFORD STUY NEW BEGINNINGS CHARTER', + ]); + await driver.sendKeys(Key.BACK_SPACE); + assert.deepEqual(await getACOptions(3), [ + 'STUART M TOWNSEND MIDDLE SCHOOL', + 'STUDIO SCHOOL (THE)', + 'STUYVESANT HIGH SCHOOL', + ]); + await driver.sendKeys(' bre'); + assert.equal(await driver.find('.cell_editor .test-tokenfield .test-tokenfield-input').value(), 'stu bre'); + assert.deepEqual(await getACOptions(3), [ + 'ST BRENDAN SCHOOL', + 'BRONX STUDIO SCHOOL-WRITERS-ARTISTS', + 'BROOKLYN STUDIO SECONDARY SCHOOL', + ]); + + await driver.sendKeys(Key.DOWN, Key.ENTER, Key.ENTER); + await gu.waitForServer(); + assert.equal(await cell.getText(), 'ST BRENDAN SCHOOL'); + await gu.undo(); + assert.equal(await cell.getText(), ''); + }); + + it('should highlight matching parts of items', async function() { + await driver.sendKeys(Key.HOME); + + let cell = await gu.getCell({section: 'References', col: 'Colors', rowNum: 2}).doClick(); + assert.equal(await cell.getText(), 'Red'); + await driver.sendKeys(Key.ENTER, 'Red'); + await driver.findWait('.test-ref-editor-item', 1000); + assert.deepEqual( + await driver.findContent('.test-ref-editor-item', /Dark Red/).findAll('span', e => e.getText()), + ['Red']); + assert.deepEqual( + await driver.findContent('.test-ref-editor-item', /Rebecca Purple/).findAll('span', e => e.getText()), + ['Re']); + await driver.sendKeys(Key.ESCAPE); + + cell = await gu.getCell({section: 'References', col: 'Schools', rowNum: 1}).doClick(); + await driver.sendKeys('br tech'); + assert.deepEqual( + await driver.findContentWait('.test-ref-editor-item', /BROOKLYN TECH/, 1000).findAll('span', e => e.getText()), + ['BR', 'TECH']); + assert.deepEqual( + await driver.findContent('.test-ref-editor-item', /BUFFALO.*TECHNOLOGY/).findAll('span', e => e.getText()), + ['B', 'TECH']); + assert.deepEqual( + await driver.findContent('.test-ref-editor-item', /ENERGY TECH/).findAll('span', e => e.getText()), + ['TECH']); + await driver.sendKeys(Key.ESCAPE); + }); + + it('should reflect changes to the target column', async function() { + await driver.sendKeys(Key.HOME); + + const cell = await gu.getCell({section: 'References', col: 'Colors', rowNum: 4}).doClick(); + assert.equal(await cell.getText(), ''); + await driver.sendKeys(Key.ENTER); + assert.deepEqual(await getACOptions(2), ['Alice Blue', 'Añil']); + await driver.sendKeys(Key.ESCAPE); + + // Change a color + await gu.getCell({section: 'Colors', col: 'Color Name', rowNum: 1}).doClick(); + await driver.sendKeys('HAZELNUT', Key.ENTER); + await gu.waitForServer(); + + // See that the old value is gone from the autocomplete, and the new one is present. + await cell.click(); + await driver.sendKeys(Key.ENTER); + assert.deepEqual(await getACOptions(2), ['Añil', 'Aqua']); + await driver.sendKeys('H'); + assert.deepEqual(await getACOptions(2), ['HAZELNUT', 'Honeydew']); + await driver.sendKeys(Key.ESCAPE); + + // Delete a row. + await gu.getCell({section: 'Colors', col: 'Color Name', rowNum: 1}).doClick(); + await driver.find('body').sendKeys(Key.chord(await gu.modKey(), '-')); + await gu.waitForServer(); + + // See that the value is gone from the autocomplete. + await cell.click(); + await driver.sendKeys('H'); + assert.deepEqual(await getACOptions(2), ['Honeydew', 'Hot Pink']); + await driver.sendKeys(Key.ESCAPE); + + // Add a row. + await gu.getCell({section: 'Colors', col: 'Color Name', rowNum: 1}).doClick(); + await driver.find('body').sendKeys(Key.chord(await gu.modKey(), '=')); + await gu.waitForServer(); + await driver.sendKeys('HELIOTROPE', Key.ENTER); + await gu.waitForServer(); + + // See that the new value is visible in the autocomplete. + await cell.click(); + await driver.sendKeys('H'); + assert.deepEqual(await getACOptions(2), ['HELIOTROPE', 'Honeydew']); + await driver.sendKeys(Key.BACK_SPACE); + assert.deepEqual(await getACOptions(2), ['Añil', 'Aqua']); + await driver.sendKeys(Key.ESCAPE); + + // Undo all the changes. + await gu.undo(4); + + await cell.click(); + await driver.sendKeys('H'); + assert.deepEqual(await getACOptions(2), ['Honeydew', 'Hot Pink']); + await driver.sendKeys(Key.BACK_SPACE); + assert.deepEqual(await getACOptions(2), ['Alice Blue', 'Añil']); + await driver.sendKeys(Key.ESCAPE); + }); + }); +}); diff --git a/test/nbrowser/testServer.ts b/test/nbrowser/testServer.ts index 9e483376..ea3684cc 100644 --- a/test/nbrowser/testServer.ts +++ b/test/nbrowser/testServer.ts @@ -176,6 +176,9 @@ export class TestServerMerged implements IMochaServer { * request takes a long time. */ public async pauseUntil(callback: () => Promise) { + if (this.isExternalServer()) { + throw new Error("Can't pause external server"); + } log.info("Pausing node server"); this._server.kill('SIGSTOP'); try { diff --git a/test/test_under_docker.sh b/test/test_under_docker.sh index b0dda17e..e2db8131 100755 --- a/test/test_under_docker.sh +++ b/test/test_under_docker.sh @@ -3,7 +3,7 @@ # This runs browser tests with the server started using docker, to # catch any configuration problems. # Run with MOCHA_WEBDRIVER_HEADLESS=1 for headless operation. -# Run with VERBOSE=1 for server logs. +# Run with DEBUG=1 for server logs. # Settings for script robustness set -o pipefail # trace ERR through pipes @@ -28,11 +28,18 @@ cleanup() { exit $return_value } +GRIST_LOG_LEVEL="error" +if [[ "${DEBUG:-}" == 1 ]]; then + GRIST_LOG_LEVEL="" +fi + docker run --name $DOCKER_CONTAINER --rm \ - --env VERBOSE=${VERBOSE:-} \ + --env VERBOSE=${DEBUG:-} \ -p $PORT:$PORT --env PORT=$PORT \ --env GRIST_SESSION_COOKIE=grist_test_cookie \ --env GRIST_TEST_LOGIN=1 \ + --env GRIST_LOG_LEVEL=$GRIST_LOG_LEVEL \ + --env GRIST_LOG_SKIP_HTTP=${DEBUG:-false} \ --env TEST_SUPPORT_API_KEY=api_key_for_support \ ${TEST_IMAGE:-gristlabs/grist} & @@ -45,10 +52,16 @@ while true; do done echo "" echo "[server found]" +MOCHA=mocha +# Test if we have mocha available as a command +if ! type $MOCHA > /dev/null 2>&1; then + echo "Mocha not found, using from ./node_modules/.bin/mocha" + MOCHA=./node_modules/.bin/mocha +fi TEST_ADD_SAMPLES=1 TEST_ACCOUNT_PASSWORD=not-needed \ HOME_URL=http://localhost:8585 \ GRIST_SESSION_COOKIE=grist_test_cookie \ GRIST_TEST_LOGIN=1 \ NODE_PATH=_build:_build/stubs \ - mocha _build/test/nbrowser/*.js "$@" + $MOCHA _build/test/nbrowser/*.js -g ${GREP_TESTS:-''} "$@"