diff --git a/app/client/components/GridView.js b/app/client/components/GridView.js index b7aa2936..edaa17c2 100644 --- a/app/client/components/GridView.js +++ b/app/client/components/GridView.js @@ -34,7 +34,7 @@ const {onDblClickMatchElem} = require('app/client/lib/dblclick'); // Grist UI Components const {dom: grainjsDom, Holder, Computed} = require('grainjs'); const {closeRegisteredMenu, menu} = require('../ui2018/menus'); -const {calcFieldsCondition} = require('../ui/GridViewMenus'); +const {calcFieldsCondition, ColumnAddMenuOld} = require('../ui/GridViewMenus'); const {ColumnAddMenu, ColumnContextMenu, MultiColumnMenu, freezeAction} = require('../ui/GridViewMenus'); const {RowContextMenu} = require('../ui/RowContextMenu'); @@ -50,6 +50,8 @@ 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 {FieldBuilder} = require("../widgets/FieldBuilder"); +const {GRIST_NEW_COLUMN_MENU} = require("../models/features"); const t = makeT('GridView'); @@ -836,7 +838,7 @@ GridView.prototype.deleteRows = async function(rowIds) { GridView.prototype.addNewColumn = function() { this.insertColumn(this.viewSection.viewFields().peekLength) - .then(() => this.scrollPaneRight()); + .then(() => this.scrollPaneRight()); }; GridView.prototype.insertColumn = async function(index) { @@ -857,6 +859,33 @@ GridView.prototype.insertColumn = async function(index) { this.currentEditingColumnIndex(index); }; +if(GRIST_NEW_COLUMN_MENU) { + GridView.prototype.addNewColumnWithoutRenamePopup = async function() { + const index = this.viewSection.viewFields().peekLength; + const pos = tableUtil.fieldInsertPositions(this.viewSection.viewFields(), index)[0]; + var action = ['AddColumn', null, {"_position": pos}]; + await this.gristDoc.docData.bundleActions('Insert column', async () => { + const colInfo = await this.tableModel.sendTableAction(action); + if (!this.viewSection.isRaw.peek()) { + const fieldInfo = { + colRef: colInfo.colRef, + parentPos: pos, + parentId: this.viewSection.id.peek() + }; + await this.gristDoc.docModel.viewFields.sendTableAction(['AddRecord', null, fieldInfo]); + } + }); + const builder = new FieldBuilder(this.gristDoc, this.viewSection.viewFields().peek()[this.viewSection.viewFields().peekLength - 1], this.cursor); + return builder; + }; + + GridView.prototype.addNewFormulaColumn = async function(formula, name) { + const builder = await this.addNewColumnWithoutRenamePopup(); + await builder.gristDoc.convertToFormula(builder.field.colRef.peek(), formula); + return builder; + } +} + GridView.prototype.renameColumn = function(index) { this.currentEditingColumnIndex(index); }; @@ -1105,6 +1134,28 @@ GridView.prototype.buildDom = function() { } }; + const addColumnMenu = (gridView, viewSection)=> { + if(GRIST_NEW_COLUMN_MENU()) + { + return menu(ctl => [ColumnAddMenu(gridView, viewSection), testId('new-columns-menu')]); + } + else { + return [ + dom.on('click', 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 (viewSection.hiddenColumns().length === 0) { + ev.stopImmediatePropagation(); // Don't open the menu defined below + this.addNewColumn(); + } + }), + menu((ctl => ColumnAddMenuOld(gridView, viewSection))) + ] + } + } + + + return dom( 'div.gridview_data_pane.flexvbox', // offset for frozen columns - how much move them to the left @@ -1298,15 +1349,7 @@ GridView.prototype.buildDom = function() { this._modField = dom('div.column_name.mod-add-column.field', '+', kd.style("width", PLUS_WIDTH + 'px'), - dom.on('click', 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) { - ev.stopImmediatePropagation(); // Don't open the menu defined below - this.addNewColumn(); - } - }), - menu((ctl => ColumnAddMenu(this, this.viewSection))) + addColumnMenu(this, this.viewSection), ) )) ) diff --git a/app/client/components/GristDoc.ts b/app/client/components/GristDoc.ts index 75756d21..5215bf88 100644 --- a/app/client/components/GristDoc.ts +++ b/app/client/components/GristDoc.ts @@ -47,7 +47,6 @@ import {IPageWidget, toPageWidget} from 'app/client/ui/PageWidgetPicker'; import {linkFromId, selectBy} from 'app/client/ui/selectBy'; import {WebhookPage} from 'app/client/ui/WebhookPage'; import {startWelcomeTour} from 'app/client/ui/WelcomeTour'; -import {AttachedCustomWidgets, IAttachedCustomWidget, IWidgetType} from 'app/common/widgetTypes'; import {PlayerState, YouTubePlayer} from 'app/client/ui/YouTubePlayer'; import {isNarrowScreen, mediaSmall, mediaXSmall, testId, theme} from 'app/client/ui2018/cssVars'; import {IconName} from 'app/client/ui2018/IconList'; @@ -69,6 +68,7 @@ import {LocalPlugin} from "app/common/plugin"; import {StringUnion} from 'app/common/StringUnion'; import {TableData} from 'app/common/TableData'; import {DocStateComparison} from 'app/common/UserAPI'; +import {AttachedCustomWidgets, IAttachedCustomWidget, IWidgetType} from 'app/common/widgetTypes'; import {CursorPos} from 'app/plugin/GristAPI'; import { bundleChanges, @@ -1090,12 +1090,15 @@ export class GristDoc extends DisposableWithEvents { } // Convert column to data column with a trigger formula - public async convertToTrigger(colRefs: number, formula: string): Promise { + public async convertToTrigger( + colRefs: number, + formula: string, + recalcWhen: RecalcWhen = RecalcWhen.DEFAULT ): Promise { return this.docModel.columns.sendTableAction( ['UpdateRecord', colRefs, { isFormula: false, formula, - recalcWhen: RecalcWhen.DEFAULT, + recalcWhen: recalcWhen, recalcDeps: null, }] ); diff --git a/app/client/models/features.ts b/app/client/models/features.ts index 353fe0a3..a235a266 100644 --- a/app/client/models/features.ts +++ b/app/client/models/features.ts @@ -25,6 +25,10 @@ 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 db2636c1..81959c23 100644 --- a/app/client/ui/GridViewMenus.ts +++ b/app/client/ui/GridViewMenus.ts @@ -1,30 +1,181 @@ +import {allCommands} from 'app/client/components/commands'; import {makeT} from 'app/client/lib/localization'; -import { allCommands } from 'app/client/components/commands'; -import { ViewFieldRec } from 'app/client/models/entities/ViewFieldRec'; -import { testId, theme } from 'app/client/ui2018/cssVars'; -import { icon } from 'app/client/ui2018/icons'; -import { menuDivider, menuItem, menuItemCmd } from 'app/client/ui2018/menus'; -import { Sort } from 'app/common/SortSpec'; -import { dom, DomElementArg, styled } from 'grainjs'; +import {ViewFieldRec} from 'app/client/models/entities/ViewFieldRec'; +import {testId, theme} from 'app/client/ui2018/cssVars'; +import {icon} from 'app/client/ui2018/icons'; +import { + enhanceBySearch, + menuDivider, + menuItem, + menuItemCmd, + menuItemSubmenu, + menuSubHeader, + menuText +} from 'app/client/ui2018/menus'; +import {Sort} from 'app/common/SortSpec'; +import {dom, DomElementArg, Observable, styled} from 'grainjs'; +import {RecalcWhen} from "../../common/gristTypes"; +import {GristDoc} from "../components/GristDoc"; +import {ColumnRec} from "../models/entities/ColumnRec"; +import {FieldBuilder} from "../widgets/FieldBuilder"; import isEqual = require('lodash/isEqual'); const t = makeT('GridViewMenus'); +//encapsulation over the view that menu will be generated for interface IView { - addNewColumn: () => void; + gristDoc: GristDoc; + //adding new column to the view, and return a FieldBuilder that can be used to further modify the column + addNewColumn: () => Promise; + addNewColumnWithoutRenamePopup: () => Promise; showColumn: (colId: number, atIndex: number) => void; + //Add new colum to the view as formula column, with given column name and + //formula equation. + // Return a FieldBuilder that can be used to further modify the column + addNewFormulaColumn(formula: string, columnName: string): Promise; } interface IViewSection { viewFields: any; hiddenColumns: any; + columns: any; } -/** - * Creates a menu to add a new column. Should be used only when there are hidden columns to display, - * otherwise there is no need for this menu. - */ -export function ColumnAddMenu(gridView: IView, viewSection: IViewSection) { +interface IColumnInfo{ + colId: string; + label: string; + index: number; +} + + +// Section for "Show hidden column" in a colum menu. +// If there are no hidden columns - don't show the section. +// If there is more that X - show submenu +function MenuHideColumnSection(gridView: IView, viewSection: IViewSection){ + //function to generate the list with name of hidden columns and unhinging them on click + const listOfHiddenColumns = viewSection.hiddenColumns().map((col: any, index: number): IColumnInfo => { return { + colId:col.id(), label: col.label(), index: viewSection.columns().findIndex((c: any) => c.id() === col.id()), + }; }); + + //Generating dom and hadling actions in menu section for hidden columns - allow to unhide it. + const hiddenColumnMenu = () => { + //if there is more than 5 hidden columns - show submenu + if(listOfHiddenColumns.length > 5){ + return[ + menuItemSubmenu( + (ctl: any)=>{ + // enhance this submenu by adding search bar on the top. enhanceBySearch is doing basically two things: + // adding search bar, and expose searchCriteria observable to be used to generate list of items to be shown + return enhanceBySearch((searchCriteria)=> { + // put all hidden columns into observable + const hiddenColumns: Array = listOfHiddenColumns; + const dynamicHiddenColumnsList = Observable.create(null, hiddenColumns); + // when search criteria changes - filter the list of hidden columns and update the observable + searchCriteria.addListener((sc: string) => { + return dynamicHiddenColumnsList.set( + hiddenColumns.filter((c: IColumnInfo) => c.label.includes(sc))); + }); + // generate a list of menu items from the observable + return [ + // each hidden column is a menu item that will call showColumn on click + // and place column at the end of the table + dom.forEach(dynamicHiddenColumnsList, + (col: any) => menuItem( + ()=>{ gridView.showColumn(col.colId, viewSection.columns().length); }, + col.label //column label as menu item text + ) + ) + ]; + }); + }, + {}, //options - we do not need any for this submenu + t("Show hidden columns"), //text of the submenu + {class: menuItem.className} // style of the submenu + ) + ]; + // in case there are less than five hidden columns - show them all in the main level of the menu + } else { + // generate a list of menu items from the list of hidden columns + return listOfHiddenColumns.map((col: any) => + menuItem( + ()=> { gridView.showColumn(col.colId, viewSection.columns().length); }, + col.label, //column label as menu item text + testId(`new-columns-menu-hidden-columns-${col.label.replace(' ', '-')}`) + ) + ); + } + }; + + + return dom.maybe(() => viewSection.hiddenColumns().length > 0, ()=>[ + menuDivider(), + menuSubHeader(t("Hidden Columns"), testId('new-columns-menu-hidden-columns')), + hiddenColumnMenu()] + ); +} + +function MenuShortcuts(gridView: IView){ + return [ + menuDivider(), + menuSubHeader(t("Shortcuts"), testId('new-columns-menu-shortcuts')), + menuItemSubmenu((ctl: any)=>[ + menuItem( + () => addNewColumnWithTimestamp(gridView, false), t("Apply to new records"), + testId('new-columns-menu-shortcuts-timestamp-new') + ), + menuItem( + () => addNewColumnWithTimestamp(gridView, true), t("Apply on record changes"), + testId('new-columns-menu-shortcuts-timestamp-change') + ), + ], {}, t("Timestamp"), testId('new-columns-menu-shortcuts-timestamp')), + menuItemSubmenu((ctl: any)=>[ + menuItem( + () => addNewColumnWithAuthor(gridView, false), t("Apply to new records"), + testId('new-columns-menu-shortcuts-author-new') + ), + menuItem( + () => addNewColumnWithAuthor(gridView, true), t("Apply on record changes"), + testId('new-columns-menu-shortcuts-author-change') + ), + + ], {}, t("Authorship"), testId('new-columns-menu-shortcuts-author')), + ]; } + +function MenuLookups(viewSection: IViewSection, gridView: IView){ + return [ + menuDivider(), + menuSubHeader(t("Lookups"), testId('new-columns-menu-lookups')), + buildLookupsOptions(viewSection, gridView) + ]; +} + +function buildLookupsOptions(viewSection: IViewSection, gridView: IView){ + const referenceCollection = viewSection.columns().filter((e: ColumnRec)=> e.pureType()=="Ref"); + + if(referenceCollection.length == 0){ + return menuText(()=>{}, t("no reference column"), testId('new-columns-menu-lookups-none')); + } + //TODO: Make search work - right now enhanceBySearch searchQuery parameter is not subscribed and menu items are + // not updated when search query changes. Filter the columns names based on search query observable (like in + // MenuHideColumnSection) + return referenceCollection.map((ref: any) => menuItemSubmenu((ctl) => { + return enhanceBySearch((searchQuery) => [ + ...ref.refTable().columns().all().map((col: ColumnRec) => + menuItem( + async () => { + await gridView.addNewFormulaColumn(`$${ref.label()}.${col.label()}`, + `${ref.label()}_${col.label()}`); + }, col.label() + ) + ) + ]); + }, {}, ref.label(), {class: menuItem.className}, testId(`new-columns-menu-lookups-${ref.label()}`))); +} + +// Old version of column menu +// TODO: This is only valid as long as feature flag GRIST_NEW_COLUMN_MENU is existing in the system. +// Once it is removed (so production is working only with the new column menu, this function should be removed as well. +export function ColumnAddMenuOld(gridView: IView, viewSection: IViewSection) { return [ menuItem(() => gridView.addNewColumn(), t("Add Column")), menuDivider(), @@ -35,6 +186,56 @@ export function ColumnAddMenu(gridView: IView, viewSection: IViewSection) { }, t("Show column {{- label}}", {label: col.label()}))) ]; } + +/** + * Creates a menu to add a new column. + */ +export function ColumnAddMenu(gridView: IView, viewSection: IViewSection) { + return [ + menuItem( + async () => { await gridView.addNewColumn(); }, + `+ ${t("Add Column")}`, + testId('new-columns-menu-add-new') + ), + MenuHideColumnSection(gridView, viewSection), + MenuLookups(viewSection, gridView), + MenuShortcuts(gridView), + ]; +} + +//TODO: figure out how to change columns names; +const addNewColumnWithTimestamp = async (gridView: IView, triggerOnUpdate: boolean) => { + await gridView.gristDoc.docData.bundleActions('Add new column with timestamp', async () => { + const column = await gridView.addNewColumnWithoutRenamePopup(); + if (!triggerOnUpdate) { + await column.gristDoc.convertToTrigger(column.origColumn.id.peek(), 'NOW()', RecalcWhen.DEFAULT); + await column.field.displayLabel.setAndSave(t('Created At')); + await column.field.column.peek().type.setAndSave('DateTime'); + } else { + await column.gristDoc.convertToTrigger(column.origColumn.id.peek(), 'NOW()', RecalcWhen.MANUAL_UPDATES); + await column.field.displayLabel.setAndSave(t('Last Updated At')); + await column.field.column.peek().type.setAndSave('DateTime'); + } + }, {nestInActiveBundle: true}); +}; + +const addNewColumnWithAuthor = async (gridView: IView, triggerOnUpdate: boolean) => { + await gridView.gristDoc.docData.bundleActions('Add new column with author', async () => { + const column = await gridView.addNewColumnWithoutRenamePopup(); + if (!triggerOnUpdate) { + await column.gristDoc.convertToTrigger(column.origColumn.id.peek(), 'user.Name', RecalcWhen.DEFAULT); + await column.field.displayLabel.setAndSave(t('Created By')); + await column.field.column.peek().type.setAndSave('Text'); + } else { + await column.gristDoc.convertToTrigger(column.origColumn.id.peek(), 'user.Name', RecalcWhen.MANUAL_UPDATES); + await column.field.displayLabel.setAndSave(t('Last Updated By')); + await column.field.column.peek().type.setAndSave('Text'); + } + }, {nestInActiveBundle: true}); +}; + + + export interface IMultiColumnContextMenu { // For multiple selection, true/false means the value applies to all columns, 'mixed' means it's // true for some columns, but not all. diff --git a/app/client/ui2018/menus.ts b/app/client/ui2018/menus.ts index 7ff22fba..d325b23c 100644 --- a/app/client/ui2018/menus.ts +++ b/app/client/ui2018/menus.ts @@ -8,8 +8,10 @@ import { testId, theme, vars } from 'app/client/ui2018/cssVars'; import { IconName } from 'app/client/ui2018/IconList'; import { icon } from 'app/client/ui2018/icons'; import { cssSelectBtn } from 'app/client/ui2018/select'; -import { BindableValue, Computed, dom, DomElementArg, DomElementMethod, IDomArgs, - MaybeObsArray, MutableObsArray, Observable, styled } from 'grainjs'; +import { + BindableValue, Computed, dom, DomElementArg, DomElementMethod, IDomArgs, + MaybeObsArray, MutableObsArray, Observable, styled +} from 'grainjs'; import * as weasel from 'popweasel'; const t = makeT('menus'); @@ -47,6 +49,27 @@ export function menu(createFunc: weasel.MenuCreateFunc, options?: weasel.IMenuOp return weasel.menu(wrappedCreateFunc, {...defaults, ...options}); } +const cssSearchField = styled('input', + 'border: none;'+ + 'background-color: transparent;'+ + 'padding: 8px 24px 4px 24px;'+ + '&:focus {outline: none;}' +); +export function enhanceBySearch( menuFunc: (searchCriteria: Observable) => DomElementArg[]): DomElementArg[] +{ + const searchCriteria = Observable.create(null, ''); + const searchInput = [ + menuItemStatic( + cssSearchField( + dom.on('input', (_ev, elem) => searchCriteria.set(elem.value)), + {placeholder: '🔍\uFE0E\t' + t("Search columns")} + ) + ), + menuDivider(), + ]; + return [...searchInput, ...menuFunc(searchCriteria)]; +} + // TODO Weasel doesn't allow other options for submenus, but probably should. export type ISubMenuOptions = weasel.ISubMenuOptions & weasel.IPopupOptions; @@ -78,7 +101,7 @@ export const cssMenuElem = styled('div', ` } `); -const menuItemStyle = ` +export const menuItemStyle = ` justify-content: flex-start; align-items: center; color: ${theme.menuItemFg}; @@ -94,6 +117,8 @@ const menuItemStyle = ` } `; +export const menuItemStatic = styled('div', menuItemStyle); + export const menuCssClass = cssMenuElem.className; // Add grist-floating-menu class to support existing browser tests @@ -376,17 +401,23 @@ export function selectMenu( items: () => DomElementArg[], ...args: IDomArgs ) { - const _menu = cssSelectMenuElem(testId('select-menu')); return cssSelectBtn( label, icon('Dropdown'), - menu( + listOfMenuItems(items), + ...args, + ); +} + +export function listOfMenuItems(items: () => DomElementArg[],) { + const _menu = cssSelectMenuElem(testId('select-menu')); + return menu( items, { ...weasel.defaultMenuOptions, menuCssClass: _menu.className + ' grist-floating-menu', - stretchToSelector : `.${cssSelectBtn.className}`, - trigger : [(triggerElem, ctl) => { + stretchToSelector: `.${cssSelectBtn.className}`, + trigger: [(triggerElem, ctl) => { const isDisabled = () => triggerElem.classList.contains('disabled'); dom.onElem(triggerElem, 'click', () => isDisabled() || ctl.toggle()); dom.onKeyElem(triggerElem as HTMLElement, 'keydown', { @@ -395,8 +426,6 @@ export function selectMenu( }); }] }, - ), - ...args, ); } @@ -434,10 +463,12 @@ export const menuText = styled('div', ` cursor: default; `); + export const menuItem = styled(weasel.menuItem, menuItemStyle); export const menuItemLink = styled(weasel.menuItemLink, menuItemStyle); + /** * A version of menuItem which runs the action on next tick, allowing the menu to close even when * the action causes the disabling of the element being clicked. diff --git a/app/common/gristUrls.ts b/app/common/gristUrls.ts index 0647a919..73cc8fdd 100644 --- a/app/common/gristUrls.ts +++ b/app/common/gristUrls.ts @@ -676,6 +676,9 @@ 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 cf66c59a..508eb105 100644 --- a/app/server/lib/sendAppPage.ts +++ b/app/server/lib/sendAppPage.ts @@ -79,6 +79,7 @@ export function makeGristConfig(options: MakeGristConfigOptons): GristLoadConfig featureFormulaAssistant: Boolean(process.env.OPENAI_API_KEY || process.env.ASSISTANT_CHAT_COMPLETION_ENDPOINT), assistantService: process.env.OPENAI_API_KEY ? 'OpenAI' : undefined, permittedCustomWidgets: getPermittedCustomWidgets(), + gristNewColumnMenu: isAffirmative(process.env.GRIST_NEW_COLUMN_MENU), supportEmail: SUPPORT_EMAIL, userLocale: (req as RequestWithLogin | undefined)?.user?.options?.locale, telemetry: server?.getTelemetry().getTelemetryConfig(), diff --git a/buildtools/fly-template.toml b/buildtools/fly-template.toml index 2345f059..a56a4d84 100644 --- a/buildtools/fly-template.toml +++ b/buildtools/fly-template.toml @@ -10,6 +10,7 @@ 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/GridViewNewColumnMenu.ts b/test/nbrowser/GridViewNewColumnMenu.ts new file mode 100644 index 00000000..b37ff20f --- /dev/null +++ b/test/nbrowser/GridViewNewColumnMenu.ts @@ -0,0 +1,231 @@ +import {driver, Key, WebElement} from "mocha-webdriver"; +import {DocCreationInfo} from "app/common/DocListAPI"; +import {UserAPIImpl} from "app/common/UserAPI"; +import {assert} from "chai"; +import * as gu from "./gristUtils"; +import {setupTestSuite} from "./testUtils"; + + +describe('GridViewNewColumnMenu', function () { + if(process.env.GRIST_NEW_COLUMN_MENU) { + this.timeout('5m'); + const cleanup = setupTestSuite(); + + //helpers + let session: gu.Session, doc: DocCreationInfo, apiImpl: UserAPIImpl; + + before(async function () { + session = await gu.session().login(); + await createEmptyDoc('ColumnMenu'); + }); + + this.afterEach(async function () { + 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); + }); + + 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ź"}], + ]); + }; + + const addReferenceColumnToManinTable = async () => { + //add reference column + await apiImpl.applyUserActions(doc.id, [ + ['AddColumn', 'Table1', 'Reference', {type: 'Ref:Reference'}], + ]); + }; + + 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(); + }); + }); + + 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(); + }); + + 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 + const columns = await gu.getColumnNames(); + assert.include(columns, 'D', 'new column is not present'); + assert.lengthOf(columns, 4, 'wrong number of columns'); + await gu.undo(); + }); + }); + + 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(); + }); + + describe('inline menu section', function () { + before(async function () { + await gu.addColumn('Add1'); + await gu.addColumn('Add2'); + await gu.addColumn('Add3'); + }); + + it('1 to 5 hidden columns, secion 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.find(`.test-new-columns-menu-hidden-columns-${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(5); + }); + + it('inline button should show column at the end of the table', async function () { + }); + }); + + describe('submenu section', function () { + it('more than 5 hidden columns, section should be in submenu', async function () { + }); + + it('submenu should be searchable', async function () { + }); + + it('submenu button should show column at the end of the table', async function () { + }); + }); + }); + + describe('lookups', function () { + before(async function () { + //save current state + }); + + after(async function () { + //restore current state + }); + it('should show columns in menu with lookup', async function () { + }); + it('should create formula column with data from selected column', async function () { + }); + }); + + describe('shortucts', function () { + describe('Timestamp', function () { + it('created at - should create new column with date triggered on create'); + }); + + describe('Timestamp', function () { + it('created at - should create new column with date triggered on create', function () { + + }); + it('modified at - should create new column with date triggered on change', function () { + + }); + }); + + 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', function () { + + }); + }); + }); + + + 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(); + } + + async function openAddColumnIcon() { + const isMenuPresent = await driver.find(".test-new-columns-menu").isPresent(); + if (!isMenuPresent) { + await driver.findWait(".mod-add-column", 100).click(); + } + return driver.findWait(".test-new-columns-menu", 100); + } + + 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'); + } + } + + 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`); + }; + } +}); diff --git a/test/nbrowser/gristUtils.ts b/test/nbrowser/gristUtils.ts index c863e6e5..0afe8aa2 100644 --- a/test/nbrowser/gristUtils.ts +++ b/test/nbrowser/gristUtils.ts @@ -15,6 +15,7 @@ import * as PluginApi from 'app/plugin/grist-plugin-api'; import {csvDecodeRow} from 'app/common/csvFormat'; import { AccessLevel } from 'app/common/CustomWidget'; import { decodeUrl } from 'app/common/gristUrls'; +import { isAffirmative } from "app/common/gutil"; import { FullUser, UserProfile } from 'app/common/LoginSessionAPI'; import { resetOrg } from 'app/common/resetOrg'; import { UserAction } from 'app/common/DocActions'; @@ -2328,6 +2329,9 @@ 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(); + } // 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) {