From 3c219e05f68c32add5d01a78dcdb152d65ac939c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jaros=C5=82aw=20Sadzi=C5=84ski?= Date: Mon, 6 Nov 2023 16:42:04 +0100 Subject: [PATCH] (core) Removing the new menu flag Summary: Enabling the `GRIST_NEW_COLUMN_MENU` flag by default and removing it. Test Plan: Existing Reviewers: georgegevoian Reviewed By: georgegevoian Differential Revision: https://phab.getgrist.com/D4098 --- app/client/components/GridView.js | 107 +- app/client/models/features.ts | 4 - app/client/ui/GridViewMenus.ts | 146 ++- app/common/gristUrls.ts | 3 - app/server/lib/sendAppPage.ts | 1 - buildtools/fly-template.toml | 1 - test/nbrowser/CellColor.ts | 3 + test/nbrowser/ColumnOps.ntest.js | 10 +- test/nbrowser/DescriptionColumn.ts | 1 + test/nbrowser/GridViewNewColumnMenu.ts | 1420 +++++++++++++++++++----- test/nbrowser/MultiColumn.ts | 40 +- test/nbrowser/ReferenceColumns.ts | 1 + test/nbrowser/ReferenceList.ts | 2 + test/nbrowser/TextEditor.ntest.js | 1 + test/nbrowser/gristUtils.ts | 108 +- 15 files changed, 1394 insertions(+), 454 deletions(-) diff --git a/app/client/components/GridView.js b/app/client/components/GridView.js index bd0f6381..13b5262c 100644 --- a/app/client/components/GridView.js +++ b/app/client/components/GridView.js @@ -44,7 +44,6 @@ const { buildAddColumnMenu, buildColumnContextMenu, buildMultiColumnMenu, - buildOldAddColumnMenu, calcFieldsCondition, freezeAction, } = require('app/client/ui/GridViewMenus'); @@ -56,7 +55,6 @@ const {NEW_FILTER_JSON} = require('app/client/models/ColumnFilter'); const {CombinedStyle} = require("app/client/models/Styles"); const {buildRenameColumn} = require('app/client/ui/ColumnTitle'); const {makeT} = require('app/client/lib/localization'); -const {GRIST_NEW_COLUMN_MENU} = require("../models/features"); const t = makeT('GridView'); @@ -311,14 +309,14 @@ GridView.gridCommands = { editField: function() { closeRegisteredMenu(); this.scrollToCursor(true); this.activateEditorAtCursor(); }, insertFieldBefore: function(maybeKeyboardEvent) { - if (GRIST_NEW_COLUMN_MENU() && !maybeKeyboardEvent) { + if (!maybeKeyboardEvent) { this._openInsertColumnMenu(this.cursor.fieldIndex()); } else { this.insertColumn(null, {index: this.cursor.fieldIndex()}); } }, insertFieldAfter: function(maybeKeyboardEvent) { - if (GRIST_NEW_COLUMN_MENU() && !maybeKeyboardEvent) { + if (!maybeKeyboardEvent) { this._openInsertColumnMenu(this.cursor.fieldIndex() + 1); } else { this.insertColumn(null, {index: this.cursor.fieldIndex() + 1}); @@ -1302,8 +1300,7 @@ GridView.prototype.buildDom = function() { testId('column-menu-trigger'), ), dom('div.selection'), - // FIXME: remove once New Column menu is enabled by default. - GRIST_NEW_COLUMN_MENU() ? this._buildInsertColumnMenu({field}) : null, + this._buildInsertColumnMenu({field}), ); }), this.isPreview ? null : kd.maybe(() => !this.gristDoc.isReadonlyKo(), () => ( @@ -2004,66 +2001,50 @@ GridView.prototype._scrollColumnIntoView = function(colIndex) { * the GridView. */ GridView.prototype._buildInsertColumnMenu = function(options = {}) { - if (GRIST_NEW_COLUMN_MENU()) { - const {field} = options; - const triggers = []; - if (!field) { triggers.push('click'); } - - return [ - field ? kd.toggleClass('field-insert-before', () => - this._insertColumnIndex() === field._index()) : null, - menu( - ctl => { - ctl.onDispose(() => this._insertColumnIndex(null)); - - let index = this._insertColumnIndex.peek(); - if (index === null || index === -1) { - index = undefined; - } + const {field} = options; + const triggers = []; + if (!field) { triggers.push('click'); } + + return [ + field ? kd.toggleClass('field-insert-before', () => + this._insertColumnIndex() === field._index()) : null, + menu( + ctl => { + ctl.onDispose(() => this._insertColumnIndex(null)); + + let index = this._insertColumnIndex.peek(); + if (index === null || index === -1) { + index = undefined; + } - return [ - buildAddColumnMenu(this, index), - elem => { FocusLayer.create(ctl, {defaultFocusElem: elem, pauseMousetrap: true}); }, - testId('new-columns-menu'), - ]; + return [ + buildAddColumnMenu(this, index), + elem => { FocusLayer.create(ctl, {defaultFocusElem: elem, pauseMousetrap: true}); }, + testId('new-columns-menu'), + ]; + }, + { + modifiers: { + offset: { + offset: '8,8', + }, }, - { - modifiers: { - offset: { - offset: '8,8', - }, + selectOnOpen: true, + trigger: [ + ...triggers, + (_, ctl) => { + ctl.autoDispose(this._insertColumnIndex.subscribe((index) => { + if (field?._index() === index || (!field && index === -1)) { + ctl.open(); + } else if (!ctl.isDisposed()) { + ctl.close(); + } + })); }, - selectOnOpen: true, - trigger: [ - ...triggers, - (_, ctl) => { - ctl.autoDispose(this._insertColumnIndex.subscribe((index) => { - if (field?._index() === index || (!field && index === -1)) { - ctl.open(); - } else if (!ctl.isDisposed()) { - ctl.close(); - } - })); - }, - ], - } - ), - ]; - } else { - // FIXME: remove once New Column menu is enabled by default. - return [ - dom.on('click', async ev => { - // If there are no hidden columns, clicking the plus just adds a new column. - // If there are hidden columns, display a dropdown menu. - if (this.viewSection.hiddenColumns().length === 0) { - // Don't open the menu defined below. - ev.stopImmediatePropagation(); - await this.insertColumn(); - } - }), - menu((() => buildOldAddColumnMenu(this, this.viewSection))), - ] - } + ], + } + ), + ]; } GridView.prototype._openInsertColumnMenu = function(columnIndex) { diff --git a/app/client/models/features.ts b/app/client/models/features.ts index a235a266..353fe0a3 100644 --- a/app/client/models/features.ts +++ b/app/client/models/features.ts @@ -25,10 +25,6 @@ export function WHICH_FORMULA_ASSISTANT() { return getGristConfig().assistantService; } -export function GRIST_NEW_COLUMN_MENU(){ - return Boolean(getGristConfig().gristNewColumnMenu); -} - export function PERMITTED_CUSTOM_WIDGETS(): Observable { const G = getBrowserGlobals('document', 'window'); if (!G.window.PERMITTED_CUSTOM_WIDGETS) { diff --git a/app/client/ui/GridViewMenus.ts b/app/client/ui/GridViewMenus.ts index dcdc26b4..40f05043 100644 --- a/app/client/ui/GridViewMenus.ts +++ b/app/client/ui/GridViewMenus.ts @@ -1,7 +1,6 @@ import {allCommands} from 'app/client/components/commands'; import GridView from 'app/client/components/GridView'; import {makeT} from 'app/client/lib/localization'; -import {ViewSectionRec} from 'app/client/models/DocModel'; import {ViewFieldRec} from 'app/client/models/entities/ViewFieldRec'; import {GristTooltips} from 'app/client/ui/GristTooltips'; import {withInfoTooltip} from 'app/client/ui/tooltips'; @@ -29,18 +28,6 @@ import isEqual = require('lodash/isEqual'); const t = makeT('GridViewMenus'); -// FIXME: remove once New Column menu is enabled by default. -export function buildOldAddColumnMenu(gridView: GridView, viewSection: ViewSectionRec) { - return [ - menuItem(async () => { await gridView.insertColumn(); }, t("Add Column")), - menuDivider(), - ...viewSection.hiddenColumns().map((col: any) => menuItem( - async () => { - await gridView.showColumn(col.id()); - }, t("Show column {{- label}}", {label: col.label()}))) - ]; -} - export function buildAddColumnMenu(gridView: GridView, index?: number) { const isSummaryTable = Boolean(gridView.viewSection.table().summarySourceTable()); return [ @@ -66,13 +53,14 @@ function buildHiddenColumnsMenuItems(gridView: GridView, index?: number) { if (hiddenColumns.length <= 5) { return [ menuDivider(), - menuSubHeader(t('Hidden Columns'), testId('new-columns-menu-hidden-columns')), + menuSubHeader(t('Hidden Columns'), testId('new-columns-menu-hidden-columns-header')), hiddenColumns.map((col: ColumnRec) => menuItem( async () => { await gridView.showColumn(col.id(), index); }, col.label(), + testId('new-columns-menu-hidden-column-inlined'), ) ), ]; @@ -84,13 +72,18 @@ function buildHiddenColumnsMenuItems(gridView: GridView, index?: number) { return searchableMenu( hiddenColumns.map((col) => ({ cleanText: col.label().trim().toLowerCase(), - builder: () => menuItemTrimmed(() => gridView.showColumn(col.id(), index), col.label()) + builder: () => menuItemTrimmed( + () => gridView.showColumn(col.id(), index), + col.label(), + testId('new-columns-menu-hidden-column-collapsed'), + ) })), {searchInputPlaceholder: t('Search columns')} ); }, {allowNothingSelected: true}, t('Hidden Columns'), + testId('new-columns-menu-hidden-columns-menu') ), ]; } @@ -145,7 +138,10 @@ function buildTimestampMenuItems(gridView: GridView, index?: number) { t("Apply on record changes"), testId('new-columns-menu-shortcuts-timestamp-change'), ), - ], {}, t("Timestamp"), testId('new-columns-menu-shortcuts-timestamp')); + ], {}, + t("Timestamp"), + testId('new-columns-menu-shortcuts-timestamp') + ); } function buildAuthorshipMenuItems(gridView: GridView, index?: number) { @@ -291,7 +287,7 @@ function buildUUIDMenuItem(gridView: GridView, index?: number) { ); } -function menuLabelWithToast(label: string, toast: string) { +function menuLabelWithBadge(label: string, toast: string) { return cssListLabel( cssListCol(label), cssListFun(toast)); @@ -332,7 +328,7 @@ function buildLookupSection(gridView: GridView, index?: number){ case 'count': case 'sum': return `SUM(${referenceToSource}.${col.colId()})`; case 'percent': - return `AVERAGE(map(int, ${referenceToSource}.${col.colId()})) if ${referenceToSource} else 0`; + return `AVERAGE(map(int, ${referenceToSource}.${col.colId()})) if ${referenceToSource} else None`; default: return `${referenceToSource}`; } } @@ -365,8 +361,6 @@ function buildLookupSection(gridView: GridView, index?: number){ }; } - - function buildLookupsMenuItems() { // Function that builds a menu for one of our Ref columns, we will show all columns // from the referenced table and offer to create a formula column with aggregation in case @@ -383,7 +377,7 @@ function buildLookupSection(gridView: GridView, index?: number){ } else { // For RefList column we will show the column name and the aggregation function which is the first // on of suggested action (and a default action). - label = menuLabelWithToast(col.label(), suggestAggregation(col)[0]); + label = menuLabelWithBadge(col.label(), suggestAggregation(col)[0]); } return { @@ -394,14 +388,36 @@ function buildLookupSection(gridView: GridView, index?: number){ function buildItem() { if (ref.pureType() === 'Ref') { // Just insert a plain menu item that will insert a formula column with lookup. - return menuItemTrimmed(() => insertPlainLookup(), col.label()); - } else { - // Built nested menu. - return menuItemSubmenu( - () => suggestAggregation(col).map((fun) => menuItem(() => insertAggLookup(fun), fun)), - {}, - label + return menuItemTrimmed( + () => insertPlainLookup(), col.label(), + testId(`new-columns-menu-lookup-column`), + testId(`new-columns-menu-lookup-column-${col.colId()}`), ); + } else { + // Depending on the number of aggregation functions we will either create a plain menu item + // or submenu with all the functions. + const functions = suggestAggregation(col); + if (functions.length === 1) { + const action = () => insertAggLookup(functions[0]); + return menuItem(action, label, + testId(`new-columns-menu-lookup-column`), + testId(`new-columns-menu-lookup-column-${col.colId()}`) + ); + } else { + return menuItemSubmenu( + () => functions.map((fun) => menuItem( + () => insertAggLookup(fun), fun, + testId(`new-columns-menu-lookup-submenu-function`), + testId(`new-columns-menu-lookup-submenu-function-${fun}`), + )), + { + action: () => insertAggLookup(suggestAggregation(col)[0]), + }, + label, + testId(`new-columns-menu-lookup-submenu`), + testId(`new-columns-menu-lookup-submenu-${col.colId()}`), + ); + } } } @@ -451,7 +467,8 @@ function buildLookupSection(gridView: GridView, index?: number){ ), {allowNothingSelected: true}, `${ref.refTable()?.tableNameDef()} [${ref.label()}]`, - testId(`new-columns-menu-lookups-${ref.colId()}`), + testId(`new-columns-menu-lookup-${ref.colId()}`), + testId(`new-columns-menu-lookup`), )); } @@ -463,6 +480,7 @@ function buildLookupSection(gridView: GridView, index?: number){ } function buildReverseLookupsMenuItems() { + const getReferencesToThisTable = (): RefTable[] => { const {viewSection} = gridView; const otherTables = gridView.gristDoc.docModel.allTables.all().filter((tab) => @@ -473,16 +491,16 @@ function buildLookupSection(gridView: GridView, index?: number){ tableName: tab.tableNameDef(), columns: tab.visibleColumns(), referenceFields: - tab.columns().peek().filter((c) => (c.pureType() === 'Ref' || c.pureType() == 'RefList') && + tab.visibleColumns.peek().filter((c) => (c.pureType() === 'Ref' || c.pureType() == 'RefList') && c.refTable()?.tableId() === viewSection.tableId()) }; }) .filter((tab) => tab.referenceFields.length > 0); }; - const buildColumn = async (tab: RefTable, col: ColumnRec, refCol: ColumnRec, aggregate: string) => { - const formula = `${tab.tableId}.lookupRecords(${refCol.colId()}= - ${refCol.pureType() == 'RefList' ? 'CONTAINS($id)' : '$id'})`; + const insertColumn = async (tab: RefTable, col: ColumnRec, refCol: ColumnRec, aggregate: string) => { + const formula = + `${tab.tableId}.lookupRecords(${refCol.colId()}=${refCol.pureType() == 'RefList' ? 'CONTAINS($id)' : '$id'})`; await gridView.insertColumn(`${tab.tableId}_${col.label()}`, { colInfo: { label: `${tab.tableId}_${col.label()}`, @@ -495,41 +513,51 @@ function buildLookupSection(gridView: GridView, index?: number){ }); }; - const buildSubmenuForRevLookup = (tab: RefTable, refCol: any) => { + const tablesWithAnyRefColumn = getReferencesToThisTable(); + return tablesWithAnyRefColumn.map((tab: RefTable) => tab.referenceFields.map((refCol) => { const buildSubmenuForRevLookupMenuItem = (col: ColumnRec): SearchableMenuItem => { - const suggestedColumns = suggestAggregation(col); - const primarySuggestedColumn = suggestedColumns[0]; - + const aggregationList = suggestAggregation(col); + const firstAggregation = aggregationList[0]; + if (!firstAggregation) { + throw new Error(`No aggregation suggested for column ${col.label()}`); + } return { cleanText: col.label().trim().toLowerCase(), builder: () => { - if (suggestedColumns.length === 1) { - return menuItem(() => buildColumn(tab, col, refCol, primarySuggestedColumn), - menuLabelWithToast(col.label(), primarySuggestedColumn)); + const content = menuLabelWithBadge(col.label(), firstAggregation); + // In case we have only one suggested column we will just insert it, and there is no, + // need for submenu. + if (aggregationList.length === 1) { + const action = () => insertColumn(tab, col, refCol, firstAggregation); + return menuItem(action, content, testId('new-columns-menu-revlookup-column')); } else { - return menuItemSubmenu((ctl) => - suggestedColumns.map(fun => - menuItem(async () => - buildColumn(tab, col, refCol, fun), t(fun))) - , {}, menuLabelWithToast(col.label(), primarySuggestedColumn)); + // We have some other suggested columns, we will build submenu for them. + const submenu = () => { + const items = aggregationList.map((fun) => { + const action = () => insertColumn(tab, col, refCol, fun); + return menuItem(action, fun, testId('new-columns-menu-revlookup-column-function')); + }); + return items; + }; + const options = {}; + return menuItemSubmenu( + submenu, + options, + content, + testId('new-columns-menu-revlookup-submenu'), + ); } } }; }; - - return menuItemSubmenu( - () => - searchableMenu( - tab.columns.map(col => buildSubmenuForRevLookupMenuItem(col)), - {searchInputPlaceholder: t('Search columns')} - ), - {allowNothingSelected: true}, `${tab.tableName} [← ${refCol.label()}]`); - }; - - const tablesWithAnyRefColumn = getReferencesToThisTable(); - return tablesWithAnyRefColumn.map((tab: RefTable) => tab.referenceFields.map((refCol) => - buildSubmenuForRevLookup(tab, refCol) - )); + const label = `${tab.tableName} [← ${refCol.label()}]`; + const options = {allowNothingSelected: true}; + const submenu = () => { + const subItems = tab.columns.map(buildSubmenuForRevLookupMenuItem); + return searchableMenu(subItems, {searchInputPlaceholder: t('Search columns')}); + }; + return menuItemSubmenu(submenu, options, label, testId('new-columns-menu-revlookup')); + })); } const lookupMenu = buildLookupsMenuItems(); diff --git a/app/common/gristUrls.ts b/app/common/gristUrls.ts index 3ab254c9..dc2a5a27 100644 --- a/app/common/gristUrls.ts +++ b/app/common/gristUrls.ts @@ -701,9 +701,6 @@ export interface GristLoadConfig { permittedCustomWidgets?: IAttachedCustomWidget[]; - // Feature flag for the new column menu. - gristNewColumnMenu?: boolean; - // Used to determine which disclosure links should be provided to user of // formula assistance. assistantService?: 'OpenAI' | undefined; diff --git a/app/server/lib/sendAppPage.ts b/app/server/lib/sendAppPage.ts index dac64ffc..918f2662 100644 --- a/app/server/lib/sendAppPage.ts +++ b/app/server/lib/sendAppPage.ts @@ -80,7 +80,6 @@ export function makeGristConfig(options: MakeGristConfigOptions): GristLoadConfi featureFormulaAssistant: Boolean(process.env.OPENAI_API_KEY || process.env.ASSISTANT_CHAT_COMPLETION_ENDPOINT), assistantService: process.env.OPENAI_API_KEY ? 'OpenAI' : undefined, permittedCustomWidgets: getPermittedCustomWidgets(server), - gristNewColumnMenu: isAffirmative(process.env.GRIST_NEW_COLUMN_MENU), supportEmail: SUPPORT_EMAIL, userLocale: (req as RequestWithLogin | undefined)?.user?.options?.locale, telemetry: server?.getTelemetry().getTelemetryConfig(req as RequestWithLogin | undefined), diff --git a/buildtools/fly-template.toml b/buildtools/fly-template.toml index a56a4d84..2345f059 100644 --- a/buildtools/fly-template.toml +++ b/buildtools/fly-template.toml @@ -10,7 +10,6 @@ processes = [] APP_STATIC_URL="https://{APP_NAME}.fly.dev" ALLOWED_WEBHOOK_DOMAINS="webhook.site" PERMITTED_CUSTOM_WIDGETS="calendar" - GRIST_NEW_COLUMN_MENU="true" GRIST_SINGLE_ORG="docs" PORT = "8080" FLY_DEPLOY_EXPIRATION = "{FLY_DEPLOY_EXPIRATION}" diff --git a/test/nbrowser/CellColor.ts b/test/nbrowser/CellColor.ts index 6ef7ad97..ecae91c4 100644 --- a/test/nbrowser/CellColor.ts +++ b/test/nbrowser/CellColor.ts @@ -461,6 +461,7 @@ describe('CellColor', function() { it('should handle correctly default text color', async function() { // Create new checkbox column await driver.find('.mod-add-column').click(); + await driver.find('.test-new-columns-menu-add-new').click(); await gu.waitForServer(); await gu.setType(/Toggle/); @@ -497,6 +498,8 @@ describe('CellColor', function() { // create a new checkbox column await driver.find('.mod-add-column').click(); + await driver.find('.test-new-columns-menu-add-new').click(); + await gu.waitForServer(); await gu.setType(/Toggle/); diff --git a/test/nbrowser/ColumnOps.ntest.js b/test/nbrowser/ColumnOps.ntest.js index 3bed2e28..a8cac868 100644 --- a/test/nbrowser/ColumnOps.ntest.js +++ b/test/nbrowser/ColumnOps.ntest.js @@ -18,6 +18,7 @@ describe('ColumnOps.ntest', function() { it("should allow adding and deleting columns", async function() { await gu.clickColumnMenuItem('Name', 'Insert column to the right'); + await $('.test-new-columns-menu-add-new').click(); await gu.waitForServer(); // Newly created columns labels become editable automatically. The next line checks that the // label is editable and then closes the editor. @@ -78,7 +79,7 @@ describe('ColumnOps.ntest', function() { // Then show it using the add column menu await $('.mod-add-column').scrollIntoView(true); await $(".mod-add-column").click(); - await gu.actions.selectFloatingOption('Show column Name'); + await showColumn('Name'); await gu.waitForServer(); await assert.isPresent(gu.getColumnHeader('Name'), true); }); @@ -86,13 +87,14 @@ describe('ColumnOps.ntest', function() { it("[+] button show add column directly if no hidden columns", async function() { await $('.mod-add-column').scrollIntoView(true); await $(".mod-add-column").click(); - await gu.actions.selectFloatingOption('Show column Pop'); + await showColumn("Pop"); await gu.waitForServer(); await assert.isPresent(gu.getColumnHeader("Pop. '000"), true); await assert.isPresent(gu.getColumnHeader('B'), false); await $('.mod-add-column').scrollIntoView(true); await $(".mod-add-column").click(); + await $('.test-new-columns-menu-add-new').click(); await gu.waitToPass(() => gu.getColumnHeader('B')); await gu.getOpenEditingLabel(await gu.getColumnHeader('B')).wait().sendKeys($.ENTER); await gu.waitForServer(); @@ -273,3 +275,7 @@ describe('ColumnOps.ntest', function() { }); }); + +function showColumn(name) { + return $(`.test-new-columns-menu-hidden-column-inlined:contains(${name})`).click(); +} diff --git a/test/nbrowser/DescriptionColumn.ts b/test/nbrowser/DescriptionColumn.ts index 83b1deb4..cd14102e 100644 --- a/test/nbrowser/DescriptionColumn.ts +++ b/test/nbrowser/DescriptionColumn.ts @@ -577,6 +577,7 @@ async function clickAddDescription() { async function addColumn() { await driver.find(".mod-add-column").click(); + await driver.find('.test-new-columns-menu-add-new').click(); await gu.waitForServer(); } diff --git a/test/nbrowser/GridViewNewColumnMenu.ts b/test/nbrowser/GridViewNewColumnMenu.ts index e49b85b4..a97e9aff 100644 --- a/test/nbrowser/GridViewNewColumnMenu.ts +++ b/test/nbrowser/GridViewNewColumnMenu.ts @@ -1,372 +1,1220 @@ -import {driver, Key, WebElement} from "mocha-webdriver"; -import {DocCreationInfo} from "app/common/DocListAPI"; -import {UserAPIImpl} from "app/common/UserAPI"; +import {driver, Key} from "mocha-webdriver"; import {assert} from "chai"; import * as gu from "./gristUtils"; import {setupTestSuite} from "./testUtils"; - +import {UserAPIImpl} from 'app/common/UserAPI'; describe('GridViewNewColumnMenu', function () { - if(process.env.GRIST_NEW_COLUMN_MENU) { - this.timeout('5m'); - const cleanup = setupTestSuite(); + this.timeout('2m'); + const cleanup = setupTestSuite(); + let api: UserAPIImpl; + let docId: string; - //helpers - let session: gu.Session, doc: DocCreationInfo, apiImpl: UserAPIImpl; + before(async function () { + const session = await gu.session().login(); + api = session.createHomeApi(); + docId = await session.tempNewDoc(cleanup, 'ColumnMenu'); - before(async function () { - session = await gu.session().login(); - await createEmptyDoc('ColumnMenu'); + // Add a table that will be used for lookups. + await gu.sendActions([ + ['AddTable', 'Person', [ + {id: "Name"}, + {id: "Age", type: 'Numeric'}, + {id: 'Hobby', type: 'ChoiceList', widgetOptions: JSON.stringify({choices: ['Books', 'Cars']})}, + {id: 'Employee', type: 'Choice', widgetOptions: JSON.stringify({choices: ['Y', 'N']})}, + {id: "Birthday date", type: 'Date', label: 'Birthday date'}, + {id: "Member", type: 'Bool'}, + {id: "SeenAt", type: 'DateTime:UTC'}, + {id: "Photo", type: 'Attachments'}, + {id: "Fun", type: 'Any', formula: '44'}, + {id: 'Parent', type: 'Ref:Person'}, + {id: 'Children', type: 'RefList:Person'}, + ]], + ['AddRecord', 'Person', null, {Name: "Bob", Age: 12}], + ['AddRecord', 'Person', null, {Name: "Robert", Age: 34, Parent: 1}], + ]); + }); + + describe('sections', function () { + revertEach(); + + it('looks ok for an empty document', async function () { + await clickAddColumn(); + await hasAddNewColumMenu(); + await hasShortcuts(); + await closeAddColumnMenu(); }); - this.afterEach(async function () { + it('has lookup columns', async function () { + await gu.sendActions([ + // Create a table that we can reference to. + ['AddTable', 'Reference', [ + {id: "Name"}, + {id: "Age"}, + {id: "City"} + ]], + // Add some data to the table. + ['AddRecord', 'Reference', null, {Name: "Bob", Age: 12, City: "New York"}], + ['AddRecord', 'Reference', null, {Name: "Robert", Age: 34, City: "Łódź"}], + // And a Ref column in the main table to that table. + ['AddColumn', 'Table1', 'Reference', {type: 'Ref:Reference'}], + ]); + + await clickAddColumn(); + await hasAddNewColumMenu(); + await hasShortcuts(); + await hasLookupMenu('Reference'); await closeAddColumnMenu(); }); - describe('menu composition', function () { + }); - it('simple columns, should have add column and shortcuts', async function () { - const menu = await openAddColumnIcon(); - await hasAddNewColumMenu(menu); - await hasShortcuts(menu); - }); + describe('column creation', function () { + revertEach(); - it('have lookup columns, should have add column, shortcuts and lookup section ', async function () { - const createReferenceTable = async () => { - await apiImpl.applyUserActions(doc.id, [ - ['AddTable', 'Reference', [ - {id: "Name"}, - {id: "Age"}, - {id: "City"}]], - ]); - await apiImpl.applyUserActions(doc.id, [ - ['AddRecord', 'Reference', null, {Name: "Bob", Age: 12, City: "New York"}], - ['AddRecord', 'Reference', null, {Name: "Robert", Age: 34, City: "Łódź"}], - ]); - }; + it('should show rename menu after a new column click', async function () { + await clickAddColumn(); + await driver.findWait('.test-new-columns-menu-add-new', 100).click(); + await driver.findWait('.test-column-title-popup', 100, 'rename menu is not present'); + await closeAddColumnMenu(); + }); + + it('should create a new column', async function () { + await clickAddColumn(); + await driver.findWait('.test-new-columns-menu-add-new', 100).click(); + //discard rename menu + await driver.findWait('.test-column-title-close', 100).click(); + //check if new column is present + const columns = await gu.getColumnNames(); + assert.include(columns, 'D', 'new column is not present'); + assert.lengthOf(columns, 4, 'wrong number of columns'); + + // check that single undo removes new column + await gu.undo(); + + const columns2 = await gu.getColumnNames(); + assert.notInclude(columns2, 'D', 'new column is still present'); + assert.lengthOf(columns2, 3, 'wrong number of columns'); + }); + + it('should support inserting before selected column', async function () { + await gu.openColumnMenu('A', 'Insert column to the left'); + await driver.findWait(".test-new-columns-menu", 100); + await gu.sendKeys(Key.ENTER); + await gu.waitForServer(); + await driver.findWait('.test-column-title-close', 100).click(); + const columns = await gu.getColumnNames(); + assert.deepEqual(columns, ['D', 'A', 'B', 'C']); + }); + + it('should support inserting after selected column', async function () { + await gu.openColumnMenu('A', 'Insert column to the right'); + await driver.findWait(".test-new-columns-menu", 100); + await gu.sendKeys(Key.ENTER); + await gu.waitForServer(); + await driver.findWait('.test-column-title-close', 100).click(); + const columns = await gu.getColumnNames(); + assert.deepEqual(columns, ['A', 'D', 'B', 'C']); + }); + + it('should support inserting after the last visible column', async function () { + await gu.openColumnMenu('C', 'Insert column to the right'); + await driver.findWait(".test-new-columns-menu", 100); + await gu.sendKeys(Key.ENTER); + await gu.waitForServer(); + await driver.findWait('.test-column-title-close', 100).click(); + const columns = await gu.getColumnNames(); + assert.deepEqual(columns, ['A', 'B', 'C', 'D']); + }); + + it('should skip showing menu when inserting with keyboard shortcuts', async function () { + await gu.sendKeys(Key.chord(Key.ALT, '=')); + await gu.waitForServer(); + assert.isFalse(await driver.find('.test-new-columns-menu').isPresent()); + await gu.sendKeys(Key.ENTER); + let columns = await gu.getColumnNames(); + assert.deepEqual(columns, ['A', 'B', 'C', 'D']); + await gu.sendKeys(Key.chord(Key.SHIFT, Key.ALT, '=')); + await gu.waitForServer(); + assert.isFalse(await driver.find('.test-new-columns-menu').isPresent()); + await gu.sendKeys(Key.ENTER); + columns = await gu.getColumnNames(); + assert.deepEqual(columns, ['A', 'B', 'C', 'E', 'D']); + }); + }); + + describe('hidden columns', function () { + revertThis(); + + it('hides hidden column section from < 5 columns', async function () { + await gu.sendActions([ + ['AddVisibleColumn', 'Table1', 'New1', {type: 'Any'}], + ['AddVisibleColumn', 'Table1', 'New2', {type: 'Any'}], + ['AddVisibleColumn', 'Table1', 'New3', {type: 'Any'}], + ]); + await gu.openWidgetPanel(); + await clickAddColumn(); + assert.isFalse(await driver.find(".new-columns-menu-hidden-columns").isPresent(), 'hidden section is present'); + await closeAddColumnMenu(); + }); + + describe('inline menu section', function () { + revertEach(); - const addReferenceColumnToManinTable = async () => { - //add reference column - await apiImpl.applyUserActions(doc.id, [ - ['AddColumn', 'Table1', 'Reference', {type: 'Ref:Reference'}], - ]); + it('shows hidden section as inlined for 1 to 5 hidden columns', async function () { + // Check that the hidden section is present and has the expected columns. + const checkSection = async (...columns: string[]) => { + await clickAddColumn(); + await driver.findWait(".test-new-columns-menu-hidden-columns-header", 100, 'hidden section is not present'); + for (const column of columns) { + assert.isTrue( + await driver.findContent('.test-new-columns-menu-hidden-column-inlined', column).isPresent(), + `column ${column} is not present` + ); + } + await closeAddColumnMenu(); }; - await createReferenceTable(); - await addReferenceColumnToManinTable(); - await gu.reloadDoc(); - - //open menu - const menu = await openAddColumnIcon(); - // check if all three sections are present - await hasAddNewColumMenu(menu); - await hasShortcuts(menu); - await hasLookupMenu(menu, 'Reference'); - //TODO - remove reference column somehow. - await apiImpl.applyUserActions(doc.id, [["RemoveColumn", "Table1", "Reference"]]); - await gu.reloadDoc(); + await gu.moveToHidden('A'); + await checkSection('A'); + await gu.moveToHidden('B'); + await gu.moveToHidden('C'); + await gu.moveToHidden('New1'); + await gu.moveToHidden('New2'); + await checkSection('A', 'B', 'C', 'New1', 'New2'); + }); + + it('should add hidden column at the end', async function () { + let columns = await gu.getColumnNames(); + assert.deepEqual(columns, ['A', 'B', 'C', 'New1', 'New2', 'New3']); + + // Hide 'A' and add it back. + await gu.moveToHidden('A'); + await clickAddColumn(); + await driver.findContent('.test-new-columns-menu-hidden-column-inlined', 'A').click(); + await gu.waitForServer(); + + // Now check that the column was added at the end. + columns = await gu.getColumnNames(); + assert.deepEqual(columns, ['B', 'C', 'New1', 'New2', 'New3', 'A']); }); }); - describe('column creation', function () { - it('should show rename menu after new column click', async function () { - const menu = await openAddColumnIcon(); - await menu.findWait('.test-new-columns-menu-add-new', 100).click(); - await driver.findWait('.test-column-title-popup', 100, 'rename menu is not present'); - await gu.undo(); + describe('submenu section', function () { + before(async function () { + await gu.sendActions([ + ['AddVisibleColumn', 'Table1', 'New4', {type: 'Any'}], + ]); }); - it('should create new column', async function () { - const menu = await openAddColumnIcon(); - await menu.findWait('.test-new-columns-menu-add-new', 100).click(); - //discard rename menu - await driver.findWait('.test-column-title-close', 100).click(); - //check if new column is present + after(async function () { + await gu.sendActions([ + ['RemoveColumn', 'Table1', 'New4'], + ]); + }); + + it('more than 5 hidden columns, section should be in submenu', async function () { + // Hide all columns except A. const columns = await gu.getColumnNames(); - assert.include(columns, 'D', 'new column is not present'); - assert.lengthOf(columns, 4, 'wrong number of columns'); + for (const column of columns.slice(1)) { + await gu.moveToHidden(column); + } + + // Make sure they are hidden. + assert.deepEqual(await gu.getColumnNames(), ['A']); + + // Now make sure we see all of them in the submenu. + await clickAddColumn(); + await driver.findWait(".test-new-columns-menu-hidden-columns-menu", 100, 'hidden section is not present'); + assert.isFalse(await driver.find(".test-new-columns-menu-hidden-columns-header").isPresent()); + + // We don't see any hidden columns in the main menu. + assert.isFalse(await driver.find(".test-new-columns-menu-hidden-column-inlined").isPresent()); + + // Now expand the submenu and check that we see all the hidden columns. + await driver.find(".test-new-columns-menu-hidden-columns-menu").click(); + + // And we should see all the hidden columns. + for (const column of columns.slice(1)) { + assert.isTrue( + await driver.findContentWait('.test-new-columns-menu-hidden-column-collapsed', column, 100).isDisplayed(), + `column ${column} is not present` + ); + } + + // Add B column. + await driver.findContent('.test-new-columns-menu-hidden-column-collapsed', 'B').click(); + await gu.waitForServer(); + + // Now check that the column was added at the end. + const columns2 = await gu.getColumnNames(); + assert.deepEqual(columns2, ['A', 'B']); + + // Hide it again. await gu.undo(); }); - it('should support inserting before selected column', async function () { - await gu.openColumnMenu('A', 'Insert column to the left'); - await driver.findWait(".test-new-columns-menu", 100); - await gu.sendKeys(Key.ENTER); + it('submenu should be searchable', async function () { + await clickAddColumn(); + await driver.find(".test-new-columns-menu-hidden-columns-menu").click(); + await driver.findWait('.test-searchable-menu-input', 100).click(); + await gu.sendKeys('New'); + await checkResult(['New1', 'New2', 'New3', 'New4']); + + await gu.sendKeys('2'); + await checkResult(['New2']); + + await gu.sendKeys('dummy'); + await checkResult([]); + + await gu.clearInput(); + await checkResult(['B', 'C', 'New1', 'New2', 'New3', 'New4']); + + await gu.sendKeys(Key.ESCAPE); + assert.isFalse(await isMenuPresent()); + + // Show it once again and add B and C. + await clickAddColumn(); + await driver.find(".test-new-columns-menu-hidden-columns-menu").click(); + await driver.findContentWait('.test-new-columns-menu-hidden-column-collapsed', 'B', 100).click(); await gu.waitForServer(); - await driver.findWait('.test-column-title-close', 100).click(); + + await clickAddColumn(); + // Now this column is inlined. + await driver.findContentWait(".test-new-columns-menu-hidden-column-inlined", 'C', 100).click(); + await gu.waitForServer(); + + // Make sure they are added at the end. const columns = await gu.getColumnNames(); - assert.deepEqual(columns, ['D', 'A', 'B', 'C']); - await gu.undo(); + assert.deepEqual(columns, ['A', 'B', 'C']); + + async function checkResult(cols: string[]) { + await gu.waitToPass(async () => { + assert.deepEqual( + await collapsedHiddenColumns(), + cols + ); + }, 250); + } }); + }); + }); - it('should support inserting after selected column', async function () { - await gu.openColumnMenu('A', 'Insert column to the right'); - await driver.findWait(".test-new-columns-menu", 100); - await gu.sendKeys(Key.ENTER); + const COLUMN_LABELS = [ + "Name", "Age", "Hobby", "Employee", "Birthday date", "Member", "SeenAt", "Photo", "Fun", "Parent", "Children" + ]; + + describe('lookups from Reference columns', function () { + revertThis(); + + before(async function () { + await gu.sendActions([ + ['AddVisibleColumn', 'Table1', 'Person', {type: 'Ref:Person'}], + ['AddVisibleColumn', 'Table1', 'Employees', {type: 'RefList:Person'}], + ]); + await gu.openColumnPanel(); + + // Add color to the name column to make sure it is not added to the lookup menu. + await gu.openPage('Person'); + await gu.getCell('Name', 1).click(); + await gu.openCellColorPicker(); + await gu.setFillColor('#FD8182'); + await driver.find('.test-colors-save').click(); + await gu.waitForServer(); + + // And add conditional rule here. We will test if style rules are not copied over. + await gu.addInitialStyleRule(); + await gu.openStyleRuleFormula(0); + await gu.sendKeys('True'); + await gu.sendKeys(Key.ENTER); + await gu.waitForServer(); + + await gu.openCellColorPicker(0); + await gu.setFillColor('#FD8182'); + await driver.find('.test-colors-save').click(); + await gu.waitForServer(); + + await gu.openPage('Table1'); + }); + + it('should show only 2 reference columns', async function () { + await clickAddColumn(); + await gu.waitToPass(async () => { + const labels = await driver.findAll('.test-new-columns-menu-lookup', (el) => el.getText()); + assert.deepEqual( + labels, + ['Person [Person]', 'Person [Employees]'], + ); + }); + await closeAddColumnMenu(); + }); + + it('should suggest to add every column from a reference', async function () { + await clickAddColumn(); + await driver.findWait('.test-new-columns-menu-lookup-Person', 100).click(); + await gu.waitToPass(async () => { + const allColumns = await driver.findAll('.test-new-columns-menu-lookup-column', (el) => el.getText()); + assert.deepEqual(allColumns, COLUMN_LABELS); + }); + await closeAddColumnMenu(); + }); + + // Now add each column and make sure it is added with a proper name. + for(const column of COLUMN_LABELS) { + it(`should insert ${column} with a proper name and type from a Ref column`, async function () { + const revert = await gu.begin(); + await clickAddColumn(); + await driver.findWait('.test-new-columns-menu-lookup-Person', 100).click(); + await driver.findContentWait(`.test-new-columns-menu-lookup-column`, column, 100).click(); await gu.waitForServer(); - await driver.findWait('.test-column-title-close', 100).click(); + const columns = await gu.getColumnNames(); - assert.deepEqual(columns, ['A', 'D', 'B', 'C']); - await gu.undo(); + assert.deepEqual(columns, ['A', 'B', 'C', 'Person', 'Employees', `Person_${column}`]); + + // This should be a formula column. + assert.equal(await gu.columnBehavior(), "Formula Column"); + + // And the formula should be correct. + await driver.find('.formula_field_sidepane').click(); + assert.equal(await gu.getFormulaText(), `$Person.${column.replace(" ", "_")}`); + await gu.sendKeys(Key.ESCAPE); + + switch (column) { + case "Name": + // This should be a text column. + assert.equal(await gu.getType(), 'Text'); + // We should have color but no rules. + await gu.openCellColorPicker(); + assert.equal(await driver.find(".test-fill-hex").value(), '#FD8182'); + await driver.find('.test-colors-cancel').click(); + assert.equal(0, await gu.styleRulesCount()); + break; + case "Age": + // This should be a numeric column. + assert.equal(await gu.getType(), 'Numeric'); + break; + case "Hobby": { + // This should be a choice column. + assert.equal(await gu.getType(), 'Choice List'); + // And the choices should be correct. + const labels = await driver.findAll('.test-choice-list-entry-label', el => el.getText()); + assert.deepEqual(labels, ['Books', 'Cars']); + break; + } + case "Employee": { + // This should be a choice column. + assert.equal(await gu.getType(), 'Choice'); + // And the choices should be correct. + const labels = await driver.findAll('.test-choice-list-entry-label', el => el.getText()); + assert.deepEqual(labels, ['Y', 'N']); + break; + } + case "Birthday date": + // This should be a date column. + assert.equal(await gu.getType(), 'Date'); + break; + case "Member": + // This should be a boolean column. + assert.equal(await gu.getType(), 'Toggle'); + break; + case "SeenAt": + // This should be a datetime column. + assert.equal(await gu.getType(), 'DateTime'); + assert.equal(await driver.find(".test-tz-autocomplete input").value(), 'UTC'); + break; + case "Photo": + // This should be an attachment column. + assert.equal(await gu.getType(), 'Attachment'); + break; + case "Fun": + // This should be an any column. + assert.equal(await gu.getType(), 'Any'); + break; + case "Parent": + // This should be a ref column. + assert.equal(await gu.getType(), 'Reference'); + // With a proper table. + assert.equal(await gu.getRefTable(), 'Person'); + // And a proper column. + assert.equal(await gu.getRefShowColumn(), 'Row ID'); + break; + case "Children": + // This should be a ref list column. + assert.equal(await gu.getType(), 'Reference List'); + // With a proper table. + assert.equal(await gu.getRefTable(), 'Person'); + // And a proper column. + assert.equal(await gu.getRefShowColumn(), 'Row ID'); + break; + } + + await revert(); }); + } - it('should support inserting after the last visible column', async function () { - await gu.openColumnMenu('C', 'Insert column to the right'); - await driver.findWait(".test-new-columns-menu", 100); - await gu.sendKeys(Key.ENTER); + it('should suggest aggregations for RefList column', async function () { + await clickAddColumn(); + await driver.findWait('.test-new-columns-menu-lookup-Employees', 100).click(); + // Wait for the menu to appear. + await driver.findWait('.test-new-columns-menu-lookup-column', 100); + // First check items (so columns we can add which don't have menu) + const items = await driver.findAll('.test-new-columns-menu-lookup-column', (el) => el.getText()); + assert.deepEqual(items, [ + 'Name\nlist', + 'Hobby\nlist', + 'Employee\nlist', + 'Photo\nlist', + 'Fun\nlist', + 'Parent\nlist', + 'Children\nlist' + ]); + + const menus = await driver.findAll('.test-new-columns-menu-lookup-submenu', (el) => el.getText()); + assert.deepEqual(menus, [ + 'Age\nsum', + 'Birthday date\nlist', + 'Member\ncount', + 'SeenAt\nlist' + ]); + + // Make sure that clicking on a column adds it with a default aggregation. + await driver.find('.test-new-columns-menu-lookup-column-Name').click(); + await gu.waitForServer(); + const columns = await gu.getColumnNames(); + assert.deepEqual(columns, ['A', 'B', 'C', 'Person', 'Employees', 'Employees_Name']); + await checkTypeAndFormula('Any', `$Employees.Name`); + + await gu.undo(); + }); + + // Now test each aggregation. + for(const column of ['Age', 'Member', 'Birthday date', 'SeenAt']) { + it(`should insert ${column} with a proper name and type from a RefList column`, async function () { + const colId = column.replace(" ", "_"); + + await clickAddColumn(); + await driver.findWait('.test-new-columns-menu-lookup-Employees', 100).click(); + await driver.findWait(`.test-new-columns-menu-lookup-submenu-${colId}`, 100).mouseMove(); + + // Wait for the menu to show up. + await driver.findWait('.test-new-columns-menu-lookup-submenu-function', 100); + + // Make sure the list of function is accurate. + const suggestedFunctions = + await driver.findAll('.test-new-columns-menu-lookup-submenu-function', (el) => el.getText()); + + switch(column) { + case "Age": + assert.deepEqual(suggestedFunctions, ['sum', 'average', 'min', 'max']); + break; + case "Birthday date": + assert.deepEqual(suggestedFunctions, ['list', 'min', 'max']); + break; + case "Member": + assert.deepEqual(suggestedFunctions, ['count', 'percent']); + break; + case "SeenAt": + assert.deepEqual(suggestedFunctions, ['list', 'min', 'max']); + break; + } + + // Now pick the default function. + await driver.findWait(`.test-new-columns-menu-lookup-submenu-${colId}`, 100).click(); await gu.waitForServer(); - await driver.findWait('.test-column-title-close', 100).click(); + const columns = await gu.getColumnNames(); - assert.deepEqual(columns, ['A', 'B', 'C', 'D']); + assert.deepEqual(columns, ['A', 'B', 'C', 'Person', 'Employees', `Employees_${column}`]); + + // This should be a formula column. + assert.equal(await gu.columnBehavior(), "Formula Column"); + + // And the formula should be correct. + switch(column) { + case "Age": + await checkTypeAndFormula('Numeric', `SUM($Employees.${colId})`); + + // For this column test other aggregations as well. + await gu.undo(); + await addRefListLookup('Employees', column, 'average'); + await checkTypeAndFormula('Numeric', `AVERAGE($Employees.${colId})`); + + await gu.undo(); + await addRefListLookup('Employees', column, 'min'); + await checkTypeAndFormula('Numeric', `MIN($Employees.${colId})`); + + await gu.undo(); + await addRefListLookup('Employees', column, 'max'); + await checkTypeAndFormula('Numeric', `MAX($Employees.${colId})`); + + break; + case "Member": + await checkTypeAndFormula('Integer', `SUM($Employees.${colId})`); + // Here we also test that the formula is correct for percent. + await gu.undo(); + await addRefListLookup('Employees', column, 'percent'); + await checkTypeAndFormula('Numeric', `AVERAGE(map(int, $Employees.Member)) if $Employees else None`); + assert.isTrue( + await driver.findContent('.test-numeric-mode .test-select-button', /%/).matches('[class*=-selected]')); + break; + case "SeenAt": + await checkTypeAndFormula('Any', `$Employees.${colId}`); + await gu.undo(); + await addRefListLookup('Employees', column, 'min'); + await checkTypeAndFormula('DateTime', `MIN($Employees.${colId})`); + assert.equal(await driver.find(".test-tz-autocomplete input").value(), 'UTC'); + + await gu.undo(); + await addRefListLookup('Employees', column, 'max'); + await checkTypeAndFormula('DateTime', `MAX($Employees.${colId})`); + assert.equal(await driver.find(".test-tz-autocomplete input").value(), 'UTC'); + break; + default: + await checkTypeAndFormula('Any', `$Employees.${colId}`); + break; + } + await gu.undo(); }); + } + }); - it('should skip showing menu when inserting with keyboard shortcuts', async function () { - await gu.sendKeys(Key.chord(Key.ALT, '=')); - await gu.waitForServer(); - assert.isFalse(await driver.find('.test-new-columns-menu').isPresent()); - await gu.sendKeys(Key.ENTER); - let columns = await gu.getColumnNames(); - assert.deepEqual(columns, ['A', 'B', 'C', 'D']); - await gu.sendKeys(Key.chord(Key.SHIFT, Key.ALT, '=')); - await gu.waitForServer(); - assert.isFalse(await driver.find('.test-new-columns-menu').isPresent()); - await gu.sendKeys(Key.ENTER); - columns = await gu.getColumnNames(); - assert.deepEqual(columns, ['A', 'B', 'C', 'E', 'D']); - await gu.undo(2); - }); + describe('reverse lookups', function () { + revertThis(); + + before(async function () { + // Reference the Person table once more + await gu.sendActions([ + ['AddVisibleColumn', 'Person', 'Item', {type: 'Ref:Table1'}], + ['AddVisibleColumn', 'Person', 'Items', {type: 'RefList:Table1'}], + ]); }); - describe('hidden columns', function () { - it('no hidden column in document, section should not be present', async function () { - const menu = await openAddColumnIcon(); - const isHiddenSectionPresent = await menu.find(".new-columns-menu-hidden-columns").isPresent(); - assert.isFalse(isHiddenSectionPresent, 'hidden section is present'); - await closeAddColumnMenu(); - }); + it('should show reverse lookups in the menu', async function () { + await clickAddColumn(); + // Wait for any menu to show up. + await driver.findWait('.test-new-columns-menu-revlookup', 100); + // We should see two rev lookups. + assert.deepEqual(await driver.findAll('.test-new-columns-menu-revlookup', (el) => el.getText()), [ + 'Person [← Item]', + 'Person [← Items]', + ]); + }); - describe('inline menu section', function () { - before(async function () { - await gu.addColumn('Add1'); - await gu.addColumn('Add2'); - await gu.addColumn('Add3'); - }); + it('should show same list from Ref and RefList', async function () { + await driver.findContent('.test-new-columns-menu-revlookup', 'Person [← Item]').mouseMove(); + // Wait for any menu to show up. + await driver.findWait('.test-new-columns-menu-revlookup-column', 100); - it('1 to 5 hidden columns, section should be inline', async function () { - const checkSection = async (...columns: string[]) => { - const menu = await openAddColumnIcon(); - await menu.findWait(".test-new-columns-menu-hidden-columns", 100, - 'hidden section is not present'); - for (const column of columns) { - const isColumnPresent = await menu.findContent('li', column).isPresent(); - assert.isTrue(isColumnPresent, `column ${column} is not present`); - } - await closeAddColumnMenu(); - }; - - await gu.openWidgetPanel(); - await gu.moveToHidden('A'); - await checkSection('A'); - await gu.moveToHidden('B'); - await gu.moveToHidden('C'); - await gu.moveToHidden('Add1'); - await gu.moveToHidden('Add2'); - await checkSection('A', 'B', 'C', 'Add1', 'Add2'); - await gu.undo(11); - }); + const columns = await driver.findAll('.test-new-columns-menu-revlookup-column', (el) => el.getText()); + const submenus = await driver.findAll('.test-new-columns-menu-revlookup-submenu', (el) => el.getText()); - it('inline button should show column at the end of the table', async function () { - }); - }); + // Now open the other submenu and make sure list is the same. + await driver.findContent('.test-new-columns-menu-revlookup', 'Person [← Items]').mouseMove(); + // Wait for any menu to show up. + await driver.findWait('.test-new-columns-menu-revlookup-column', 100); - describe('submenu section', function () { - it('more than 5 hidden columns, section should be in submenu', async function () { - }); + const columns2 = await driver.findAll('.test-new-columns-menu-revlookup-column', (el) => el.getText()); + const submenus2 = await driver.findAll('.test-new-columns-menu-revlookup-submenu', (el) => el.getText()); + + assert.deepEqual(columns, columns2); + assert.deepEqual(submenus, submenus2); + + assert.deepEqual(columns, [ + 'Name\nlist', + 'Hobby\nlist', + 'Employee\nlist', + 'Photo\nlist', + 'Fun\nlist', + 'Parent\nlist', + 'Children\nlist', + 'Item\nlist', + 'Items\nlist' + ]); + assert.deepEqual(submenus, [ + 'Age\nsum', + 'Birthday date\nlist', + 'Member\ncount', + 'SeenAt\nlist' + ]); + + // Make sure that clicking one of the columns adds it with a default aggregation. + await driver.findContent('.test-new-columns-menu-revlookup-column', 'Name').click(); + await gu.waitForServer(); + + const columns3 = await gu.getColumnNames(); + assert.deepEqual(columns3, ['A', 'B', 'C', 'Person_Name']); + assert.equal(await gu.columnBehavior(), "Formula Column"); + assert.equal(await gu.getType(), 'Any'); + await driver.find('.formula_field_sidepane').click(); + assert.equal(await gu.getFormulaText(), `Person.lookupRecords(Items=CONTAINS($id)).Name`); + await gu.sendKeys(Key.ESCAPE); + await gu.undo(); + }); + + describe('reverse lookups from Ref column', function () { + for(const column of ['Age', 'Member', 'Birthday date', 'SeenAt']) { + it(`should properly add reverse lookup for ${column}`, async function () { + await clickAddColumn(); + await driver.findContentWait('.test-new-columns-menu-revlookup', 'Person [← Item]', 100).mouseMove(); + + // This is submenu so expand it. + await driver.findContentWait('.test-new-columns-menu-revlookup-submenu', new RegExp("^" + column), 100) + .mouseMove(); + + // Wait for any function to appear. + await driver.findWait('.test-new-columns-menu-revlookup-column-function', 100); + + // Make sure we see proper list. + const functions = await driver.findAll('.test-new-columns-menu-revlookup-column-function', + (el) => el.getText()); + switch(column) { + case "Age": + assert.deepEqual(functions, ['sum', 'average', 'min', 'max']); + break; + case "Member": + assert.deepEqual(functions, ['count', 'percent']); + break; + case "Birthday date": + case "SeenAt": + assert.deepEqual(functions, ['list', 'min', 'max']); + break; + } + + // Now add each function and make sure it is added with a proper name. + await gu.sendKeys(Key.ESCAPE); + switch(column) { + case "Age": + await addRevLookup('sum'); + await checkTypeAndFormula('Numeric', `SUM(Person.lookupRecords(Item=$id).Age)`); + + assert.deepEqual(await gu.getColumnNames(), + ['A', 'B', 'C', 'Person_Age']); + + await gu.undo(); + await addRevLookup('average'); + await checkTypeAndFormula('Numeric', `AVERAGE(Person.lookupRecords(Item=$id).Age)`); + + await gu.undo(); + await addRevLookup('min'); + await checkTypeAndFormula('Numeric', `MIN(Person.lookupRecords(Item=$id).Age)`); + + await gu.undo(); + await addRevLookup('max'); + await checkTypeAndFormula('Numeric', `MAX(Person.lookupRecords(Item=$id).Age)`); + break; + case "Member": + await addRevLookup('count'); + await checkTypeAndFormula('Integer', `SUM(Person.lookupRecords(Item=$id).Member)`); + + await gu.undo(); + await addRevLookup('percent'); + await checkTypeAndFormula('Numeric', + `AVERAGE(map(int, Person.lookupRecords(Item=$id).Member))` + + ` if Person.lookupRecords(Item=$id) else None`); + break; + case "Birthday date": + await addRevLookup('list'); + await checkTypeAndFormula('Any', `Person.lookupRecords(Item=$id).Birthday_date`); + + await gu.undo(); + await addRevLookup('min'); + await checkTypeAndFormula('Date', `MIN(Person.lookupRecords(Item=$id).Birthday_date)`); + + await gu.undo(); + await addRevLookup('max'); + await checkTypeAndFormula('Date', `MAX(Person.lookupRecords(Item=$id).Birthday_date)`); - it('submenu should be searchable', async function () { + assert.deepEqual(await gu.getColumnNames(), + ['A', 'B', 'C', 'Person_Birthday date']); + + break; + case "SeenAt": + await addRevLookup('max'); + await checkTypeAndFormula('DateTime', `MAX(Person.lookupRecords(Item=$id).SeenAt)`); + // Here check the timezone. + assert.equal(await driver.find(".test-tz-autocomplete input").value(), 'UTC'); + break; + } + + await gu.undo(); + + async function addRevLookup(func: string) { + await clickAddColumn(); + await driver.findContentWait('.test-new-columns-menu-revlookup', 'Person [← Item]', 100).mouseMove(); + await driver.findContentWait('.test-new-columns-menu-revlookup-submenu', new RegExp("^" + column), 100) + .mouseMove(); + await driver.findContentWait('.test-new-columns-menu-revlookup-column-function', func, 100).click(); + await gu.waitForServer(); + } }); + } + }); + + describe('reverse lookups from RefList column', function () { + for(const column of ['Age', 'Member', 'Birthday date', 'SeenAt']) { + it(`should properly add reverse lookup for ${column}`, async function () { + await clickAddColumn(); + await driver.findContentWait('.test-new-columns-menu-revlookup', 'Person [← Items]', 100).mouseMove(); + + // This is submenu so expand it. + await driver.findContentWait('.test-new-columns-menu-revlookup-submenu', new RegExp("^" + column), 100) + .mouseMove(); + + // Wait for any function to appear. + await driver.findWait('.test-new-columns-menu-revlookup-column-function', 100); + + // Make sure we see proper list. + const functions = await driver.findAll('.test-new-columns-menu-revlookup-column-function', + (el) => el.getText()); + switch(column) { + case "Age": + assert.deepEqual(functions, ['sum', 'average', 'min', 'max']); + break; + case "Member": + assert.deepEqual(functions, ['count', 'percent']); + break; + case "Birthday date": + case "SeenAt": + assert.deepEqual(functions, ['list', 'min', 'max']); + break; + } + + // Now add each function and make sure it is added with a proper name. + await gu.sendKeys(Key.ESCAPE); + switch(column) { + case "Age": + await addRevLookup('sum'); + await checkTypeAndFormula('Numeric', `SUM(Person.lookupRecords(Items=CONTAINS($id)).Age)`); + + await gu.undo(); + await addRevLookup('average'); + await checkTypeAndFormula('Numeric', `AVERAGE(Person.lookupRecords(Items=CONTAINS($id)).Age)`); + + await gu.undo(); + await addRevLookup('min'); + await checkTypeAndFormula('Numeric', `MIN(Person.lookupRecords(Items=CONTAINS($id)).Age)`); + + await gu.undo(); + await addRevLookup('max'); + await checkTypeAndFormula('Numeric', `MAX(Person.lookupRecords(Items=CONTAINS($id)).Age)`); + break; + case "Member": + await addRevLookup('count'); + await checkTypeAndFormula('Integer', `SUM(Person.lookupRecords(Items=CONTAINS($id)).Member)`); - it('submenu button should show column at the end of the table', async function () { + await gu.undo(); + await addRevLookup('percent'); + await checkTypeAndFormula('Numeric', + `AVERAGE(map(int, Person.lookupRecords(Items=CONTAINS($id)).Member))` + + ` if Person.lookupRecords(Items=CONTAINS($id)) else None`); + break; + case "Birthday date": + await addRevLookup('list'); + await checkTypeAndFormula('Any', `Person.lookupRecords(Items=CONTAINS($id)).Birthday_date`); + + await gu.undo(); + await addRevLookup('min'); + await checkTypeAndFormula('Date', `MIN(Person.lookupRecords(Items=CONTAINS($id)).Birthday_date)`); + + await gu.undo(); + await addRevLookup('max'); + await checkTypeAndFormula('Date', `MAX(Person.lookupRecords(Items=CONTAINS($id)).Birthday_date)`); + break; + case "SeenAt": + await addRevLookup('max'); + await checkTypeAndFormula('DateTime', `MAX(Person.lookupRecords(Items=CONTAINS($id)).SeenAt)`); + // Here check the timezone. + assert.equal(await driver.find(".test-tz-autocomplete input").value(), 'UTC'); + break; + } + + await gu.undo(); + + async function addRevLookup(func: string) { + await clickAddColumn(); + await driver.findContentWait('.test-new-columns-menu-revlookup', 'Person [← Items]', 100).mouseMove(); + await driver.findContentWait('.test-new-columns-menu-revlookup-submenu', new RegExp("^" + column), 100) + .mouseMove(); + await driver.findContentWait('.test-new-columns-menu-revlookup-column-function', func, 100).click(); + await gu.waitForServer(); + } }); - }); + } }); + }); - describe('lookups', function () { - before(async function () { - //save current state - }); + describe('shortcuts', function () { + describe('Timestamp', function () { + revertEach(); - after(async function () { - //restore current state - }); - it('should show columns in menu with lookup', async function () { + it('created at - should create new column with date triggered on create', async function () { + await gu.openColumnPanel(); + + await clickAddColumn(); + await driver.findWait('.test-new-columns-menu-shortcuts-timestamp', 100).mouseMove(); + await driver.findWait('.test-new-columns-menu-shortcuts-timestamp-new', 100).click(); + await gu.waitForServer(); + + // Make sure we have Created At column at the end. + const columns = await gu.getColumnNames(); + assert.deepEqual(columns, ['A', 'B', 'C', 'Created At']); + + // Make sure this is the column that is selected. + assert.equal(await driver.find('.test-field-label').value(), 'Created At'); + assert.equal(await driver.find('.test-field-col-id').value(), '$Created_At'); + + // Check behavior - this is trigger formula + assert.equal(await gu.columnBehavior(), "Data Column"); + assert.isTrue(await driver.findContent('div', 'TRIGGER FORMULA').isDisplayed()); + + // It applies to new records only. + assert.equal(await driver.find('.test-field-formula-apply-to-new').getAttribute('checked'), 'true'); + assert.equal(await driver.find('.test-field-formula-apply-on-changes').getAttribute('checked'), null); + + // Make sure type and formula are correct. + await checkTypeAndFormula('DateTime', 'NOW()'); + assert.isNotEmpty(await driver.find(".test-tz-autocomplete input").value()); }); - it('should create formula column with data from selected column', async function () { + + it('modified at - should create new column with date triggered on change', async function () { + await clickAddColumn(); + await driver.findWait('.test-new-columns-menu-shortcuts-timestamp', 100).mouseMove(); + await driver.findWait('.test-new-columns-menu-shortcuts-timestamp-change', 100).click(); + await gu.waitForServer(); + + // Make sure we have this column at the end. + const columns = await gu.getColumnNames(); + assert.deepEqual(columns, ['A', 'B', 'C', 'Last Updated At']); + + // Make sure this is the column that is selected. + assert.equal(await driver.find('.test-field-label').value(), 'Last Updated At'); + assert.equal(await driver.find('.test-field-col-id').value(), '$Last_Updated_At'); + + // Check behavior - this is trigger formula + assert.equal(await gu.columnBehavior(), "Data Column"); + assert.isTrue(await driver.findContent('div', 'TRIGGER FORMULA').isDisplayed()); + + // It applies to new records only and if anything changes. + assert.equal(await driver.find('.test-field-formula-apply-to-new').getAttribute('checked'), 'true'); + assert.equal(await driver.find('.test-field-formula-apply-on-changes').getAttribute('checked'), 'true'); + assert.equal(await driver.find('.test-field-triggers-select').getText(), 'Any field'); + + // Make sure type and formula are correct. + await checkTypeAndFormula('DateTime', 'NOW()'); + assert.isNotEmpty(await driver.find(".test-tz-autocomplete input").value()); }); }); - describe('shortcuts', function () { - describe('Timestamp', function () { - it('created at - should create new column with date triggered on create', function () { + describe('Authorship', function () { + revertEach(); - }); - it('modified at - should create new column with date triggered on change', function () { + it('created by - should create new column with author name triggered on create', async function () { + await gu.openColumnPanel(); - }); + await clickAddColumn(); + await driver.findWait('.test-new-columns-menu-shortcuts-author', 100).mouseMove(); + await driver.findWait('.test-new-columns-menu-shortcuts-author-new', 100).click(); + await gu.waitForServer(); + + // Make sure we have this column at the end. + const columns = await gu.getColumnNames(); + assert.deepEqual(columns, ['A', 'B', 'C', 'Created By']); + + // Make sure this is the column that is selected. + assert.equal(await driver.find('.test-field-label').value(), 'Created By'); + assert.equal(await driver.find('.test-field-col-id').value(), '$Created_By'); + + // Check behavior - this is trigger formula + assert.equal(await gu.columnBehavior(), "Data Column"); + assert.isTrue(await driver.findContent('div', 'TRIGGER FORMULA').isDisplayed()); + + // It applies to new records only. + assert.equal(await driver.find('.test-field-formula-apply-to-new').getAttribute('checked'), 'true'); + assert.equal(await driver.find('.test-field-formula-apply-on-changes').getAttribute('checked'), null); + + // Make sure type and formula are correct. + await checkTypeAndFormula('Text', 'user.Name'); }); - describe('Authorship', function () { - it('created by - should create new column with author name triggered on create', function () { + it('modified by - should create new column with author name triggered on change', async function () { + await gu.openColumnPanel(); - }); - it('modified by - should create new column with author name triggered on change', function () { + await clickAddColumn(); + await driver.findWait('.test-new-columns-menu-shortcuts-author', 100).mouseMove(); + await driver.findWait('.test-new-columns-menu-shortcuts-author-change', 100).click(); + await gu.waitForServer(); - }); + // Make sure we have this column at the end. + const columns = await gu.getColumnNames(); + assert.deepEqual(columns, ['A', 'B', 'C', 'Last Updated By']); + + // Make sure this is the column that is selected. + assert.equal(await driver.find('.test-field-label').value(), 'Last Updated By'); + assert.equal(await driver.find('.test-field-col-id').value(), '$Last_Updated_By'); + + // Check behavior - this is trigger formula + assert.equal(await gu.columnBehavior(), "Data Column"); + assert.isTrue(await driver.findContent('div', 'TRIGGER FORMULA').isDisplayed()); + + // It applies to new records only and if anything changes. + assert.equal(await driver.find('.test-field-formula-apply-to-new').getAttribute('checked'), 'true'); + assert.equal(await driver.find('.test-field-formula-apply-on-changes').getAttribute('checked'), 'true'); + assert.equal(await driver.find('.test-field-triggers-select').getText(), 'Any field'); + + // Make sure type and formula are correct. + await checkTypeAndFormula('Text', 'user.Name'); }); + }); - describe('Detect Duplicates in...', function () { - it('should show columns in a searchable sub-menu', async function () { - const menu = await openAddColumnIcon(); - await menu.findWait('.test-new-columns-menu-shortcuts-duplicates', 100).mouseMove(); - await gu.waitToPass(async () => { - assert.deepEqual( - await driver.findAll('.test-searchable-menu li', (el) => el.getText()), - ['A', 'B', 'C'] - ); - }, 500); - await driver.find('.test-searchable-menu-input').click(); - await gu.sendKeys('A'); - await gu.waitToPass(async () => { - assert.deepEqual( - await driver.findAll('.test-searchable-menu li', (el) => el.getText()), - ['A'] - ); - }, 250); + describe('Detect Duplicates in...', function () { + it('should show columns in a searchable sub-menu', async function () { + await clickAddColumn(); + await driver.findWait('.test-new-columns-menu-shortcuts-duplicates', 100).mouseMove(); + await gu.waitToPass(async () => { + assert.deepEqual( + await driver.findAll('.test-searchable-menu li', (el) => el.getText()), + ['A', 'B', 'C'] + ); + }, 500); + await driver.find('.test-searchable-menu-input').click(); + await gu.sendKeys('A'); + await gu.waitToPass(async () => { + assert.deepEqual( + await driver.findAll('.test-searchable-menu li', (el) => el.getText()), + ['A'] + ); + }, 250); - await gu.sendKeys('BC'); - await gu.waitToPass(async () => { - assert.deepEqual( - await driver.findAll('.test-searchable-menu li', (el) => el.getText()), - [] - ); - }, 250); + await gu.sendKeys('BC'); + await gu.waitToPass(async () => { + assert.deepEqual( + await driver.findAll('.test-searchable-menu li', (el) => el.getText()), + [] + ); + }, 250); - await gu.clearInput(); - await gu.waitToPass(async () => { - assert.deepEqual( - await driver.findAll('.test-searchable-menu li', (el) => el.getText()), - ['A', 'B', 'C'] - ); - }, 250); - }); + await gu.clearInput(); + await gu.waitToPass(async () => { + assert.deepEqual( + await driver.findAll('.test-searchable-menu li', (el) => el.getText()), + ['A', 'B', 'C'] + ); + }, 250); + }); + + it('should create new column that checks for duplicates in the specified column', async function () { + await clickAddColumn(); + await driver.findWait('.test-new-columns-menu-shortcuts-duplicates', 100).mouseMove(); + await driver.findContentWait('.test-searchable-menu li', 'A', 500).click(); + await gu.waitForServer(); + await gu.sendKeys(Key.ENTER); + + // Just checking the formula looks plausible - correctness is best left to a python test. + assert.equal( + await driver.find('.test-formula-editor').getText(), + '$A != "" and $A is not None and len(Table1.lookupRecords(A=$A)) > 1' + ); + await gu.sendKeys(Key.ESCAPE); + let columns = await gu.getColumnNames(); + assert.deepEqual(columns, ['A', 'B', 'C', 'Duplicate in A']); + await gu.undo(); - it('should create new column that checks for duplicates in the specified column', async function () { - let menu = await openAddColumnIcon(); - await menu.findWait('.test-new-columns-menu-shortcuts-duplicates', 100).mouseMove(); - await driver.findContentWait('.test-searchable-menu li', 'A', 500).click(); + // Try it with list-based columns; the formula should look a little different. + for (const [label, type] of [['Choice', 'Choice List'], ['Ref', 'Reference List']]) { + await gu.addColumn(label, type); + await clickAddColumn(); + await driver.findWait('.test-new-columns-menu-shortcuts-duplicates', 100).mouseMove(); + await driver.findContentWait('.test-searchable-menu li', label, 500).click(); await gu.waitForServer(); await gu.sendKeys(Key.ENTER); - - // Just checking the formula looks plausible - correctness is best left to a python test. assert.equal( await driver.find('.test-formula-editor').getText(), - '$A != "" and $A is not None and len(Table1.lookupRecords(A=$A)) > 1' + `any([len(Table1.lookupRecords(${label}=CONTAINS(x))) > 1 for x in $${label}])` ); await gu.sendKeys(Key.ESCAPE); - let columns = await gu.getColumnNames(); - assert.deepEqual(columns, ['A', 'B', 'C', 'Duplicate in A']); - await gu.undo(); - - // Try it with list-based columns; the formula should look a little different. - for (const [label, type] of [['Choice', 'Choice List'], ['Ref', 'Reference List']]) { - await gu.addColumn(label, type); - menu = await openAddColumnIcon(); - await menu.findWait('.test-new-columns-menu-shortcuts-duplicates', 100).mouseMove(); - await driver.findContentWait('.test-searchable-menu li', label, 500).click(); - await gu.waitForServer(); - await gu.sendKeys(Key.ENTER); - assert.equal( - await driver.find('.test-formula-editor').getText(), - `any([len(Table1.lookupRecords(${label}=CONTAINS(x))) > 1 for x in $${label}])` - ); - await gu.sendKeys(Key.ESCAPE); - columns = await gu.getColumnNames(); - assert.deepEqual(columns, ['A', 'B', 'C', label, `Duplicate in ${label}`]); - await gu.undo(4); - } - }); + columns = await gu.getColumnNames(); + assert.deepEqual(columns, ['A', 'B', 'C', label, `Duplicate in ${label}`]); + await gu.undo(4); + } }); + }); - describe('UUID', function () { - it('should create new column that generates a UUID on new record', async function () { - await gu.getCell(2, 1).click(); - await gu.sendKeys('A', Key.ENTER); - await gu.waitForServer(); - const menu = await openAddColumnIcon(); - await menu.findWait('.test-new-columns-menu-shortcuts-uuid', 100).click(); - await gu.waitForServer(); - const cells1 = await gu.getVisibleGridCells({col: 'UUID', rowNums: [1, 2]}); - assert.match(cells1[0], /[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/); - assert.equal(cells1[1], ''); - await gu.getCell(2, 2).click(); - await gu.sendKeys('B', Key.ENTER); - await gu.waitForServer(); - const cells2 = await gu.getVisibleGridCells({col: 'UUID', rowNums: [1, 2, 3]}); - assert.match(cells2[0], /[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/); - assert.match(cells2[1], /[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/); - assert.equal(cells2[2], ''); - assert.equal(cells1[0], cells2[0]); - await gu.undo(3); - }); + describe('UUID', function () { + it('should create new column that generates a UUID on new record', async function () { + await gu.getCell(2, 1).click(); + await gu.sendKeys('A', Key.ENTER); + await gu.waitForServer(); + await clickAddColumn(); + await driver.findWait('.test-new-columns-menu-shortcuts-uuid', 100).click(); + await gu.waitForServer(); + const cells1 = await gu.getVisibleGridCells({col: 'UUID', rowNums: [1, 2]}); + assert.match(cells1[0], /[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/); + assert.equal(cells1[1], ''); + await gu.getCell(2, 2).click(); + await gu.sendKeys('B', Key.ENTER); + await gu.waitForServer(); + const cells2 = await gu.getVisibleGridCells({col: 'UUID', rowNums: [1, 2, 3]}); + assert.match(cells2[0], /[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/); + assert.match(cells2[1], /[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/); + assert.equal(cells2[2], ''); + assert.equal(cells1[0], cells2[0]); + await gu.undo(3); }); }); + }); + + it("should not show hidden Ref columns", async function () { + // Duplicate current tab and start transforming a column. + const mainTab = await gu.myTab(); + const transformTab = await gu.duplicateTab(); + + // Start transforming a column. + await gu.sendActions([ + ['AddRecord', 'Table1', null, {A: 1, B: 2, C: 3}], + ]); + await gu.getCell('A', 1).click(); + await gu.setType('Reference', {apply: false}); + await gu.waitForServer(); + await gu.setRefTable('Person'); + await gu.waitForServer(); + + // Now we have two hidden columns present. + let columns = await api.getTable(docId, 'Table1'); + assert.includeMembers(Object.keys(columns), [ + 'gristHelper_Converted', + 'gristHelper_Transform' + ]); + + // Now on the main tab, make sure we don't see those references in lookup menu. + await mainTab.open(); + await clickAddColumn(); + await driver.findWait('.test-new-columns-menu-lookups-none', 100); + // Now test RefList columns. + await transformTab.open(); + await driver.find('.test-type-transform-cancel').click(); + await gu.waitForServer(); + // Make sure hidden columns are removed. + columns = await api.getTable(docId, 'Table1'); + assert.notIncludeMembers(Object.keys(columns), [ + 'gristHelper_Converted', + 'gristHelper_Transform' + ]); + await gu.setType('Reference List', {apply: false}); + await gu.setRefTable('Person'); + await gu.waitForServer(); + columns = await api.getTable(docId, 'Table1'); + assert.includeMembers(Object.keys(columns), [ + 'gristHelper_Converted', + 'gristHelper_Transform' + ]); - async function createEmptyDoc(docName: string) { - session = await gu.session().login(); - const docId = await session.tempNewDoc(cleanup, docName); - doc = {id: docId, title: docName}; - apiImpl = session.createHomeApi(); + // Now on the main make sure we still don't see those references in lookup menu. + await mainTab.open(); + await clickAddColumn(); + await driver.findWait('.test-new-columns-menu-lookups-none', 100); + + // Now test reverse lookups. + await gu.openPage('Person'); + await clickAddColumn(); + + // Wait for any menu to show up. + await driver.findWait('.test-new-columns-menu-lookup', 100); + + // Now make sure we don't have helper columns + assert.isEmpty(await driver.findAll('.test-new-columns-menu-revlookup', e => e.getText())); + + await gu.sendKeys(Key.ESCAPE); + await gu.scrollActiveView(-1000, 0); + + await transformTab.open(); + await driver.find('.test-type-transform-cancel').click(); + await gu.waitForServer(); + await transformTab.close(); + await mainTab.open(); + await gu.openPage('Table1'); + }); + + async function clickAddColumn() { + const isMenuPresent = await driver.find(".test-new-columns-menu").isPresent(); + if (!isMenuPresent) { + await driver.findWait(".mod-add-column", 100).click(); } + await driver.findWait(".test-new-columns-menu", 100); + } + + async function isMenuPresent() { + return await driver.find(".test-new-columns-menu").isPresent(); + } + + async function closeAddColumnMenu() { + await driver.sendKeys(Key.ESCAPE); + assert.isFalse(await isMenuPresent(), 'menu is still present'); + } + + async function hasAddNewColumMenu() { + await isDisplayed('.test-new-columns-menu-add-new', 'add new column menu is not present'); + } + + async function isDisplayed(selector: string, message: string) { + assert.isTrue(await driver.findWait(selector, 100, message).isDisplayed(), message); + } + + async function hasShortcuts() { + await isDisplayed('.test-new-columns-menu-shortcuts', 'shortcuts section is not present'); + await isDisplayed('.test-new-columns-menu-shortcuts-timestamp', 'timestamp shortcuts section is not present'); + await isDisplayed('.test-new-columns-menu-shortcuts-author', 'authorship shortcuts section is not present'); + } + + async function hasLookupMenu(colId: string) { + await isDisplayed('.test-new-columns-menu-lookup', 'lookup section is not present'); + await isDisplayed(`.test-new-columns-menu-lookup-${colId}`, `lookup section for ${colId} is not present`); + } - async function openAddColumnIcon() { - const isMenuPresent = await driver.find(".test-new-columns-menu").isPresent(); - if (!isMenuPresent) { - await driver.findWait(".mod-add-column", 100).click(); + async function collapsedHiddenColumns() { + return await driver.findAll('.test-new-columns-menu-hidden-column-collapsed', (el) => el.getText()); + } + + function revertEach() { + let revert: () => Promise; + beforeEach(async function () { + revert = await gu.begin(); + }); + + gu.afterEachCleanup(async function () { + if (await isMenuPresent()) { + await closeAddColumnMenu(); } - return driver.findWait(".test-new-columns-menu", 100); - } + await revert(); + }); + } + - async function closeAddColumnMenu() { - const isMenuPresent = await driver.find(".test-new-columns-menu").isPresent(); - if (isMenuPresent) { - await driver.sendKeys(Key.ESCAPE); - assert.isFalse(await driver.wait(driver.find(".test-new-columns-menu").isPresent(), 100), - 'menu is still present after close by escape'); + function revertThis() { + let revert: () => Promise; + before(async function () { + revert = await gu.begin(); + }); + + gu.afterCleanup(async function () { + if (await isMenuPresent()) { + await closeAddColumnMenu(); } - } + await revert(); + }); + } + + async function addRefListLookup(refListId: string, colId: string, func: string) { + await clickAddColumn(); + await driver.findWait(`.test-new-columns-menu-lookup-${refListId}`, 100).click(); + await driver.findWait(`.test-new-columns-menu-lookup-submenu-${colId}`, 100).mouseMove(); + await driver.findWait(`.test-new-columns-menu-lookup-submenu-function-${func}`, 100).click(); + await gu.waitForServer(); + } - const hasAddNewColumMenu = async (menu: WebElement) => { - await checkInMenu(menu, '.test-new-columns-menu-add-new', 'add new column menu is not present'); - }; - - const checkInMenu = async (menu: WebElement, selector: string, message: string) => { - const element = await menu.findWait(selector, 100, message); - assert.exists(element, message); - return element; - }; - - const hasShortcuts = async (menu: WebElement) => { - await checkInMenu(menu, '.test-new-columns-menu-shortcuts', 'shortcuts section is not present'); - await checkInMenu(menu, '.test-new-columns-menu-shortcuts-timestamp', - 'timestamp shortcuts section is not present'); - await checkInMenu(menu, '.test-new-columns-menu-shortcuts-author', 'authorship shortcuts section is not present'); - }; - - const hasLookupMenu = async (menu: WebElement, tableName: string) => { - await checkInMenu(menu, '.test-new-columns-menu-lookups', 'lookup section is not present'); - await checkInMenu(menu, `.test-new-columns-menu-lookups-${tableName}`, - `lookup section for ${tableName} is not present`); - }; + async function checkTypeAndFormula(type: string, formula: string) { + assert.equal(await gu.getType(), type); + await driver.find('.formula_field_sidepane').click(); + assert.equal(await gu.getFormulaText(false).then(s => s.trim()), formula); + await gu.sendKeys(Key.ESCAPE); } }); diff --git a/test/nbrowser/MultiColumn.ts b/test/nbrowser/MultiColumn.ts index c1b63092..89aa37d2 100644 --- a/test/nbrowser/MultiColumn.ts +++ b/test/nbrowser/MultiColumn.ts @@ -129,23 +129,23 @@ describe('MultiColumn', function() { it('should show proper behavior label', async () => { await selectColumns('Test1'); - assert.equal(await columnBehavior(), 'Empty Column'); + assert.equal(await gu.columnBehavior(), 'Empty Column'); await selectColumns('Test1', 'Test3'); - assert.equal(await columnBehavior(), 'Empty Columns'); + assert.equal(await gu.columnBehavior(), 'Empty Columns'); // Change first to be data column. await selectColumns('Test1'); await driver.find(".test-field-set-data").click(); await gu.waitForServer(); await selectColumns('Test1', 'Test3'); - assert.equal(await columnBehavior(), 'Mixed Behavior'); + assert.equal(await gu.columnBehavior(), 'Mixed Behavior'); // Change second to be a data column await selectColumns('Test2'); await driver.find(".test-field-set-data").click(); await gu.waitForServer(); await selectColumns('Test1', 'Test2'); - assert.equal(await columnBehavior(), 'Data Columns'); + assert.equal(await gu.columnBehavior(), 'Data Columns'); // Now make them all formulas await gu.sendActions([ ['ModifyColumn', 'Table1', 'Test1', {formula: '1', isFormula: true}], @@ -153,13 +153,13 @@ describe('MultiColumn', function() { ['ModifyColumn', 'Table1', 'Test3', {formula: '1', isFormula: true}], ]); await selectColumns('Test1', 'Test3'); - assert.equal(await columnBehavior(), 'Formula Columns'); + assert.equal(await gu.columnBehavior(), 'Formula Columns'); // Make one of them data column and test that the mix is recognized. await selectColumns('Test1'); await gu.changeBehavior('Convert column to data'); await selectColumns('Test1', 'Test3'); - assert.equal(await columnBehavior(), 'Mixed Behavior'); + assert.equal(await gu.columnBehavior(), 'Mixed Behavior'); }); it('should reset multiple columns', async () => { @@ -170,14 +170,14 @@ describe('MultiColumn', function() { ['ModifyColumn', 'Table1', 'Test3', {formula: '1', isFormula: true}], ]); await selectColumns('Test1', 'Test3'); - assert.equal(await columnBehavior(), 'Formula Columns'); + assert.equal(await gu.columnBehavior(), 'Formula Columns'); await alignment('center'); assert.equal(await alignment(), 'center'); // Reset all of them assert.deepEqual(await gu.availableBehaviorOptions(), ['Convert columns to data', 'Clear and reset']); await gu.changeBehavior('Clear and reset'); - assert.equal(await columnBehavior(), 'Empty Columns'); + assert.equal(await gu.columnBehavior(), 'Empty Columns'); assert.equal(await alignment(), 'left'); // Make them all data columns @@ -185,17 +185,17 @@ describe('MultiColumn', function() { await gu.getCell('Test2', 1).click(); await gu.enterCell('a'); await gu.getCell('Test3', 1).click(); await gu.enterCell('a'); await selectColumns('Test1', 'Test3'); - assert.equal(await columnBehavior(), 'Data Columns'); + assert.equal(await gu.columnBehavior(), 'Data Columns'); await selectColumns('Test1'); - assert.equal(await columnBehavior(), 'Data Column'); + assert.equal(await gu.columnBehavior(), 'Data Column'); // Reset all of them await selectColumns('Test1', 'Test3'); assert.deepEqual(await gu.availableBehaviorOptions(), ['Clear and reset']); await gu.changeBehavior('Clear and reset'); - assert.equal(await columnBehavior(), 'Empty Columns'); + assert.equal(await gu.columnBehavior(), 'Empty Columns'); await selectColumns('Test1'); - assert.equal(await columnBehavior(), 'Empty Column'); + assert.equal(await gu.columnBehavior(), 'Empty Column'); assert.equal(await gu.getCell('Test1', 1).getText(), ''); assert.equal(await gu.getCell('Test2', 1).getText(), ''); assert.equal(await gu.getCell('Test3', 1).getText(), ''); @@ -203,12 +203,12 @@ describe('MultiColumn', function() { it('should convert to data multiple columns', async () => { await selectColumns('Test1', 'Test3'); - assert.equal(await columnBehavior(), 'Empty Columns'); + assert.equal(await gu.columnBehavior(), 'Empty Columns'); assert.deepEqual(await gu.availableBehaviorOptions(), ['Convert columns to data', 'Clear and reset']); await gu.changeBehavior('Convert columns to data'); - assert.equal(await columnBehavior(), 'Data Columns'); + assert.equal(await gu.columnBehavior(), 'Data Columns'); await selectColumns('Test1'); - assert.equal(await columnBehavior(), 'Data Column'); + assert.equal(await gu.columnBehavior(), 'Data Column'); // Now make them all formula columns await gu.sendActions([ @@ -217,14 +217,14 @@ describe('MultiColumn', function() { ['ModifyColumn', 'Table1', 'Test3', {formula: '3', isFormula: true}], ]); await selectColumns('Test1', 'Test3'); - assert.equal(await columnBehavior(), 'Formula Columns'); + assert.equal(await gu.columnBehavior(), 'Formula Columns'); // Convert them to data assert.deepEqual(await gu.availableBehaviorOptions(), ['Convert columns to data', 'Clear and reset']); await gu.changeBehavior('Convert columns to data'); - assert.equal(await columnBehavior(), 'Data Columns'); + assert.equal(await gu.columnBehavior(), 'Data Columns'); await selectColumns('Test1'); - assert.equal(await columnBehavior(), 'Data Column'); + assert.equal(await gu.columnBehavior(), 'Data Column'); // Test that data stays. assert.equal(await gu.getCell('Test1', 1).getText(), '1'); assert.equal(await gu.getCell('Test2', 1).getText(), '2'); @@ -1250,9 +1250,7 @@ async function toggleDerived() { await gu.waitForServer(); } -async function columnBehavior() { - return (await driver.find(".test-field-behaviour").getText()); -} + async function wrapDisabled() { return (await driver.find(".test-tb-wrap-text > div").matches('[class*=disabled]')); diff --git a/test/nbrowser/ReferenceColumns.ts b/test/nbrowser/ReferenceColumns.ts index 205d247f..485019f6 100644 --- a/test/nbrowser/ReferenceColumns.ts +++ b/test/nbrowser/ReferenceColumns.ts @@ -20,6 +20,7 @@ describe('ReferenceColumns', function() { 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 driver.findWait('.test-new-columns-menu-add-new', 100).click(); await gu.waitForServer(); await gu.setType(/Reference/); await gu.waitForServer(); diff --git a/test/nbrowser/ReferenceList.ts b/test/nbrowser/ReferenceList.ts index 7c14f7c8..6abffb01 100644 --- a/test/nbrowser/ReferenceList.ts +++ b/test/nbrowser/ReferenceList.ts @@ -253,6 +253,8 @@ describe('ReferenceList', function() { // Create a new Reference List column. await driver.find('.test-right-tab-field').click(); await driver.find('.mod-add-column').click(); + await driver.findWait('.test-new-columns-menu-add-new', 100).click(); + await gu.waitForServer(); await gu.setType(/Reference List/); await gu.waitForServer(); diff --git a/test/nbrowser/TextEditor.ntest.js b/test/nbrowser/TextEditor.ntest.js index a6ab36db..ec42547d 100644 --- a/test/nbrowser/TextEditor.ntest.js +++ b/test/nbrowser/TextEditor.ntest.js @@ -133,6 +133,7 @@ describe('TextEditor.ntest', function() { async function addColumnRightOf(index) { // Add a column. We have to hover over the column header first. await gu.openColumnMenu({col: index}, 'Insert column to the right'); + await driver.find('.test-new-columns-menu-add-new').click(); await gu.waitForServer(); await gu.sendKeys($.ESCAPE); } diff --git a/test/nbrowser/gristUtils.ts b/test/nbrowser/gristUtils.ts index a6453a22..39ce2f76 100644 --- a/test/nbrowser/gristUtils.ts +++ b/test/nbrowser/gristUtils.ts @@ -16,7 +16,6 @@ import {CommandName} from 'app/client/components/commandList'; import {csvDecodeRow} from 'app/common/csvFormat'; import { AccessLevel } from 'app/common/CustomWidget'; import { decodeUrl } from 'app/common/gristUrls'; -import { isAffirmative } from "app/common/gutil"; import { FullUser, UserProfile } from 'app/common/LoginSessionAPI'; import { resetOrg } from 'app/common/resetOrg'; import { UserAction } from 'app/common/DocActions'; @@ -689,20 +688,26 @@ export async function enterFormula(formula: string) { } /** - * Check that formula editor is shown and its value matches the given regexp. + * Check that formula editor is shown and returns its value. + * By default returns only text that is visible to the user, pass false to get all text. */ -export async function getFormulaText() { +export async function getFormulaText(onlyVisible = true): Promise { assert.equal(await driver.findWait('.test-formula-editor', 500).isDisplayed(), true); - return await driver.find('.code_editor_container').getText(); + if (onlyVisible) { + return await driver.find('.code_editor_container').getText(); + } else { + return await driver.executeScript( + () => (document as any).querySelector(".code_editor_container").innerText + ); + } } /** * Check that formula editor is shown and its value matches the given regexp. */ export async function checkFormulaEditor(value: RegExp|string) { - assert.equal(await driver.findWait('.test-formula-editor', 500).isDisplayed(), true); const valueRe = typeof value === 'string' ? exactMatch(value) : value; - assert.match(await driver.find('.code_editor_container').getText(), valueRe); + assert.match(await getFormulaText(), valueRe); } /** @@ -1291,6 +1296,40 @@ export async function begin(invariant: () => any = () => true) { }; } +/** + * A hook that can be used to clear a state after suite is finished and current test passed. + * If under debugging session and NO_CLEANUP env variable is set it will skip this cleanup and allow you + * to examine the state of the database or browser. + */ +export function afterCleanup(test: () => void | Promise) { + after(function() { + if (process.env.NO_CLEANUP) { + function anyTestFailed(suite: Mocha.Suite): boolean { + return suite.tests.some(t => t.state === 'failed') || suite.suites.some(anyTestFailed); + } + + if (this.currentTest?.parent && anyTestFailed(this.currentTest?.parent)) { + return; + } + } + return test(); + }); +} + +/** + * A hook that can be used to clear state after each test that has passed. + * If under debugging session and NO_CLEANUP env variable is set it will skip this cleanup and allow you + * to examine the state of the database or browser. + */ +export function afterEachCleanup(test: () => void | Promise) { + afterEach(function() { + if (this.currentTest?.state !== 'passed' && !this.currentTest?.pending && process.env.NO_CLEANUP) { + return; + } + return test(); + }); +} + /** * Simulates a transaction on the GristDoc. Use with cautions, as there is no guarantee it will undo correctly * in a case of failure. @@ -1546,6 +1585,7 @@ export function openColumnMenu(col: IColHeader|string, option?: string): WebElem export async function deleteColumn(col: IColHeader|string) { await openColumnMenu(col, 'Delete column'); await waitForServer(); + await wipeToasts(); } export type ColumnType = @@ -2339,9 +2379,7 @@ export function hexToRgb(hex: string) { export async function addColumn(name: string, type?: string) { await scrollIntoView(await driver.find('.active_section .mod-add-column')); await driver.find('.active_section .mod-add-column').click(); - if (isAffirmative(process.env.GRIST_NEW_COLUMN_MENU)) { - await driver.findWait('.test-new-columns-menu-add-new', 100).click(); - } + await driver.findWait('.test-new-columns-menu-add-new', 100).click(); // If we are on a summary table, we could be see a menu helper const menu = (await driver.findAll('.grist-floating-menu'))[0]; if (menu) { @@ -2360,7 +2398,11 @@ export async function addColumn(name: string, type?: string) { export async function showColumn(name: string) { await scrollIntoView(await driver.find('.active_section .mod-add-column')); await driver.find('.active_section .mod-add-column').click(); - await driver.findContent('.grist-floating-menu li', `Show column ${name}`).click(); + if (await driver.findContent('.test-new-columns-menu-hidden-column-inlined', `${name}`).isPresent()) { + await driver.findContent('.test-new-columns-menu-hidden-column-inlined', `${name}`).click(); + } else { + await driver.findContent('.test-new-columns-menu-hidden-column-collapsed', `${name}`).click(); + } await waitForServer(); } @@ -2739,6 +2781,40 @@ export async function onNewTab(action: () => Promise) { await driver.switchTo().window(tabs[tabs.length - 2]); } +/** + * Returns a controller for the current tab. + */ +export async function myTab() { + const tabs = await driver.getAllWindowHandles(); + const myTab = tabs[tabs.length - 1]; + return { + open() { + return driver.switchTo().window(myTab); + } + }; +} + +/** + * Duplicate current tab and return a controller for it. Assumes the current tab shows document. + */ +export async function duplicateTab() { + const url = await driver.getCurrentUrl(); + await driver.executeScript("window.open('about:blank', '_blank')"); + const tabs = await driver.getAllWindowHandles(); + const myTab = tabs[tabs.length - 1]; + await driver.switchTo().window(myTab); + await driver.get(url); + await waitForDocToLoad(); + return { + close() { + return driver.close(); + }, + open() { + return driver.switchTo().window(myTab); + } + }; +} + /** * Scrolls active Grid or Card list view. */ @@ -3042,6 +3118,10 @@ export async function changeBehavior(option: BehaviorActions|RegExp) { await waitForServer(); } +export async function columnBehavior() { + return (await driver.find(".test-field-behaviour").getText()); +} + /** * Gets all available options in the behavior menu. */ @@ -3426,15 +3506,15 @@ class Clipboard implements IClipboard { /** * Runs a Grist command in the browser window. */ -export async function sendCommand(name: CommandName) { - await driver.executeAsyncScript((name: any, done: any) => { - const result = (window as any).gristApp.allCommands[name].run(); +export async function sendCommand(name: CommandName, argument: any = null) { + await driver.executeAsyncScript((name: any, argument: any, done: any) => { + const result = (window as any).gristApp.allCommands[name].run(argument); if (result?.finally) { result.finally(done); } else { done(); } - }, name); + }, name, argument); await waitForServer(); }