From eeab42e270e53341a8572ab55ed758276a4d30d6 Mon Sep 17 00:00:00 2001 From: Ghislain B Date: Sat, 28 Oct 2023 21:07:25 -0400 Subject: [PATCH] feat: add sub-menu(s) to HeaderMenu plugin (#1158) * feat: add sub-menu(s) to HeaderMenu plugin --- .../src/examples/example14.scss | 7 + .../src/examples/example14.ts | 57 ++++ .../__tests__/slickCellMenu.plugin.spec.ts | 6 +- .../__tests__/slickContextMenu.spec.ts | 4 +- .../__tests__/slickHeaderMenu.spec.ts | 205 +++++++++++- .../common/src/extensions/menuBaseClass.ts | 5 +- .../common/src/extensions/slickGridMenu.ts | 1 - .../common/src/extensions/slickHeaderMenu.ts | 300 ++++++++++++------ .../interfaces/headerMenuItems.interface.ts | 8 +- .../interfaces/headerMenuOption.interface.ts | 3 + test/cypress/e2e/example01.cy.ts | 6 +- test/cypress/e2e/example04.cy.ts | 4 +- test/cypress/e2e/example07.cy.ts | 14 +- test/cypress/e2e/example09.cy.ts | 2 +- test/cypress/e2e/example10.cy.ts | 28 +- test/cypress/e2e/example11.cy.ts | 2 +- test/cypress/e2e/example13.cy.ts | 4 +- test/cypress/e2e/example14.cy.ts | 95 +++++- test/cypress/e2e/example15.cy.ts | 2 +- 19 files changed, 616 insertions(+), 137 deletions(-) diff --git a/examples/vite-demo-vanilla-bundle/src/examples/example14.scss b/examples/vite-demo-vanilla-bundle/src/examples/example14.scss index 8ecaa129e..fee8a3380 100644 --- a/examples/vite-demo-vanilla-bundle/src/examples/example14.scss +++ b/examples/vite-demo-vanilla-bundle/src/examples/example14.scss @@ -3,3 +3,10 @@ $control-height: 2.4em; // @import '@slickgrid-universal/common/dist/styles/sass/slickgrid-theme-salesforce.scss'; @import 'bulma/bulma'; + +.salmon { + color: lightsalmon; +} +.green { + color: rgb(127, 196, 24); +} \ No newline at end of file diff --git a/examples/vite-demo-vanilla-bundle/src/examples/example14.ts b/examples/vite-demo-vanilla-bundle/src/examples/example14.ts index a6ec29eec..f19b90d7d 100644 --- a/examples/vite-demo-vanilla-bundle/src/examples/example14.ts +++ b/examples/vite-demo-vanilla-bundle/src/examples/example14.ts @@ -355,6 +355,49 @@ export default class Example14 { }, ]; + // add custom Header Menu to all columns except "Action" + this.columnDefinitions.forEach(col => { + col.header = { + menu: { + items: [ + { command: '', divider: true, positionOrder: 98 }, + { + // we can also have multiple nested sub-menus + command: 'custom-actions', title: 'Hello', positionOrder: 99, + items: [ + { command: 'hello-world', title: 'Hello World' }, + { command: 'hello-slickgrid', title: 'Hello SlickGrid' }, + { + command: 'sub-menu', title: `Let's play`, cssClass: 'green', subMenuTitle: 'choose your game', subMenuTitleCssClass: 'text-italic salmon', + items: [ + { command: 'sport-badminton', title: 'Badminton' }, + { command: 'sport-tennis', title: 'Tennis' }, + { command: 'sport-racquetball', title: 'Racquetball' }, + { command: 'sport-squash', title: 'Squash' }, + ] + } + ] + }, + { + command: 'feedback', title: 'Feedback', positionOrder: 100, + items: [ + { command: 'request-update', title: 'Request update from supplier', iconCssClass: 'mdi mdi-star', tooltip: 'this will automatically send an alert to the shipping team to contact the user for an update' }, + 'divider', + { + command: 'sub-menu', title: 'Contact Us', iconCssClass: 'mdi mdi-account', subMenuTitle: 'contact us...', subMenuTitleCssClass: 'italic', + items: [ + { command: 'contact-email', title: 'Email us', iconCssClass: 'mdi mdi-pencil-outline' }, + { command: 'contact-chat', title: 'Chat with us', iconCssClass: 'mdi mdi-message-text-outline' }, + { command: 'contact-meeting', title: 'Book an appointment', iconCssClass: 'mdi mdi-coffee' }, + ] + } + ] + } + ] + } + }; + }); + this.gridOptions = { eventNamingStyle: EventNamingStyle.lowerCase, editable: true, @@ -434,6 +477,20 @@ export default class Example14 { }, // when using the cellMenu, you can change some of the default options and all use some of the callback methods enableCellMenu: true, + headerMenu: { + subItemChevronClass: 'mdi mdi-chevron-down mdi-rotate-270', + onCommand: (_e, args) => { + // e.preventDefault(); // preventing default event would keep the menu open after the execution + const command = args.item?.command; + if (command.includes('hello-')) { + alert(args?.item.title); + } else if (command.includes('sport-')) { + alert('Just do it, play ' + args?.item?.title); + } else if (command.includes('contact-')) { + alert('Command: ' + args?.item?.command); + } + }, + } }; } diff --git a/packages/common/src/extensions/__tests__/slickCellMenu.plugin.spec.ts b/packages/common/src/extensions/__tests__/slickCellMenu.plugin.spec.ts index 130f4c11a..47b898971 100644 --- a/packages/common/src/extensions/__tests__/slickCellMenu.plugin.spec.ts +++ b/packages/common/src/extensions/__tests__/slickCellMenu.plugin.spec.ts @@ -316,12 +316,12 @@ describe('CellMenu Plugin', () => {
  • -
    +
    Sub Commands
  • -
    +
    Sub Commands 2
  • @@ -804,7 +804,7 @@ describe('CellMenu Plugin', () => {
  • -
    +
    Sub Options
  • diff --git a/packages/common/src/extensions/__tests__/slickContextMenu.spec.ts b/packages/common/src/extensions/__tests__/slickContextMenu.spec.ts index 5b5cdb67d..0b1504e3d 100644 --- a/packages/common/src/extensions/__tests__/slickContextMenu.spec.ts +++ b/packages/common/src/extensions/__tests__/slickContextMenu.spec.ts @@ -342,7 +342,7 @@ describe('ContextMenu Plugin', () => {
  • -
    +
    Sub Commands
  • @@ -1349,7 +1349,7 @@ describe('ContextMenu Plugin', () => {
  • -
    +
    Sub Options
  • diff --git a/packages/common/src/extensions/__tests__/slickHeaderMenu.spec.ts b/packages/common/src/extensions/__tests__/slickHeaderMenu.spec.ts index 96da2b704..4e02e3698 100644 --- a/packages/common/src/extensions/__tests__/slickHeaderMenu.spec.ts +++ b/packages/common/src/extensions/__tests__/slickHeaderMenu.spec.ts @@ -50,7 +50,7 @@ const gridStub = { getColumns: jest.fn(), getColumnIndex: jest.fn(), getContainerNode: jest.fn(), - getGridPosition: jest.fn(), + getGridPosition: () => ({ width: 10, left: 0 }), getUID: () => 'slickgrid12345', getOptions: () => gridOptionsMock, registerPlugin: jest.fn(), @@ -78,6 +78,7 @@ const filterServiceStub = { const pubSubServiceStub = { publish: jest.fn(), subscribe: jest.fn(), + subscribeEvent: jest.fn(), unsubscribe: jest.fn(), unsubscribeAll: jest.fn(), } as BasePubSubService; @@ -171,6 +172,7 @@ describe('HeaderMenu Plugin', () => { describe('plugins - Header Menu', () => { let gridContainerDiv: HTMLDivElement; let headerDiv: HTMLDivElement; + let headersDiv: HTMLDivElement; beforeEach(() => { jest.spyOn(SharedService.prototype, 'slickGrid', 'get').mockReturnValue(gridStub); @@ -184,8 +186,13 @@ describe('HeaderMenu Plugin', () => { headerDiv.className = 'slick-header-column'; gridContainerDiv = document.createElement('div'); gridContainerDiv.className = 'slickgrid-container'; + headersDiv = document.createElement('div'); + headersDiv.className = 'slick-header-columns'; jest.spyOn(gridStub, 'getContainerNode').mockReturnValue(gridContainerDiv); jest.spyOn(gridStub, 'getGridPosition').mockReturnValue({ top: 10, bottom: 5, left: 15, right: 22, width: 225 } as ElementPosition); + headersDiv.appendChild(headerDiv); + gridContainerDiv.appendChild(headersDiv); + document.body.appendChild(gridContainerDiv); }); afterEach(() => { @@ -378,7 +385,7 @@ describe('HeaderMenu Plugin', () => { gridContainerDiv.querySelector('.slick-menu-item.mdi-lightbulb-on')!.dispatchEvent(new Event('click', { bubbles: true, cancelable: true, composed: false })); expect(actionMock).toHaveBeenCalled(); - expect(gridContainerDiv.innerHTML).toBe(''); + expect(headerDiv.querySelector('.slick-header-menu-button')!.innerHTML).toBe(''); }); it('should populate a Header Menu and a 2nd button and expect the "onCommand" handler to be executed when defined', () => { @@ -404,7 +411,7 @@ describe('HeaderMenu Plugin', () => { gridContainerDiv.querySelector('.slick-menu-item.mdi-lightbulb-on')!.dispatchEvent(new Event('click', { bubbles: true, cancelable: true, composed: false })); expect(onCommandMock).toHaveBeenCalled(); - expect(gridContainerDiv.innerHTML).toBe(''); + expect(headerDiv.querySelector('.slick-header-menu-button')!.innerHTML).toBe(''); }); it('should populate a Header Menu and a 2nd button is "disabled" but still expect the button NOT to be disabled because the "itemUsabilityOverride" has priority over the "disabled" property', () => { @@ -447,11 +454,9 @@ describe('HeaderMenu Plugin', () => { Object.defineProperty(buttonElm, 'clientWidth', { writable: true, configurable: true, value: 350 }); Object.defineProperty(plugin.menuElement, 'clientWidth', { writable: true, configurable: true, value: 275 }); Object.defineProperty(clickEvent, 'target', { writable: true, configurable: true, value: buttonElm }); - plugin.showMenu(clickEvent, columnsMock[0], columnsMock[0].header!.menu!); expect(menuElm).toBeTruthy(); expect(menuElm.clientWidth).toBe(275); - expect(menuElm.style.left).toBe('75px'); expect(commandElm).toBeTruthy(); expect(removeExtraSpaces(commandElm.outerHTML)).toBe(removeExtraSpaces( `
  • @@ -515,6 +520,11 @@ describe('HeaderMenu Plugin', () => {
  • ` )); + // click inside menu shouldn't close it + plugin.menuElement!.dispatchEvent(new Event('mousedown', { bubbles: true, cancelable: false, composed: false })); + expect(plugin.menuElement).toBeTruthy(); + + // click anywhere else should close it const bodyElm = document.body; bodyElm.dispatchEvent(new Event('mousedown', { bubbles: true })); expect(hideMenuSpy).toHaveBeenCalled(); @@ -579,6 +589,191 @@ describe('HeaderMenu Plugin', () => { }); }); + describe('with sub-menus', () => { + let columnsMock: Column[]; + + beforeEach(() => { + columnsMock = [ + { id: 'field1', field: 'field1', name: 'Field 1', width: 100, }, + { + id: 'field3', field: 'field3', name: 'Field 3', columnGroup: 'Billing', + header: { + menu: { + items: [ + { command: 'help', title: 'Help', textCssClass: 'red bold' }, + { + command: 'sub-commands', title: 'Sub Commands', subMenuTitle: 'Sub Command Title', items: [ + { command: 'command3', title: 'Command 3', positionOrder: 70, }, + { command: 'command4', title: 'Command 4', positionOrder: 71, }, + { + command: 'more-sub-commands', title: 'More Sub Commands', subMenuTitle: 'Sub Command Title 2', subMenuTitleCssClass: 'color-warning', items: [ + { command: 'command5', title: 'Command 5', positionOrder: 72, }, + ] + } + ] + }, + { + command: 'sub-commands2', title: 'Sub Commands 2', items: [ + { command: 'command33', title: 'Command 33', positionOrder: 70, }, + ] + } + ] + } + }, width: 75, + }, + ] as Column[]; + }); + + it('should create Header Menu item with commands sub-menu items and expect sub-menu list to show in the DOM element aligned left when sub-menu is clicked', () => { + const onCommandMock = jest.fn(); + const disposeSubMenuSpy = jest.spyOn(plugin, 'disposeSubMenus'); + Object.defineProperty(document.documentElement, 'clientWidth', { writable: true, configurable: true, value: 50 }); + jest.spyOn(gridStub, 'getColumns').mockReturnValueOnce(columnsMock); + + plugin.init({ autoAlign: true }); + plugin.addonOptions.onCommand = onCommandMock; + + const eventData = { ...new Slick.EventData(), preventDefault: jest.fn() }; + gridStub.onHeaderCellRendered.notify({ column: columnsMock[1], node: headerDiv, grid: gridStub }, eventData, gridStub); + const headerButtonElm = headerDiv.querySelector('.slick-header-menu-button') as HTMLDivElement; + headerButtonElm.dispatchEvent(new Event('click', { bubbles: true, cancelable: true, composed: false })); + const headerMenu1Elm = gridContainerDiv.querySelector('.slick-header-menu.slick-menu-level-0') as HTMLDivElement; + const commandList1Elm = headerMenu1Elm.querySelector('.slick-menu-command-list') as HTMLDivElement; + Object.defineProperty(commandList1Elm, 'clientWidth', { writable: true, configurable: true, value: 70 }); + const subCommands1Elm = commandList1Elm.querySelector('[data-command="sub-commands"]') as HTMLDivElement; + Object.defineProperty(subCommands1Elm, 'clientWidth', { writable: true, configurable: true, value: 70 }); + const commandContentElm2 = subCommands1Elm.querySelector('.slick-menu-content') as HTMLDivElement; + const commandChevronElm = commandList1Elm.querySelector('.sub-item-chevron') as HTMLSpanElement; + + subCommands1Elm!.dispatchEvent(new Event('click', { bubbles: true, cancelable: true, composed: false })); + const headerMenu2Elm = document.body.querySelector('.slick-header-menu.slick-menu-level-1') as HTMLDivElement; + const commandList2Elm = headerMenu2Elm.querySelector('.slick-menu-command-list') as HTMLDivElement; + const subCommand3Elm = commandList2Elm.querySelector('[data-command="command3"]') as HTMLDivElement; + const subCommands2Elm = commandList2Elm.querySelector('[data-command="more-sub-commands"]') as HTMLDivElement; + + subCommands2Elm!.dispatchEvent(new Event('click', { bubbles: true, cancelable: true, composed: false })); + const cellMenu3Elm = document.body.querySelector('.slick-header-menu.slick-menu-level-2') as HTMLDivElement; + const commandList3Elm = cellMenu3Elm.querySelector('.slick-menu-command-list') as HTMLDivElement; + const subCommand5Elm = commandList3Elm.querySelector('[data-command="command5"]') as HTMLDivElement; + const subMenuTitleElm = commandList3Elm.querySelector('.slick-menu-title') as HTMLDivElement; + + expect(commandList1Elm.querySelectorAll('.slick-menu-item').length).toBe(3); + expect(commandList2Elm.querySelectorAll('.slick-menu-item').length).toBe(3); + expect(commandContentElm2.textContent).toBe('Sub Commands'); + expect(subMenuTitleElm.textContent).toBe('Sub Command Title 2'); + expect(subMenuTitleElm.className).toBe('slick-menu-title color-warning'); + expect(commandChevronElm.className).toBe('sub-item-chevron'); + expect(subCommand3Elm.textContent).toContain('Command 3'); + expect(subCommand5Elm.textContent).toContain('Command 5'); + expect(headerMenu1Elm.classList.contains('dropleft')); + expect(headerMenu2Elm.classList.contains('dropup')).toBeFalsy(); + expect(headerMenu2Elm.classList.contains('dropdown')).toBeTruthy(); + + // return Grid Menu menu/sub-menu if it's already opened unless we are on different sub-menu tree if so close them all + subCommands1Elm!.dispatchEvent(new Event('click')); + expect(disposeSubMenuSpy).toHaveBeenCalledTimes(1); + const subCommands12Elm = commandList1Elm.querySelector('[data-command="sub-commands2"]') as HTMLDivElement; + subCommands12Elm!.dispatchEvent(new Event('click')); + expect(disposeSubMenuSpy).toHaveBeenCalledTimes(2); + expect(disposeSubMenuSpy).toHaveBeenCalled(); + }); + + it('should create a Header Menu item with commands sub-menu items and expect sub-menu list to show in the DOM element align right when sub-menu is clicked', () => { + const onCommandMock = jest.fn(); + const disposeSubMenuSpy = jest.spyOn(plugin, 'disposeSubMenus'); + Object.defineProperty(document.documentElement, 'clientWidth', { writable: true, configurable: true, value: 50 }); + jest.spyOn(gridStub, 'getColumns').mockReturnValueOnce(columnsMock); + + plugin.init({ autoAlign: true, subItemChevronClass: 'mdi mdi-chevron-right' }); + plugin.addonOptions.onCommand = onCommandMock; + + const eventData = { ...new Slick.EventData(), preventDefault: jest.fn() }; + gridStub.onHeaderCellRendered.notify({ column: columnsMock[1], node: headerDiv, grid: gridStub }, eventData, gridStub); + const headerButtonElm = headerDiv.querySelector('.slick-header-menu-button') as HTMLDivElement; + headerButtonElm.dispatchEvent(new Event('click', { bubbles: true, cancelable: true, composed: false })); + const headerMenu1Elm = gridContainerDiv.querySelector('.slick-header-menu.slick-menu-level-0') as HTMLDivElement; + const commandList1Elm = headerMenu1Elm.querySelector('.slick-menu-command-list') as HTMLDivElement; + const subCommands1Elm = commandList1Elm.querySelector('[data-command="sub-commands"]') as HTMLDivElement; + const commandContentElm2 = subCommands1Elm.querySelector('.slick-menu-content') as HTMLDivElement; + const commandChevronElm = commandList1Elm.querySelector('.sub-item-chevron') as HTMLSpanElement; + + subCommands1Elm!.dispatchEvent(new Event('click')); + const headerMenu2Elm = document.body.querySelector('.slick-header-menu.slick-menu-level-1') as HTMLDivElement; + const commandList2Elm = headerMenu2Elm.querySelector('.slick-menu-command-list') as HTMLDivElement; + const subCommand3Elm = commandList2Elm.querySelector('[data-command="command3"]') as HTMLDivElement; + const subCommands2Elm = commandList2Elm.querySelector('[data-command="more-sub-commands"]') as HTMLDivElement; + + subCommands2Elm!.dispatchEvent(new Event('click')); + const cellMenu3Elm = document.body.querySelector('.slick-header-menu.slick-menu-level-2') as HTMLDivElement; + const commandList3Elm = cellMenu3Elm.querySelector('.slick-menu-command-list') as HTMLDivElement; + const subCommand5Elm = commandList3Elm.querySelector('[data-command="command5"]') as HTMLDivElement; + const subMenuTitleElm = commandList3Elm.querySelector('.slick-menu-title') as HTMLDivElement; + + expect(commandList1Elm.querySelectorAll('.slick-menu-item').length).toBe(3); + expect(commandList2Elm.querySelectorAll('.slick-menu-item').length).toBe(3); + expect(commandContentElm2.textContent).toBe('Sub Commands'); + expect(subMenuTitleElm.textContent).toBe('Sub Command Title 2'); + expect(subMenuTitleElm.className).toBe('slick-menu-title color-warning'); + expect(commandChevronElm.className).toBe('sub-item-chevron mdi mdi-chevron-right'); + expect(subCommand3Elm.textContent).toContain('Command 3'); + expect(subCommand5Elm.textContent).toContain('Command 5'); + expect(headerMenu1Elm.classList.contains('dropright')); + expect(headerMenu2Elm.classList.contains('dropup')).toBeFalsy(); + expect(headerMenu2Elm.classList.contains('dropdown')).toBeTruthy(); + + // return menu/sub-menu if it's already opened unless we are on different sub-menu tree if so close them all + subCommands1Elm!.dispatchEvent(new Event('click')); + expect(disposeSubMenuSpy).toHaveBeenCalledTimes(1); + const subCommands12Elm = commandList1Elm.querySelector('[data-command="sub-commands2"]') as HTMLDivElement; + subCommands12Elm!.dispatchEvent(new Event('click')); + expect(disposeSubMenuSpy).toHaveBeenCalledTimes(2); + expect(disposeSubMenuSpy).toHaveBeenCalled(); + }); + + it('should create a Grid Menu item with commands sub-menu items and expect sub-menu to be positioned on top (dropup)', () => { + const onCommandMock = jest.fn(); + Object.defineProperty(document.documentElement, 'clientWidth', { writable: true, configurable: true, value: 50 }); + jest.spyOn(gridStub, 'getColumns').mockReturnValueOnce(columnsMock); + + plugin.init({ autoAlign: true }); + plugin.addonOptions.onCommand = onCommandMock; + + const eventData = { ...new Slick.EventData(), preventDefault: jest.fn() }; + gridStub.onHeaderCellRendered.notify({ column: columnsMock[1], node: headerDiv, grid: gridStub }, eventData, gridStub); + const headerButtonElm = headerDiv.querySelector('.slick-header-menu-button') as HTMLDivElement; + headerButtonElm.dispatchEvent(new Event('click', { bubbles: true, cancelable: true, composed: false })); + const headerMenu1Elm = gridContainerDiv.querySelector('.slick-header-menu.slick-menu-level-0') as HTMLDivElement; + const commandList1Elm = headerMenu1Elm.querySelector('.slick-menu-command-list') as HTMLDivElement; + const subCommands1Elm = commandList1Elm.querySelector('[data-command="sub-commands"]') as HTMLDivElement; + Object.defineProperty(headerMenu1Elm, 'clientHeight', { writable: true, configurable: true, value: 77 }); + Object.defineProperty(headerMenu1Elm, 'clientWidth', { writable: true, configurable: true, value: 225 }); + const divEvent1 = new MouseEvent('click', { bubbles: true, cancelable: true, composed: false }) + Object.defineProperty(divEvent1, 'target', { writable: true, configurable: true, value: headerButtonElm }); + + subCommands1Elm!.dispatchEvent(new Event('click')); + plugin.repositionMenu(divEvent1 as any, headerMenu1Elm); + const headerMenu2Elm = document.body.querySelector('.slick-header-menu.slick-menu-level-1') as HTMLDivElement; + Object.defineProperty(headerMenu2Elm, 'clientHeight', { writable: true, configurable: true, value: 320 }); + + const divEvent = new MouseEvent('click', { bubbles: true, cancelable: true, composed: false }) + const subMenuElm = document.createElement('div'); + const menuItem = document.createElement('div'); + menuItem.className = 'slick-menu-item'; + menuItem.style.top = '465px'; + jest.spyOn(menuItem, 'getBoundingClientRect').mockReturnValue({ top: 465, left: 25 } as any); + Object.defineProperty(menuItem, 'target', { writable: true, configurable: true, value: menuItem }); + subMenuElm.className = 'slick-submenu'; + Object.defineProperty(divEvent, 'target', { writable: true, configurable: true, value: subMenuElm }); + menuItem.appendChild(subMenuElm); + + plugin.repositionMenu(divEvent as any, headerMenu2Elm); + const headerMenu2Elm2 = document.body.querySelector('.slick-header-menu.slick-menu-level-1') as HTMLDivElement; + + expect(headerMenu2Elm2.classList.contains('dropup')).toBeTruthy(); + expect(headerMenu2Elm2.classList.contains('dropdown')).toBeFalsy(); + }); + }); + describe('Internal Custom Commands', () => { let eventData: SlickEventData; diff --git a/packages/common/src/extensions/menuBaseClass.ts b/packages/common/src/extensions/menuBaseClass.ts index 0afc1c75b..28501dcec 100644 --- a/packages/common/src/extensions/menuBaseClass.ts +++ b/packages/common/src/extensions/menuBaseClass.ts @@ -12,6 +12,7 @@ import type { HeaderButton, HeaderButtonItem, HeaderMenu, + HeaderMenuCommandItem, MenuCommandItem, MenuOptionItem, SlickEventHandler, @@ -259,7 +260,7 @@ export class MenuBaseClass { } // dispose of all sub-menus from the DOM and unbind all listeners - // this._bindEventService.unbindAll(); this.disposeSubMenus(); this._menuElm?.remove(); this._menuElm = null; diff --git a/packages/common/src/extensions/slickHeaderMenu.ts b/packages/common/src/extensions/slickHeaderMenu.ts index bcf2e7001..a0d7857d9 100644 --- a/packages/common/src/extensions/slickHeaderMenu.ts +++ b/packages/common/src/extensions/slickHeaderMenu.ts @@ -6,17 +6,18 @@ import type { Column, CurrentSorter, DOMEvent, + DOMMouseOrTouchEvent, HeaderMenu, + HeaderMenuCommandItem, HeaderMenuCommandItemCallbackArgs, HeaderMenuItems, HeaderMenuOption, MenuCommandItem, MenuCommandItemCallbackArgs, - MenuOptionItem, MultiColumnSort, OnHeaderCellRenderedEventArgs, } from '../interfaces/index'; -import { createDomElement, emptyElement, getElementOffsetRelativeToParent, getTranslationPrefix } from '../services/index'; +import { calculateAvailableSpace, createDomElement, getElementOffsetRelativeToParent, getHtmlElementOffset, getTranslationPrefix } from '../services/index'; import type { ExtensionUtility } from '../extensions/extensionUtility'; import type { FilterService } from '../services/filter.service'; import type { SharedService } from '../services/shared.service'; @@ -36,7 +37,8 @@ import { type ExtendableItemTypes, type ExtractMenuType, MenuBaseClass, type Men * }]; */ export class SlickHeaderMenu extends MenuBaseClass { - protected _activeHeaderColumnElm?: HTMLDivElement; + protected _activeHeaderColumnElm?: HTMLDivElement | null; + protected _subMenuParentId = ''; protected _defaults = { autoAlign: true, autoAlignOffset: 0, @@ -116,40 +118,86 @@ export class SlickHeaderMenu extends MenuBaseClass { /** Hide the Header Menu */ hideMenu() { + this.disposeSubMenus(); this._menuElm?.remove(); this._menuElm = undefined; this._activeHeaderColumnElm?.classList.remove('slick-header-column-active'); } - showMenu(e: MouseEvent, columnDef: Column, menu: HeaderMenuItems) { - // let the user modify the menu or cancel altogether, - // or provide alternative menu implementation. - const callbackArgs = { - grid: this.grid, - column: columnDef, - menu - } as unknown as HeaderMenuCommandItemCallbackArgs; + repositionSubMenu(item: HeaderMenuCommandItem, columnDef: Column, level: number, e: DOMMouseOrTouchEvent) { + // creating sub-menu, we'll also pass level & the item object since we might have "subMenuTitle" to show + const subMenuElm = this.createCommandMenu(item.items || [], columnDef, level + 1, item); + document.body.appendChild(subMenuElm); + this.repositionMenu(e, subMenuElm); + } - // execute optional callback method defined by the user, if it returns false then we won't go further and not open the grid menu - if (typeof e.stopPropagation === 'function') { - this.pubSubService.publish('onHeaderMenuBeforeMenuShow', callbackArgs); - if (typeof this.addonOptions?.onBeforeMenuShow === 'function' && this.addonOptions?.onBeforeMenuShow(e, callbackArgs) === false) { - return; + repositionMenu(e: DOMMouseOrTouchEvent, menuElm: HTMLDivElement) { + const buttonElm = e.target as HTMLDivElement; // get header button createElement + const isSubMenu = menuElm.classList.contains('slick-submenu'); + const parentElm = isSubMenu + ? e.target.closest('.slick-menu-item') as HTMLDivElement + : buttonElm as HTMLElement; + + const relativePos = getElementOffsetRelativeToParent(this.sharedService.gridContainerElement, buttonElm); + const gridPos = this.grid.getGridPosition(); + const menuWidth = menuElm.offsetWidth; + const parentOffset = getHtmlElementOffset(parentElm); + let menuOffsetLeft = isSubMenu ? parentOffset?.left ?? 0 : relativePos?.left ?? 0; + let menuOffsetTop = isSubMenu + ? parentOffset?.top ?? 0 + : (relativePos?.top ?? 0) + (this.addonOptions?.menuOffsetTop ?? 0) + buttonElm.clientHeight; + + // for sub-menus only, auto-adjust drop position (up/down) + // we first need to see what position the drop will be located (defaults to bottom) + if (isSubMenu) { + // since we reposition menu below slick cell, we need to take it in consideration and do our calculation from that element + const menuHeight = menuElm?.clientHeight || 0; + const { bottom: availableSpaceBottom, top: availableSpaceTop } = calculateAvailableSpace(parentElm); + const dropPosition = ((availableSpaceBottom < menuHeight) && (availableSpaceTop > availableSpaceBottom)) ? 'top' : 'bottom'; + if (dropPosition === 'top') { + menuElm.classList.remove('dropdown'); + menuElm.classList.add('dropup'); + menuOffsetTop -= (menuHeight - parentElm.clientHeight); + } else { + menuElm.classList.remove('dropup'); + menuElm.classList.add('dropdown'); } } - if (!this._menuElm) { - this._menuElm = createDomElement('div', { - ariaExpanded: 'true', - className: 'slick-header-menu', role: 'menu', - style: { minWidth: `${this.addonOptions.minWidth}px` }, - }); - this.grid.getContainerNode()?.appendChild(this._menuElm); + // when auto-align is set, it will calculate whether it has enough space in the viewport to show the drop menu on the right (default) + // if there isn't enough space on the right, it will automatically align the drop menu to the left + // to simulate an align left, we actually need to know the width of the drop menu + if (isSubMenu && parentElm) { + // sub-menu + const subMenuPosCalc = menuOffsetLeft + Number(menuWidth) + parentElm.clientWidth; // calculate coordinate at caller element far right + const browserWidth = document.documentElement.clientWidth; + const dropSide = (subMenuPosCalc >= gridPos.width || subMenuPosCalc >= browserWidth) ? 'left' : 'right'; + if (dropSide === 'left') { + menuElm.classList.remove('dropright'); + menuElm.classList.add('dropleft'); + menuOffsetLeft -= menuWidth; + } else { + menuElm.classList.remove('dropleft'); + menuElm.classList.add('dropright'); + menuOffsetLeft += parentElm.offsetWidth; + } + } else { + // parent menu + menuOffsetLeft = relativePos?.left ?? 0; + if (this.addonOptions.autoAlign && (gridPos?.width && (menuOffsetLeft + (menuElm.clientWidth ?? 0)) >= gridPos.width)) { + menuOffsetLeft = menuOffsetLeft + buttonElm.clientWidth - menuElm.clientWidth + (this.addonOptions?.autoAlignOffset || 0); + } } - // make sure the menu element is an empty div before adding all list of commands - emptyElement(this._menuElm); - this.populateHeaderMenuCommandList(e, menu, callbackArgs); + // ready to reposition the menu + menuElm.style.top = `${menuOffsetTop}px`; + menuElm.style.left = `${menuOffsetLeft}px`; + + // mark the header as active to keep the highlighting. + this._activeHeaderColumnElm = this.grid.getContainerNode().querySelector(`:not(.slick-preheader-panel) >.slick-header-columns`); + if (this._activeHeaderColumnElm) { + this._activeHeaderColumnElm.classList.add('slick-header-column-active'); + } } /** Translate the Header Menu titles, we need to loop through all column definition to re-translate them */ @@ -189,7 +237,10 @@ export class SlickHeaderMenu extends MenuBaseClass { } // show the header menu dropdown list of commands - this._bindEventService.bind(headerButtonDivElm, 'click', ((e: MouseEvent) => this.showMenu(e, column, menu)) as EventListener); + this._bindEventService.bind(headerButtonDivElm, 'click', ((e: DOMMouseOrTouchEvent) => { + this.disposeAllMenus(); // make there's only 1 parent menu opened at a time + this.createParentMenu(e, args.column, menu); + }) as EventListener); } } @@ -204,51 +255,65 @@ export class SlickHeaderMenu extends MenuBaseClass { if (column.header?.menu) { // Removing buttons will also clean up any event handlers and data. // NOTE: If you attach event handlers directly or using a different framework, - // you must also clean them up here to avoid memory leaks. + // you must also clean them up here to avoid events leaking. args.node.querySelectorAll('.slick-header-menu-button').forEach(elm => elm.remove()); } } /** Mouse down handler when clicking anywhere in the DOM body */ - protected handleBodyMouseDown(e: DOMEvent) { - if ((this._menuElm !== e.target && !this._menuElm?.contains(e.target)) || e.target.className === 'close') { - this.hideMenu(); + protected handleBodyMouseDown(e: DOMEvent) { + if (this.menuElement) { + let isMenuClicked = false; + const parentMenuElm = e.target.closest(`.${this.menuCssClass}`); + + // did we click inside the menu or any of its sub-menu(s) + if (this.menuElement.contains(e.target) || parentMenuElm) { + isMenuClicked = true; + } + + if (this._menuElm !== e.target && !isMenuClicked && !e.defaultPrevented || e.target.className === 'close') { + this.hideMenu(); + } } } - protected handleMenuItemCommandClick(event: DOMEvent, _type: MenuType, item: ExtractMenuType, level: number, columnDef?: Column): boolean | void { - if (item === 'divider' || (item as MenuCommandItem).command && (item.disabled || (item as MenuCommandItem | MenuOptionItem).divider)) { - return false; - } + protected handleMenuItemCommandClick(event: DOMMouseOrTouchEvent, _type: MenuType, item: ExtractMenuType, level = 0, columnDef?: Column): boolean | void { + if (item !== 'divider' && !item.disabled && !(item as HeaderMenuCommandItem).divider) { + const command = (item as HeaderMenuCommandItem).command || ''; + + if (command && !(item as HeaderMenuCommandItem).items) { + const callbackArgs = { + grid: this.grid, + command: (item as MenuCommandItem).command, + column: columnDef, + item, + } as MenuCommandItemCallbackArgs; + + // execute Grid Menu callback with command, + // we'll also execute optional user defined onCommand callback when provided + this.executeHeaderMenuInternalCommands(event, callbackArgs); + this.pubSubService.publish('onHeaderMenuCommand', callbackArgs); + if (typeof this.addonOptions?.onCommand === 'function') { + this.addonOptions.onCommand(event, callbackArgs); + } - const callbackArgs = { - grid: this.grid, - command: (item as MenuCommandItem).command, - column: columnDef, - item, - } as MenuCommandItemCallbackArgs; - - // execute Grid Menu callback with command, - // we'll also execute optional user defined onCommand callback when provided - this.executeHeaderMenuInternalCommands(event, callbackArgs); - this.pubSubService.publish('onHeaderMenuCommand', callbackArgs); - if (typeof this.addonOptions?.onCommand === 'function') { - this.addonOptions.onCommand(event, callbackArgs); - } + // execute action callback when defined + if (typeof item.action === 'function') { + (item as MenuCommandItem).action!.call(this, event, callbackArgs); + } - // execute action callback when defined - if (typeof item.action === 'function') { - (item as MenuCommandItem).action!.call(this, event, callbackArgs); - } + // does the user want to leave open the Grid Menu after executing a command? + if (!event.defaultPrevented) { + this.hideMenu(); + } - // does the user want to leave open the Grid Menu after executing a command? - if (!event.defaultPrevented) { - this.hideMenu(); + // Stop propagation so that it doesn't register as a header click event. + event.preventDefault(); + event.stopPropagation(); + } else if ((item as HeaderMenuCommandItem).items) { + this.repositionSubMenu(item as HeaderMenuCommandItem, columnDef as Column, level, event); + } } - - // Stop propagation so that it doesn't register as a header click event. - event.preventDefault(); - event.stopPropagation(); } // -- @@ -452,21 +517,31 @@ export class SlickHeaderMenu extends MenuBaseClass { } } - protected populateHeaderMenuCommandList(e: MouseEvent, menu: HeaderMenuItems, args: HeaderMenuCommandItemCallbackArgs) { - this.populateCommandOrOptionItems( - 'command', - this.addonOptions, - this._menuElm as HTMLDivElement, - menu.items, - args, - this.handleMenuItemCommandClick, - ); + protected createParentMenu(e: DOMMouseOrTouchEvent, columnDef: Column, menu: HeaderMenuItems) { + // let the user modify the menu or cancel altogether, + // or provide alternative menu implementation. + const callbackArgs = { + grid: this.grid, + column: columnDef, + menu + } as unknown as HeaderMenuCommandItemCallbackArgs; + + // execute optional callback method defined by the user, if it returns false then we won't go further and not open the grid menu + if (typeof e.stopPropagation === 'function') { + this.pubSubService.publish('onHeaderMenuBeforeMenuShow', callbackArgs); + if (typeof this.addonOptions?.onBeforeMenuShow === 'function' && this.addonOptions?.onBeforeMenuShow(e, callbackArgs) === false) { + return; + } + } - this.repositionMenu(e); + // create 1st parent menu container & reposition it + this._menuElm = this.createCommandMenu(menu.items, columnDef); + this.grid.getContainerNode()?.appendChild(this._menuElm); + this.repositionMenu(e, this._menuElm); // execute optional callback method defined by the user - this.pubSubService.publish('onHeaderMenuAfterMenuShow', args); - if (typeof this.addonOptions?.onAfterMenuShow === 'function' && this.addonOptions?.onAfterMenuShow(e, args) === false) { + this.pubSubService.publish('onHeaderMenuAfterMenuShow', callbackArgs); + if (typeof this.addonOptions?.onAfterMenuShow === 'function' && this.addonOptions?.onAfterMenuShow(e, callbackArgs) === false) { return; } @@ -475,29 +550,72 @@ export class SlickHeaderMenu extends MenuBaseClass { e.stopPropagation(); } - protected repositionMenu(e: MouseEvent) { - const buttonElm = e.target as HTMLDivElement; // get header button createElement - if (this._menuElm && buttonElm.classList.contains('slick-header-menu-button')) { - const relativePos = getElementOffsetRelativeToParent(this.sharedService.gridContainerElement, buttonElm); - let leftPos = relativePos?.left ?? 0; - - // when auto-align is set, it will calculate whether it has enough space in the viewport to show the drop menu on the right (default) - // if there isn't enough space on the right, it will automatically align the drop menu to the left - // to simulate an align left, we actually need to know the width of the drop menu - if (this.addonOptions.autoAlign) { - const gridPos = this.grid.getGridPosition(); - if (gridPos?.width && (leftPos + (this._menuElm.clientWidth ?? 0)) >= gridPos.width) { - leftPos = leftPos + buttonElm.clientWidth - this._menuElm.clientWidth + (this.addonOptions?.autoAlignOffset ?? 0); - } + /** Create the menu or sub-menu(s) but without the column picker which is a separate single process */ + protected createCommandMenu(commandItems: Array, columnDef: Column, level = 0, item?: HeaderMenuCommandItem | 'divider') { + // to avoid having multiple sub-menu trees opened + // we need to somehow keep trace of which parent menu the tree belongs to + // and we should keep ref of only the first sub-menu parent, we can use the command name (remove any whitespaces though) + const subMenuCommand = (item as HeaderMenuCommandItem)?.command; + let subMenuId = (level === 1 && subMenuCommand) ? subMenuCommand.replace(/\s/g, '') : ''; + if (subMenuId) { + this._subMenuParentId = subMenuId; + } + if (level > 1) { + subMenuId = this._subMenuParentId; + } + + const menuClasses = `${this.menuCssClass} slick-menu-level-${level} ${this.gridUid}`; + const bodyMenuElm = document.body.querySelector(`.${this.menuCssClass}.slick-menu-level-${level}${this.gridUidSelector}`); + + // return menu/sub-menu if it's already opened unless we are on different sub-menu tree if so close them all + if (bodyMenuElm) { + if (bodyMenuElm.dataset.subMenuParent === subMenuId) { + return bodyMenuElm; + } + this.disposeSubMenus(); + } + + const menuElm = createDomElement('div', { + ariaExpanded: 'true', + ariaLabel: level > 1 ? 'SubMenu' : 'Header Menu', + role: 'menu', + className: menuClasses, + style: { minWidth: `${this.addonOptions.minWidth}px` }, + }); + if (level > 0) { + menuElm.classList.add('slick-submenu'); + if (subMenuId) { + menuElm.dataset.subMenuParent = subMenuId; } + } + + const commandMenuElm = createDomElement('div', { className: `${this._menuCssPrefix}-command-list`, role: 'menu' }, menuElm); - this._menuElm.style.top = `${(relativePos?.top ?? 0) + (this.addonOptions?.menuOffsetTop ?? 0) + buttonElm.clientHeight}px`; - this._menuElm.style.left = `${leftPos}px`; + const callbackArgs = { + grid: this.grid, + column: columnDef, + level, + menu: { items: commandItems } + } as unknown as HeaderMenuCommandItemCallbackArgs; - // mark the header as active to keep the highlighting. - this._activeHeaderColumnElm = this._menuElm.closest('.slick-header-column') as HTMLDivElement; - this._activeHeaderColumnElm?.classList.add('slick-header-column-active'); + // when creating sub-menu also add its sub-menu title when exists + if (item && level > 0) { + this.addSubMenuTitleWhenExists(item as HeaderMenuCommandItem, commandMenuElm); // add sub-menu title when exists } + + this.populateCommandOrOptionItems( + 'command', + this.addonOptions, + commandMenuElm, + commandItems, + callbackArgs, + this.handleMenuItemCommandClick, + ); + + // increment level for possible next sub-menus if exists + level++; + + return menuElm; } /** diff --git a/packages/common/src/interfaces/headerMenuItems.interface.ts b/packages/common/src/interfaces/headerMenuItems.interface.ts index b7e298cdc..fd32d6cc2 100644 --- a/packages/common/src/interfaces/headerMenuItems.interface.ts +++ b/packages/common/src/interfaces/headerMenuItems.interface.ts @@ -1,5 +1,11 @@ import type { MenuCommandItem } from './menuCommandItem.interface'; export interface HeaderMenuItems { - items: Array; + items: Array; +} + + +export interface HeaderMenuCommandItem extends Omit { + /** Array of Command Items (title, command, disabled, ...) */ + items?: Array; } \ No newline at end of file diff --git a/packages/common/src/interfaces/headerMenuOption.interface.ts b/packages/common/src/interfaces/headerMenuOption.interface.ts index 3f9e4fbc1..d0fcad910 100644 --- a/packages/common/src/interfaces/headerMenuOption.interface.ts +++ b/packages/common/src/interfaces/headerMenuOption.interface.ts @@ -67,6 +67,9 @@ export interface HeaderMenuOption { /** Minimum width that the drop menu will have */ minWidth?: number; + /** CSS class that can be added on the right side of a sub-item parent (typically a chevron-right icon) */ + subItemChevronClass?: string; + /** Menu item text. */ title?: string; diff --git a/test/cypress/e2e/example01.cy.ts b/test/cypress/e2e/example01.cy.ts index 4435e0bf5..f1c367b41 100644 --- a/test/cypress/e2e/example01.cy.ts +++ b/test/cypress/e2e/example01.cy.ts @@ -48,7 +48,7 @@ describe('Example 01 - Basic Grids', { retries: 1 }, () => { .invoke('show') .click(); - cy.get('.slick-header-menu') + cy.get('.slick-header-menu .slick-menu-command-list') .should('be.visible') .children('.slick-menu-item:nth-of-type(4)') .children('.slick-menu-content') @@ -83,7 +83,7 @@ describe('Example 01 - Basic Grids', { retries: 1 }, () => { .invoke('show') .click(); - cy.get('.slick-header-menu') + cy.get('.slick-header-menu .slick-menu-command-list') .should('be.visible') .children('.slick-menu-item:nth-of-type(3)') .children('.slick-menu-content') @@ -106,7 +106,7 @@ describe('Example 01 - Basic Grids', { retries: 1 }, () => { .click(); cy.get('.grid2') - .find('.slick-header-menu') + .find('.slick-header-menu .slick-menu-command-list') .should('be.visible') .children('.slick-menu-item:nth-of-type(4)') .click(); diff --git a/test/cypress/e2e/example04.cy.ts b/test/cypress/e2e/example04.cy.ts index 6d7814ab4..cd3ed9ef2 100644 --- a/test/cypress/e2e/example04.cy.ts +++ b/test/cypress/e2e/example04.cy.ts @@ -1,4 +1,4 @@ -describe('Example 04 - Frozen Grid', { retries: 0 }, () => { +describe('Example 04 - Frozen Grid', { retries: 1 }, () => { // NOTE: everywhere there's a * 2 is because we have a top+bottom (frozen rows) containers even after Unfreeze Columns/Rows const fullTitles = ['', 'Title', '% Complete', 'Start', 'Finish', 'Completed', 'Cost | Duration', 'City of Origin', 'Action']; @@ -103,7 +103,7 @@ describe('Example 04 - Frozen Grid', { retries: 0 }, () => { .invoke('show') .click(); - cy.get('.slick-header-menu') + cy.get('.slick-header-menu .slick-menu-command-list') .should('be.visible') .children('.slick-menu-item:nth-of-type(9)') .children('.slick-menu-content') diff --git a/test/cypress/e2e/example07.cy.ts b/test/cypress/e2e/example07.cy.ts index c76b1fdfe..aec110cbe 100644 --- a/test/cypress/e2e/example07.cy.ts +++ b/test/cypress/e2e/example07.cy.ts @@ -245,7 +245,7 @@ describe('Example 07 - Row Move & Checkbox Selector Selector Plugins', { retries .invoke('show') .click(); - cy.get('.slick-header-menu') + cy.get('.slick-header-menu .slick-menu-command-list') .should('be.visible') .children('.slick-menu-item:nth-of-type(6)') .children('.slick-menu-content') @@ -420,7 +420,7 @@ describe('Example 07 - Row Move & Checkbox Selector Selector Plugins', { retries .invoke('show') .click(); - cy.get('.slick-header-menu') + cy.get('.slick-header-menu .slick-menu-command-list') .children('.slick-menu-item') .each(($child, index) => { const commandTitle = $child.text(); @@ -470,7 +470,7 @@ describe('Example 07 - Row Move & Checkbox Selector Selector Plugins', { retries .invoke('show') .click(); - cy.get('.slick-header-menu') + cy.get('.slick-header-menu .slick-menu-command-list') .children('.slick-menu-item') .each(($child, index) => { const commandTitle = $child.text(); @@ -516,7 +516,7 @@ describe('Example 07 - Row Move & Checkbox Selector Selector Plugins', { retries .invoke('show') .click(); - cy.get('.slick-header-menu') + cy.get('.slick-header-menu .slick-menu-command-list') .children('.slick-menu-item') .each(($child, index) => { const commandTitle = $child.text(); @@ -541,7 +541,7 @@ describe('Example 07 - Row Move & Checkbox Selector Selector Plugins', { retries .invoke('show') .click(); - cy.get('.slick-header-menu') + cy.get('.slick-header-menu .slick-menu-command-list') .children('.slick-menu-item') .each(($child, index) => { const commandTitle = $child.text(); @@ -833,7 +833,7 @@ describe('Example 07 - Row Move & Checkbox Selector Selector Plugins', { retries .invoke('show') .click(); - cy.get('.slick-header-menu') + cy.get('.slick-header-menu .slick-menu-command-list') .children('.slick-menu-item') .each(($child, index) => { const commandTitle = $child.text(); @@ -894,7 +894,7 @@ describe('Example 07 - Row Move & Checkbox Selector Selector Plugins', { retries cy.get(`[style="top:${GRID_ROW_HEIGHT * 1}px"] > .slick-cell:nth(7)`).find('.checkmark-icon').should('have.length', 0); }); - it('should open the Cell Menu on 2nr row and delete it', () => { + it('should open the Cell Menu on 2nd row and delete it', () => { const confirmStub = cy.stub(); cy.on('window:confirm', confirmStub); diff --git a/test/cypress/e2e/example09.cy.ts b/test/cypress/e2e/example09.cy.ts index 5ba906385..396e7169b 100644 --- a/test/cypress/e2e/example09.cy.ts +++ b/test/cypress/e2e/example09.cy.ts @@ -729,7 +729,7 @@ describe('Example 09 - OData Grid', { retries: 1 }, () => { .invoke('show') .click({ force: true }); - cy.get('.slick-header-menu') + cy.get('.slick-header-menu .slick-menu-command-list') .should('be.visible') .children('.slick-menu-item:nth-of-type(6)') .children('.slick-menu-content') diff --git a/test/cypress/e2e/example10.cy.ts b/test/cypress/e2e/example10.cy.ts index de5a4d3f7..5fb819426 100644 --- a/test/cypress/e2e/example10.cy.ts +++ b/test/cypress/e2e/example10.cy.ts @@ -18,7 +18,7 @@ describe('Example 10 - GraphQL Grid', { retries: 1 }, () => { .should('have.css', 'width', '900px'); cy.get('.grid10 > .slickgrid-container') - .should($el => expect(parseInt(`${$el.height()}`)).to.eq(275)); + .should($el => expect(parseInt(`${$el.height()}`, 10)).to.eq(275)); }); it('should have English Text inside some of the Filters', () => { @@ -173,7 +173,7 @@ describe('Example 10 - GraphQL Grid', { retries: 1 }, () => { .invoke('show') .click(); - cy.get('.slick-header-menu') + cy.get('.slick-header-menu .slick-menu-command-list') .should('be.visible') .children('.slick-menu-item:nth-of-type(6)') .children('.slick-menu-content') @@ -203,7 +203,7 @@ describe('Example 10 - GraphQL Grid', { retries: 1 }, () => { .invoke('show') .click(); - cy.get('.slick-header-menu') + cy.get('.slick-header-menu .slick-menu-command-list') .should('be.visible') .children('.slick-menu-item:nth-of-type(6)') .children('.slick-menu-content') @@ -233,7 +233,7 @@ describe('Example 10 - GraphQL Grid', { retries: 1 }, () => { .invoke('show') .click(); - cy.get('.slick-header-menu') + cy.get('.slick-header-menu .slick-menu-command-list') .should('be.visible') .children('.slick-menu-item:nth-of-type(6)') .children('.slick-menu-content') @@ -434,28 +434,28 @@ describe('Example 10 - GraphQL Grid', { retries: 1 }, () => { .invoke('show') .click(); - cy.get('.slick-header-menu') + cy.get('.slick-header-menu .slick-menu-command-list') .should('be.visible') .children('.slick-menu-item:nth-of-type(3)') .children('.slick-menu-content') .should('contain', 'Sort Ascending'); - cy.get('.slick-header-menu') + cy.get('.slick-header-menu .slick-menu-command-list') .children('.slick-menu-item:nth-of-type(4)') .children('.slick-menu-content') .should('contain', 'Sort Descending'); - cy.get('.slick-header-menu') + cy.get('.slick-header-menu .slick-menu-command-list') .children('.slick-menu-item:nth-of-type(6)') .children('.slick-menu-content') .should('contain', 'Remove Filter'); - cy.get('.slick-header-menu') + cy.get('.slick-header-menu .slick-menu-command-list') .children('.slick-menu-item:nth-of-type(7)') .children('.slick-menu-content') .should('contain', 'Remove Sort'); - cy.get('.slick-header-menu') + cy.get('.slick-header-menu .slick-menu-command-list') .children('.slick-menu-item:nth-of-type(8)') .children('.slick-menu-content') .should('contain', 'Hide Column'); @@ -542,28 +542,28 @@ describe('Example 10 - GraphQL Grid', { retries: 1 }, () => { .invoke('show') .click(); - cy.get('.slick-header-menu') + cy.get('.slick-header-menu .slick-menu-command-list') .should('be.visible') .children('.slick-menu-item:nth-of-type(3)') .children('.slick-menu-content') .should('contain', 'Trier par ordre croissant'); - cy.get('.slick-header-menu') + cy.get('.slick-header-menu .slick-menu-command-list') .children('.slick-menu-item:nth-of-type(4)') .children('.slick-menu-content') .should('contain', 'Trier par ordre décroissant'); - cy.get('.slick-header-menu') + cy.get('.slick-header-menu .slick-menu-command-list') .children('.slick-menu-item:nth-of-type(6)') .children('.slick-menu-content') .should('contain', 'Supprimer le filtre'); - cy.get('.slick-header-menu') + cy.get('.slick-header-menu .slick-menu-command-list') .children('.slick-menu-item:nth-of-type(7)') .children('.slick-menu-content') .should('contain', 'Supprimer le tri'); - cy.get('.slick-header-menu') + cy.get('.slick-header-menu .slick-menu-command-list') .children('.slick-menu-item:nth-of-type(8)') .children('.slick-menu-content') .should('contain', 'Cacher la colonne'); diff --git a/test/cypress/e2e/example11.cy.ts b/test/cypress/e2e/example11.cy.ts index 9ccaa14c2..2a90484e0 100644 --- a/test/cypress/e2e/example11.cy.ts +++ b/test/cypress/e2e/example11.cy.ts @@ -624,7 +624,7 @@ describe('Example 11 - Batch Editing', { retries: 1 }, () => { .invoke('show') .click(); - cy.get('.slick-header-menu') + cy.get('.slick-header-menu .slick-menu-command-list') .should('be.visible') .children('.slick-menu-item:nth-of-type(1)') .children('.slick-menu-content') diff --git a/test/cypress/e2e/example13.cy.ts b/test/cypress/e2e/example13.cy.ts index dd698bfe9..64c1b9e5f 100644 --- a/test/cypress/e2e/example13.cy.ts +++ b/test/cypress/e2e/example13.cy.ts @@ -425,7 +425,7 @@ describe('Example 13 - Header Button Plugin', { retries: 1 }, () => { .invoke('show') .click(); - cy.get('.grid13-2 .slick-header-menu') + cy.get('.grid13-2 .slick-header-menu .slick-menu-command-list') .should('be.visible') .children('.slick-menu-item:nth-of-type(6)') .children('.slick-menu-content') @@ -455,7 +455,7 @@ describe('Example 13 - Header Button Plugin', { retries: 1 }, () => { .children('.slick-header-menu-button') .click(); - cy.get('.grid13-2 .slick-header-menu') + cy.get('.grid13-2 .slick-header-menu .slick-menu-command-list') .should('be.visible') .children('.slick-menu-item:nth-of-type(3)') .children('.slick-menu-content') diff --git a/test/cypress/e2e/example14.cy.ts b/test/cypress/e2e/example14.cy.ts index 625759b57..285532d57 100644 --- a/test/cypress/e2e/example14.cy.ts +++ b/test/cypress/e2e/example14.cy.ts @@ -78,7 +78,7 @@ describe('Example 14 - Columns Resize by Content', { retries: 1 }, () => { .invoke('show') .click(); - cy.get('.slick-header-menu') + cy.get('.slick-header-menu .slick-menu-command-list') .should('be.visible') .children('.slick-menu-item:nth-of-type(1)') .children('.slick-menu-content') @@ -184,4 +184,97 @@ describe('Example 14 - Columns Resize by Content', { retries: 1 }, () => { cy.get('.slick-cell-checkboxsel input:checked') .should('have.length', 0); }); + + describe('Custom Header Menu tests', () => { + it('should open Hello sub-menu with 2 options expect it to be aligned to right then trigger alert when command is clicked', () => { + const subCommands = ['Hello World', 'Hello SlickGrid', `Let's play`]; + const stub = cy.stub(); + cy.on('window:alert', stub); + + cy.get('.grid14') + .find('.slick-header-column:nth-of-type(2).slick-header-sortable') + .trigger('mouseover') + .children('.slick-header-menu-button') + .invoke('show') + .click(); + + cy.get('.slick-menu-item.slick-menu-item') + .contains('Hello') + .should('exist') + .click(); + + cy.get('.slick-header-menu.slick-menu-level-1.dropright') + .should('exist') + .find('.slick-menu-item') + .each(($command, index) => expect($command.text()).to.contain(subCommands[index])); + + cy.get('.slick-header-menu.slick-menu-level-1') + .find('.slick-menu-item') + .contains('Hello SlickGrid') + .click() + .then(() => expect(stub.getCall(0)).to.be.calledWith('Hello SlickGrid')); + }); + + it(`should open Hello sub-menu and expect 3 options, then open Feedback->ContactUs sub-menus and expect previous Hello menu to no longer exists`, () => { + const subCommands1 = ['Hello World', 'Hello SlickGrid', `Let's play`]; + const subCommands2 = ['Request update from supplier', '', 'Contact Us']; + const subCommands2_1 = ['Email us', 'Chat with us', 'Book an appointment']; + + const stub = cy.stub(); + cy.on('window:alert', stub); + + cy.get('.grid14') + .find('.slick-header-column:nth-of-type(8).slick-header-sortable') + .trigger('mouseover') + .children('.slick-header-menu-button') + .invoke('show') + .click(); + + cy.get('.slick-header-menu.slick-menu-level-0') + .find('.slick-menu-item.slick-menu-item') + .contains('Hello') + .should('exist') + .click(); + + cy.get('.slick-submenu').should('have.length', 1); + cy.get('.slick-header-menu.slick-menu-level-1.dropleft') // left align + .should('exist') + .find('.slick-menu-item') + .each(($command, index) => expect($command.text()).to.contain(subCommands1[index])); + + // click different sub-menu + cy.get('.slick-header-menu.slick-menu-level-0') + .find('.slick-menu-item.slick-menu-item') + .contains('Feedback') + .should('exist') + .click(); + + cy.get('.slick-submenu').should('have.length', 1); + cy.get('.slick-header-menu.slick-menu-level-1') + .should('exist') + .find('.slick-menu-item') + .each(($command, index) => expect($command.text()).to.contain(subCommands2[index])); + + // click on Feedback->ContactUs + cy.get('.slick-header-menu.slick-menu-level-1.dropleft') // left align + .find('.slick-menu-item.slick-menu-item') + .contains('Contact Us') + .should('exist') + .click(); + + cy.get('.slick-submenu').should('have.length', 2); + cy.get('.slick-header-menu.slick-menu-level-2.dropright') // right align + .should('exist') + .find('.slick-menu-item') + .each(($command, index) => expect($command.text()).to.contain(subCommands2_1[index])); + + cy.get('.slick-header-menu.slick-menu-level-2') + .find('.slick-menu-item') + .contains('Chat with us') + .click() + .then(() => expect(stub.getCall(0)).to.be.calledWith('Command: contact-chat')); + + cy.get('.slick-submenu').should('have.length', 0); + }); + }); }); diff --git a/test/cypress/e2e/example15.cy.ts b/test/cypress/e2e/example15.cy.ts index b4afd196d..cd31f3bad 100644 --- a/test/cypress/e2e/example15.cy.ts +++ b/test/cypress/e2e/example15.cy.ts @@ -809,7 +809,7 @@ describe('Example 15 - OData Grid using RxJS', { retries: 1 }, () => { .invoke('show') .click(); - cy.get('.slick-header-menu') + cy.get('.slick-header-menu .slick-menu-command-list') .should('be.visible') .children('.slick-menu-item:nth-of-type(6)') .children('.slick-menu-content')