diff --git a/app/client/ui/GridViewMenus.ts b/app/client/ui/GridViewMenus.ts index f0c88285..7f4192a2 100644 --- a/app/client/ui/GridViewMenus.ts +++ b/app/client/ui/GridViewMenus.ts @@ -3,6 +3,8 @@ 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'; import {testId, theme, vars} from 'app/client/ui2018/cssVars'; import {icon} from 'app/client/ui2018/icons'; import { @@ -265,7 +267,11 @@ function buildUUIDMenuItem(gridView: GridView, index?: number) { await gridView.gristDoc.convertToTrigger(colRef, 'UUID()'); }, {nestInActiveBundle: true}); }, - t('UUID'), + withInfoTooltip( + t('UUID'), + GristTooltips.uuid(), + {variant: 'hover'} + ), testId('new-columns-menu-shortcuts-uuid'), ); } @@ -522,8 +528,15 @@ function buildLookupSection(gridView: GridView, index?: number){ : [lookupMenu, reverseLookupMenu]; return [ - menuDivider(), - menuSubHeader(t("Lookups"), testId('new-columns-menu-lookups')), + menuDivider(), + menuSubHeader( + withInfoTooltip( + t('Lookups'), + GristTooltips.lookups(), + {variant: 'hover'} + ), + testId('new-columns-menu-lookups'), + ), ...menuContent ]; } diff --git a/app/client/ui/GristTooltips.ts b/app/client/ui/GristTooltips.ts index 27ad4bf0..f2d3fa04 100644 --- a/app/client/ui/GristTooltips.ts +++ b/app/client/ui/GristTooltips.ts @@ -35,7 +35,9 @@ export type Tooltip = | 'workOnACopy' | 'openAccessRules' | 'addRowConditionalStyle' - | 'addColumnConditionalStyle'; + | 'addColumnConditionalStyle' + | 'uuid' + | 'lookups'; export type TooltipContentFunc = (...domArgs: DomElementArg[]) => DomContents; @@ -98,6 +100,21 @@ see or edit which parts of your document.') ), ...args, ), + uuid: (...args: DomElementArg[]) => cssTooltipContent( + dom('div', t('A UUID is a randomly-generated string that is useful for unique identifiers and link keys.')), + dom('div', + cssLink({href: commonUrls.helpLinkKeys, target: '_blank'}, t('Learn more.')), + ), + ...args, + ), + lookups: (...args: DomElementArg[]) => cssTooltipContent( + dom('div', t('Lookups return data from related tables.')), + dom('div', t('Use reference columns to relate data in different tables.')), + dom('div', + cssLink({href: commonUrls.helpColRefs, target: '_blank'}, t('Learn more.')), + ), + ...args, + ), }; export interface BehavioralPromptContent { diff --git a/app/client/ui/PageWidgetPicker.ts b/app/client/ui/PageWidgetPicker.ts index 7fb23733..0bb35d3d 100644 --- a/app/client/ui/PageWidgetPicker.ts +++ b/app/client/ui/PageWidgetPicker.ts @@ -386,7 +386,7 @@ export class PageWidgetSelect extends Disposable { testId('selectby')) ), GristTooltips.selectBy(), - {tooltipMenuOptions: {attach: null}, domArgs: [ + {popupOptions: {attach: null}, domArgs: [ this._behavioralPromptsManager.attachTip('pageWidgetPickerSelectBy', { popupOptions: { attach: null, diff --git a/app/client/ui/ShareMenu.ts b/app/client/ui/ShareMenu.ts index 557d2ea6..1e922c0a 100644 --- a/app/client/ui/ShareMenu.ts +++ b/app/client/ui/ShareMenu.ts @@ -256,7 +256,7 @@ function menuWorkOnCopy(pageModel: DocPageModel) { withInfoTooltip( t("Edit without affecting the original"), GristTooltips.workOnACopy(), - {tooltipMenuOptions: {attach: null}} + {popupOptions: {attach: null}} ) ), ]; diff --git a/app/client/ui/tooltips.ts b/app/client/ui/tooltips.ts index a9b4ab6f..a8fe4a7c 100644 --- a/app/client/ui/tooltips.ts +++ b/app/client/ui/tooltips.ts @@ -12,7 +12,7 @@ import {makeLinks} from 'app/client/ui2018/links'; import {menuCssClass} from 'app/client/ui2018/menus'; import {dom, DomContents, DomElementArg, DomElementMethod, styled} from 'grainjs'; import Popper from 'popper.js'; -import {cssMenu, defaultMenuOptions, IMenuOptions, setPopupToCreateDom} from 'popweasel'; +import {cssMenu, cssMenuItem, defaultMenuOptions, IPopupOptions, setPopupToCreateDom} from 'popweasel'; import merge = require('lodash/merge'); export interface ITipOptions { @@ -307,10 +307,41 @@ export function tooltipCloseButton(ctl: ITooltipControl): HTMLElement { ); } +export interface InfoTooltipOptions { + /** Defaults to `click`. */ + variant?: InfoTooltipVariant; + /** Only applicable to the `click` variant. */ + popupOptions?: IPopupOptions; +} + +export type InfoTooltipVariant = 'click' | 'hover'; + /** - * Renders an info icon that shows a tooltip with the specified `content` on click. + * Renders an info icon that shows a tooltip with the specified `content`. */ -export function infoTooltip(content: DomContents, menuOptions?: IMenuOptions, ...domArgs: DomElementArg[]) { +export function infoTooltip( + content: DomContents, + options: InfoTooltipOptions = {}, + ...domArgs: DomElementArg[] +) { + const {variant = 'click'} = options; + switch (variant) { + case 'click': { + const {popupOptions} = options; + return buildClickableInfoTooltip(content, popupOptions, domArgs); + } + case 'hover': { + return buildHoverableInfoTooltip(content, domArgs); + } + } + +} + +function buildClickableInfoTooltip( + content: DomContents, + popupOptions?: IPopupOptions, + ...domArgs: DomElementArg[] +) { return cssInfoTooltipButton('?', (elem) => { setPopupToCreateDom( @@ -336,7 +367,7 @@ export function infoTooltip(content: DomContents, menuOptions?: IMenuOptions, .. testId('info-tooltip-popup'), ); }, - {...defaultMenuOptions, ...{placement: 'bottom-end'}, ...menuOptions}, + {...defaultMenuOptions, ...{placement: 'bottom-end'}, ...popupOptions}, ); }, testId('info-tooltip'), @@ -344,22 +375,41 @@ export function infoTooltip(content: DomContents, menuOptions?: IMenuOptions, .. ); } +function buildHoverableInfoTooltip(content: DomContents, ...domArgs: DomElementArg[]) { + return cssInfoTooltipIcon('?', + hoverTooltip(() => cssInfoTooltipTransientPopup( + content, + cssTooltipCorner(testId('tooltip-origin')), + {tabIndex: '-1'}, + testId('info-tooltip-popup'), + ), {closeOnClick: false}), + testId('info-tooltip'), + ...domArgs, + ); +} + export interface WithInfoTooltipOptions { + /** Defaults to `click`. */ + variant?: InfoTooltipVariant; domArgs?: DomElementArg[]; - tooltipButtonDomArgs?: DomElementArg[]; - tooltipMenuOptions?: IMenuOptions; + iconDomArgs?: DomElementArg[]; + /** Only applicable to the `click` variant. */ + popupOptions?: IPopupOptions; } /** - * Wraps `domContent` with a info tooltip button that displays the provided - * `tooltipContent` on click, and returns the wrapped element. + * Wraps `domContent` with a info tooltip icon that displays the provided + * `tooltipContent` and returns the wrapped element. * * The tooltip button is displayed to the right of `domContents`, and displays - * a popup on click. The popup can be dismissed by clicking away from it, clicking - * the close button in the top-right corner, or pressing Enter or Escape. + * a popup on click by default. The popup can be dismissed by clicking away from + * it; clicking the close button in the top-right corner; or pressing Enter or Escape. + * + * You may optionally specify `options.variant`, which controls whether the tooltip + * is shown on hover or on click. * * Arguments can be passed to both the top-level wrapped DOM element and the - * tooltip button element with `options.domArgs` and `options.tooltipButtonDomArgs` + * tooltip icon element with `options.domArgs` and `options.tooltipIconDomArgs` * respectively. * * Usage: @@ -374,10 +424,10 @@ export function withInfoTooltip( tooltipContent: DomContents, options: WithInfoTooltipOptions = {}, ) { - const {domArgs, tooltipButtonDomArgs, tooltipMenuOptions} = options; + const {variant = 'click', domArgs, iconDomArgs, popupOptions} = options; return cssDomWithTooltip( domContents, - infoTooltip(tooltipContent, tooltipMenuOptions, tooltipButtonDomArgs), + infoTooltip(tooltipContent, {variant, popupOptions}, iconDomArgs), ...(domArgs ?? []) ); } @@ -395,7 +445,7 @@ export function descriptionInfoTooltip( key: 'columnDescription', openOnClick: true, }; - const builder = () => cssDescriptionInfoTooltip( + const builder = () => cssInfoTooltipTransientPopup( body, // Used id test to find the origin of the tooltip regardless webdriver implementation (some of them start) cssTooltipCorner(testId('tooltip-origin')), @@ -422,7 +472,7 @@ const cssTooltipCorner = styled('div', ` visibility: hidden; `); -const cssDescriptionInfoTooltip = styled('div', ` +const cssInfoTooltipTransientPopup = styled('div', ` position: relative; white-space: pre-wrap; text-align: left; @@ -489,7 +539,7 @@ const cssTooltipCloseButton = styled('div', ` } `); -const cssInfoTooltipButton = styled('div', ` +const cssInfoTooltipIcon = styled('div', ` flex-shrink: 0; display: flex; align-items: center; @@ -499,9 +549,17 @@ const cssInfoTooltipButton = styled('div', ` border: 1px solid ${theme.controlSecondaryFg}; color: ${theme.controlSecondaryFg}; border-radius: 50%; - cursor: pointer; user-select: none; + .${cssMenuItem.className}-sel & { + color: ${theme.menuItemSelectedFg}; + border-color: ${theme.menuItemSelectedFg}; + } +`); + +const cssInfoTooltipButton = styled(cssInfoTooltipIcon, ` + cursor: pointer; + &:hover { border: 1px solid ${theme.controlSecondaryHoverFg}; color: ${theme.controlSecondaryHoverFg}; diff --git a/app/client/ui2018/menus.ts b/app/client/ui2018/menus.ts index 9470db5d..4ddf75d6 100644 --- a/app/client/ui2018/menus.ts +++ b/app/client/ui2018/menus.ts @@ -80,6 +80,7 @@ export function searchableMenu( dom.autoDispose(searchValue), dom.on('input', (_ev, elem) => { setSearchValue(elem.value); }), {placeholder: searchInputPlaceholder}, + testId('searchable-menu-input'), ), ), ), @@ -97,6 +98,7 @@ export function searchableMenu( } }); }), + testId('searchable-menu'), ]; } diff --git a/app/common/gristUrls.ts b/app/common/gristUrls.ts index cf35affd..ef94a416 100644 --- a/app/common/gristUrls.ts +++ b/app/common/gristUrls.ts @@ -80,6 +80,7 @@ export const commonUrls = { helpCustomWidgets: "https://support.getgrist.com/widget-custom", helpTelemetryLimited: "https://support.getgrist.com/telemetry-limited", helpCalendarWidget: "https://support.getgrist.com/widget-calendar", + helpLinkKeys: "https://support.getgrist.com/examples/2021-04-link-keys", plans: "https://www.getgrist.com/pricing", sproutsProgram: "https://www.getgrist.com/sprouts-program", contact: "https://www.getgrist.com/contact", diff --git a/test/nbrowser/GridViewNewColumnMenu.ts b/test/nbrowser/GridViewNewColumnMenu.ts index b37ff20f..3fbbcac1 100644 --- a/test/nbrowser/GridViewNewColumnMenu.ts +++ b/test/nbrowser/GridViewNewColumnMenu.ts @@ -68,7 +68,7 @@ describe('GridViewNewColumnMenu', function () { }); describe('column creation', function () { - it('should show rename menu after new column click', async 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'); @@ -86,6 +86,39 @@ describe('GridViewNewColumnMenu', function () { assert.lengthOf(columns, 4, 'wrong number of columns'); 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); + await gu.waitForServer(); + await driver.findWait('.test-column-title-close', 100).click(); + const columns = await gu.getColumnNames(); + assert.deepEqual(columns, ['D', 'A', 'B', 'C']); + await gu.undo(); + }); + + 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']); + await gu.undo(); + }); + + 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']); + await gu.undo(); + }); }); describe('hidden columns', function () { @@ -103,13 +136,13 @@ describe('GridViewNewColumnMenu', function () { await gu.addColumn('Add3'); }); - it('1 to 5 hidden columns, secion should be inline', async function () { + 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.find(`.test-new-columns-menu-hidden-columns-${column}`).isPresent(); + const isColumnPresent = await menu.findContent('li', column).isPresent(); assert.isTrue(isColumnPresent, `column ${column} is not present`); } await closeAddColumnMenu(); @@ -123,7 +156,7 @@ describe('GridViewNewColumnMenu', function () { await gu.moveToHidden('Add1'); await gu.moveToHidden('Add2'); await checkSection('A', 'B', 'C', 'Add1', 'Add2'); - await gu.undo(5); + await gu.undo(11); }); it('inline button should show column at the end of the table', async function () { @@ -156,11 +189,7 @@ describe('GridViewNewColumnMenu', function () { }); }); - describe('shortucts', function () { - describe('Timestamp', function () { - it('created at - should create new column with date triggered on create'); - }); - + describe('shortcuts', function () { describe('Timestamp', function () { it('created at - should create new column with date triggered on create', function () { @@ -178,6 +207,84 @@ describe('GridViewNewColumnMenu', function () { }); }); + + 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); + + 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); + }); + + it('should create new column that checks for duplicates in the specified column', async function () { + const menu = await openAddColumnIcon(); + await menu.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(), + 'True if len(Table1.lookupRecords(A=$A)) > 1 else False' + ); + await gu.sendKeys(Key.ESCAPE); + const columns = await gu.getColumnNames(); + assert.deepEqual(columns, ['A', 'B', 'C', 'Duplicate in A']); + await gu.undo(); + }); + }); + + 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); + }); + }); });