diff --git a/examples/webpack-demo-vanilla-bundle/src/examples/example03.ts b/examples/webpack-demo-vanilla-bundle/src/examples/example03.ts index 340c7f18a..7de21dd94 100644 --- a/examples/webpack-demo-vanilla-bundle/src/examples/example03.ts +++ b/examples/webpack-demo-vanilla-bundle/src/examples/example03.ts @@ -193,7 +193,7 @@ export class Example3 { } }, { - id: 'effortDriven', name: 'Effort Driven', field: 'effortDriven', + id: 'effortDriven', name: 'Effort-Driven', field: 'effortDriven', width: 80, minWidth: 20, maxWidth: 100, cssClass: 'cell-effort-driven', sortable: true, @@ -268,7 +268,7 @@ export class Example3 { }, { command: 'something', title: 'Disabled Command', disabled: true, positionOrder: 67, } ], - optionTitle: 'Change Complete Flag', + optionTitle: 'Change Effort-Driven Flag', optionItems: [ { option: true, title: 'True', iconCssClass: 'mdi mdi-check-box-outline' }, { option: false, title: 'False', iconCssClass: 'mdi mdi-checkbox-blank-outline' }, @@ -327,10 +327,10 @@ export class Example3 { // are available under the grid options as shown below onCommand: (e, args) => this.executeCommand(e, args), onOptionSelected: (_e, args) => { - // change "Completed" property with new option selected from the Cell Menu + // change "Effort-Driven" property with new option selected from the Cell Menu const dataContext = args && args.dataContext; - if (dataContext && dataContext.hasOwnProperty('completed')) { - dataContext.completed = args.item.option; + if (dataContext && dataContext.hasOwnProperty('effortDriven')) { + dataContext.effortDriven = args.item.option; this.sgb.gridService.updateItem(dataContext); } }, diff --git a/examples/webpack-demo-vanilla-bundle/src/examples/example04.ts b/examples/webpack-demo-vanilla-bundle/src/examples/example04.ts index 9ebb695f5..fdcd0ac70 100644 --- a/examples/webpack-demo-vanilla-bundle/src/examples/example04.ts +++ b/examples/webpack-demo-vanilla-bundle/src/examples/example04.ts @@ -377,7 +377,7 @@ export class Example4 { onCommand: (e, args) => this.executeCommand(e, args), onOptionSelected: (_e, args) => { // change "Completed" property with new option selected from the Cell Menu - const dataContext = args && args.dataContext; + const dataContext = args?.dataContext; if (dataContext && dataContext.hasOwnProperty('completed')) { dataContext.completed = args.item.option; this.sgb?.gridService.updateItem(dataContext); @@ -486,7 +486,7 @@ export class Example4 { alert('Please help!'); break; case 'delete-row': - if (confirm(`Do you really want to delete row (${args.row + 1}) with "${dataContext.title}"`)) { + if (confirm(`Do you really want to delete row (${args.row + 1}) with "${dataContext.title}"?`)) { this.sgb?.gridService.deleteItemById(dataContext.id); } break; diff --git a/examples/webpack-demo-vanilla-bundle/src/examples/example07.ts b/examples/webpack-demo-vanilla-bundle/src/examples/example07.ts index 811229b08..25dc9f282 100644 --- a/examples/webpack-demo-vanilla-bundle/src/examples/example07.ts +++ b/examples/webpack-demo-vanilla-bundle/src/examples/example07.ts @@ -64,6 +64,35 @@ export class Example7 { { id: 'title', nameKey: 'TITLE', field: 'title', filterable: true, editor: { model: Editors.longText, required: true, alwaysSaveOnEnterKey: true }, }, + { + id: 'action', name: 'Action', field: 'action', minWidth: 60, maxWidth: 60, + excludeFromExport: true, excludeFromHeaderMenu: true, + formatter: () => `
`, + cellMenu: { + commandTitleKey: 'COMMANDS', + commandItems: [ + { + command: 'command1', titleKey: 'DELETE_ROW', + iconCssClass: 'mdi mdi-close color-danger', cssClass: 'has-text-danger', textCssClass: 'bold', + action: (_e, args) => { + if (confirm(`Do you really want to delete row (${args.row + 1}) with "${args.dataContext.title}"?`)) { + this.sgb?.gridService.deleteItemById(args.dataContext.id); + } + } + }, + 'divider', + { + command: 'help', titleKey: 'HELP', iconCssClass: 'mdi mdi-help-circle', + action: () => alert('Please help!') + }, + ], + optionTitleKey: 'CHANGE_COMPLETED_FLAG', + optionItems: [ + { option: true, titleKey: 'TRUE', iconCssClass: 'mdi mdi-check-box-outline', action: (e, args) => this.changeCompletedOption(args.dataContext, args.item.option) }, + { option: false, titleKey: 'FALSE', iconCssClass: 'mdi mdi-checkbox-blank-outline', action: (e, args) => this.changeCompletedOption(args.dataContext, args.item.option) }, + ] + } + }, { id: 'duration', nameKey: 'DURATION', field: 'duration', sortable: true, filterable: true, type: 'number', editor: { model: Editors.text, alwaysSaveOnEnterKey: true, }, @@ -86,7 +115,7 @@ export class Example7 { editor: { model: Editors.date }, type: FieldType.dateIso, saveOutputType: FieldType.dateUtc, }, { - id: 'effort-driven', nameKey: 'COMPLETED', field: 'effortDriven', formatter: Formatters.checkmarkMaterial, + id: 'completed', nameKey: 'COMPLETED', field: 'completed', formatter: Formatters.checkmarkMaterial, filterable: true, sortable: true, filter: { collection: [{ value: '', label: '' }, { value: true, label: 'True' }, { value: false, label: 'False' }], @@ -196,6 +225,7 @@ export class Example7 { exportWithFormatter: true, sanitizeDataExport: true }, + enableCellMenu: true, enableFiltering: true, enableTranslate: true, translater: this.translateService, // pass the TranslateService instance to the grid @@ -276,6 +306,14 @@ export class Example7 { this.sgb.filterService.clearFilters(); } + changeCompletedOption(dataContext: any, newValue: boolean) { + console.log('change', dataContext, newValue) + if (dataContext && dataContext.hasOwnProperty('completed')) { + dataContext.completed = newValue; + this.sgb?.gridService.updateItem(dataContext); + } + } + /** Delete last inserted row */ deleteItem() { const requisiteColumnDef = this.columnDefinitions.find((column: Column) => column.id === 'prerequisites'); @@ -303,7 +341,7 @@ export class Example7 { percentComplete: Math.round(Math.random() * 100), start: new Date(2009, 0, 1), finish: new Date(2009, 0, 5), - effortDriven: (i % 5 === 0), + completed: (i % 5 === 0), prerequisites: (i % 2 === 0) && i !== 0 && i < 50 ? [i, i - 1] : [], }); } diff --git a/packages/common/src/controls/__tests__/gridMenuControl.spec.ts b/packages/common/src/controls/__tests__/gridMenuControl.spec.ts index e47243fd8..63ab31a15 100644 --- a/packages/common/src/controls/__tests__/gridMenuControl.spec.ts +++ b/packages/common/src/controls/__tests__/gridMenuControl.spec.ts @@ -824,7 +824,7 @@ describe('GridMenuControl', () => { it('should add a custom Grid Menu item with "iconImage" and expect an icon to be included on the item DOM element', () => { gridOptionsMock.gridMenu.customItems = [{ command: 'help', title: 'Help', iconImage: '/images/some-image.png' }]; gridOptionsMock.gridMenu.iconCssClass = undefined; - gridOptionsMock.gridMenu.iconImage = '/images/some-gridmenu-image.png'; + gridOptionsMock.gridMenu.iconImage = '/images/some-image.png'; control.columns = columnsMock; control.init(); @@ -835,7 +835,7 @@ describe('GridMenuControl', () => { const helpIconElm = helpCommandElm.querySelector('.slick-gridmenu-icon'); const helpTextElm = helpCommandElm.querySelector('.slick-gridmenu-content'); - expect(buttonImageElm.src).toBe('/images/some-gridmenu-image.png'); + expect(buttonImageElm.src).toBe('/images/some-image.png'); expect(helpTextElm.textContent).toBe('Help'); expect(helpIconElm.style.backgroundImage).toBe('url(/images/some-image.png)') expect(consoleWarnSpy).toHaveBeenCalledWith('[Slickgrid-Universal] The "iconImage" property of a Grid Menu item is now deprecated and will be removed in future version, consider using "iconCssClass" instead.'); diff --git a/packages/common/src/controls/columnPicker.control.ts b/packages/common/src/controls/columnPicker.control.ts index c07ffb635..ae573f55e 100644 --- a/packages/common/src/controls/columnPicker.control.ts +++ b/packages/common/src/controls/columnPicker.control.ts @@ -60,6 +60,10 @@ export class ColumnPickerControl { this.init(); } + get addonOptions(): ColumnPickerOption { + return this.gridOptions.columnPicker || {}; + } + get eventHandler(): SlickEventHandler { return this._eventHandler; } @@ -71,10 +75,6 @@ export class ColumnPickerControl { this._columns = newColumns; } - get controlOptions(): ColumnPickerOption { - return this.gridOptions.columnPicker || {}; - } - get gridOptions(): GridOption { return this.sharedService.gridOptions ?? {}; } @@ -93,9 +93,9 @@ export class ColumnPickerControl { this.gridOptions.columnPicker = { ...this._defaults, ...this.gridOptions.columnPicker }; // localization support for the picker - this.controlOptions.columnTitle = this.extensionUtility.getPickerTitleOutputString('columnTitle', 'columnPicker'); - this.controlOptions.forceFitTitle = this.extensionUtility.getPickerTitleOutputString('forceFitTitle', 'columnPicker'); - this.controlOptions.syncResizeTitle = this.extensionUtility.getPickerTitleOutputString('syncResizeTitle', 'columnPicker'); + this.addonOptions.columnTitle = this.extensionUtility.getPickerTitleOutputString('columnTitle', 'columnPicker'); + this.addonOptions.forceFitTitle = this.extensionUtility.getPickerTitleOutputString('forceFitTitle', 'columnPicker'); + this.addonOptions.syncResizeTitle = this.extensionUtility.getPickerTitleOutputString('syncResizeTitle', 'columnPicker'); const onHeaderContextMenuHandler = this.grid.onHeaderContextMenu; const onColumnsReorderedHandler = this.grid.onColumnsReordered; @@ -121,10 +121,10 @@ export class ColumnPickerControl { this._menuElm.appendChild(closePickerButtonElm); // user could pass a title on top of the columns list - if (this.controlOptions?.columnTitle) { + if (this.addonOptions?.columnTitle) { this._columnTitleElm = document.createElement('div'); this._columnTitleElm.className = 'title'; - this._columnTitleElm.textContent = this.controlOptions?.columnTitle ?? this._defaults.columnTitle; + this._columnTitleElm.textContent = this.addonOptions?.columnTitle ?? this._defaults.columnTitle; this._menuElm.appendChild(this._columnTitleElm); } @@ -169,21 +169,21 @@ export class ColumnPickerControl { /** Translate the Column Picker headers and also the last 2 checkboxes */ translateColumnPicker() { // update the properties by pointers, that is the only way to get Column Picker Control to see the new values - if (this.controlOptions) { - this.controlOptions.columnTitle = ''; - this.controlOptions.forceFitTitle = ''; - this.controlOptions.syncResizeTitle = ''; - this.controlOptions.columnTitle = this.extensionUtility.getPickerTitleOutputString('columnTitle', 'columnPicker'); - this.controlOptions.forceFitTitle = this.extensionUtility.getPickerTitleOutputString('forceFitTitle', 'columnPicker'); - this.controlOptions.syncResizeTitle = this.extensionUtility.getPickerTitleOutputString('syncResizeTitle', 'columnPicker'); + if (this.addonOptions) { + this.addonOptions.columnTitle = ''; + this.addonOptions.forceFitTitle = ''; + this.addonOptions.syncResizeTitle = ''; + this.addonOptions.columnTitle = this.extensionUtility.getPickerTitleOutputString('columnTitle', 'columnPicker'); + this.addonOptions.forceFitTitle = this.extensionUtility.getPickerTitleOutputString('forceFitTitle', 'columnPicker'); + this.addonOptions.syncResizeTitle = this.extensionUtility.getPickerTitleOutputString('syncResizeTitle', 'columnPicker'); } // translate all columns (including hidden columns) this.extensionUtility.translateItems(this._columns, 'nameKey', 'name'); // update the Titles of each sections (command, customTitle, ...) - if (this.controlOptions) { - this.updateAllTitles(this.controlOptions); + if (this.addonOptions) { + this.updateAllTitles(this.addonOptions); } } @@ -205,7 +205,7 @@ export class ColumnPickerControl { updateColumnPickerOrder.call(this); this._columnCheckboxes = []; - populateColumnPicker.call(this, this.controlOptions); + populateColumnPicker.call(this, this.addonOptions); this.repositionMenu(e); } diff --git a/packages/common/src/controls/gridMenu.control.ts b/packages/common/src/controls/gridMenu.control.ts index 5e3b89807..1fbbe0945 100644 --- a/packages/common/src/controls/gridMenu.control.ts +++ b/packages/common/src/controls/gridMenu.control.ts @@ -14,7 +14,7 @@ import { } from '../interfaces/index'; import { DelimiterType, FileType } from '../enums'; import { ExtensionUtility } from '../extensions/extensionUtility'; -import { emptyElement, getHtmlElementOffset, getTranslationPrefix, } from '../services'; +import { emptyElement, getHtmlElementOffset, getTranslationPrefix, hasData, } from '../services'; import { BindingEventService } from '../services/bindingEvent.service'; import { ExcelExportService } from '../services/excelExport.service'; import { FilterService } from '../services/filter.service'; @@ -88,8 +88,11 @@ export class GridMenuControl { this.init(); } - get eventHandler(): SlickEventHandler { - return this._eventHandler; + get addonOptions(): GridMenu { + return this.gridOptions.gridMenu || {}; + } + set addonOptions(newOptions: GridMenu) { + this.sharedService.gridOptions.gridMenu = newOptions; } get columns(): Column[] { @@ -99,11 +102,8 @@ export class GridMenuControl { this._columns = newColumns; } - get controlOptions(): GridMenu { - return this.gridOptions.gridMenu || {}; - } - set controlOptions(controlOptions: GridMenu) { - this.sharedService.gridOptions.gridMenu = controlOptions; + get eventHandler(): SlickEventHandler { + return this._eventHandler; } get gridOptions(): GridOption { @@ -146,15 +146,15 @@ export class GridMenuControl { this._gridUid = this.grid.getUID() ?? ''; // keep original user grid menu, useful when switching locale to translate - this._userOriginalGridMenu = { ...this.controlOptions }; - this.controlOptions = { ...this._defaults, ...this.getDefaultGridMenuOptions(), ...this.controlOptions }; + this._userOriginalGridMenu = { ...this.addonOptions }; + this.addonOptions = { ...this._defaults, ...this.getDefaultGridMenuOptions(), ...this.addonOptions }; // merge original user grid menu items with internal items // then sort all Grid Menu Custom Items (sorted by pointer, no need to use the return) const originalCustomItems = this._userOriginalGridMenu && Array.isArray(this._userOriginalGridMenu.customItems) ? this._userOriginalGridMenu.customItems : []; - this.controlOptions.customItems = [...originalCustomItems, ...this.addGridMenuCustomCommands(originalCustomItems)]; - this.extensionUtility.translateItems(this.controlOptions.customItems, 'titleKey', 'title'); - this.extensionUtility.sortItems(this.controlOptions.customItems, 'positionOrder'); + this.addonOptions.customItems = [...originalCustomItems, ...this.addGridMenuCustomCommands(originalCustomItems)]; + this.extensionUtility.translateItems(this.addonOptions.customItems, 'titleKey', 'title'); + this.extensionUtility.sortItems(this.addonOptions.customItems, 'positionOrder'); // create the Grid Menu DOM element this.createGridMenu(); @@ -185,10 +185,10 @@ export class GridMenuControl { createColumnPickerContainer() { // user could pass a title on top of the columns list - if (this.controlOptions?.columnTitle) { + if (this.addonOptions?.columnTitle) { this._columnTitleElm = document.createElement('div'); this._columnTitleElm.className = 'title'; - this._columnTitleElm.textContent = this.controlOptions?.columnTitle ?? this._defaults.columnTitle; + this._columnTitleElm.textContent = this.addonOptions?.columnTitle ?? this._defaults.columnTitle; this._gridMenuElm.appendChild(this._columnTitleElm); } @@ -200,31 +200,33 @@ export class GridMenuControl { } createGridMenu() { - const gridMenuWidth = this.controlOptions?.menuWidth ?? this._defaults.menuWidth; + this._gridUid = this._gridUid ?? this.grid.getUID() ?? ''; + const gridUidSelector = this._gridUid ? `.${this._gridUid}` : ''; + const gridMenuWidth = this.addonOptions?.menuWidth ?? this._defaults.menuWidth; const headerSide = (this.gridOptions.hasOwnProperty('frozenColumn') && this.gridOptions.frozenColumn! >= 0) ? 'right' : 'left'; - this._headerElm = document.querySelector(`.${this._gridUid} .slick-header-${headerSide}`); + this._headerElm = document.querySelector(`${gridUidSelector} .slick-header-${headerSide}`); if (this._headerElm) { // resize the header row to include the hamburger menu icon this._headerElm.style.width = `calc(100% - ${gridMenuWidth}px)`; // if header row is enabled, we also need to resize its width - const enableResizeHeaderRow = (this.controlOptions && this.controlOptions.resizeOnShowHeaderRow !== undefined) ? this.controlOptions.resizeOnShowHeaderRow : this._defaults.resizeOnShowHeaderRow; + const enableResizeHeaderRow = (this.addonOptions && this.addonOptions.resizeOnShowHeaderRow !== undefined) ? this.addonOptions.resizeOnShowHeaderRow : this._defaults.resizeOnShowHeaderRow; if (enableResizeHeaderRow && this.gridOptions.showHeaderRow) { - const headerRowElm = document.querySelector(`.${this._gridUid} .slick-headerrow`); + const headerRowElm = document.querySelector(`${gridUidSelector} .slick-headerrow`); if (headerRowElm) { headerRowElm.style.width = `calc(100% - ${gridMenuWidth}px)`; } } - const showButton = (this.controlOptions && this.controlOptions.showButton !== undefined) ? this.controlOptions.showButton : this._defaults.showButton; + const showButton = (this.addonOptions && this.addonOptions.showButton !== undefined) ? this.addonOptions.showButton : this._defaults.showButton; if (showButton) { this._gridMenuButtonElm = document.createElement('button'); this._gridMenuButtonElm.className = 'slick-gridmenu-button'; - if (this.controlOptions && this.controlOptions.iconCssClass) { - this._gridMenuButtonElm.classList.add(...this.controlOptions.iconCssClass.split(' ')); + if (this.addonOptions && this.addonOptions.iconCssClass) { + this._gridMenuButtonElm.classList.add(...this.addonOptions.iconCssClass.split(' ')); } else { - const iconImage = (this.controlOptions && this.controlOptions.iconImage) ? this.controlOptions.iconImage : ''; + const iconImage = (this.addonOptions && this.addonOptions.iconImage) ? this.addonOptions.iconImage : ''; const iconImageElm = document.createElement('img'); iconImageElm.src = iconImage; this._gridMenuButtonElm.appendChild(iconImageElm); @@ -235,8 +237,7 @@ export class GridMenuControl { this._bindEventService.bind(this._gridMenuButtonElm, 'click', this.showGridMenu.bind(this) as EventListener); } - this._gridUid = this.grid.getUID() ?? ''; - this.gridOptions.gridMenu = { ...this._defaults, ...this.controlOptions }; + this.gridOptions.gridMenu = { ...this._defaults, ...this.addonOptions }; // localization support for the picker this.translateTitleLabels(); @@ -263,12 +264,12 @@ export class GridMenuControl { this._gridMenuElm.appendChild(closePickerButtonElm); this._gridMenuElm.appendChild(this._customMenuElm); - this.populateCustomMenus(this.controlOptions, this._customMenuElm); + this.populateCustomMenus(this.addonOptions, this._customMenuElm); this.createColumnPickerContainer(); document.body.appendChild(this._gridMenuElm); - // Hide the menu on outside click. + // hide the menu on outside click. this._bindEventService.bind(document.body, 'mousedown', this.handleBodyMouseDown.bind(this) as EventListener); // destroy the picker if user leaves the page @@ -308,7 +309,7 @@ export class GridMenuControl { // execute optional callback method defined by the user, if it returns false then we won't go further neither close the menu this.pubSubService.publish('gridMenu:onMenuClose', callbackArgs); - if (typeof this.controlOptions?.onMenuClose === 'function' && this.controlOptions.onMenuClose(event, callbackArgs) === false) { + if (typeof this.addonOptions?.onMenuClose === 'function' && this.addonOptions.onMenuClose(event, callbackArgs) === false) { return; } @@ -337,10 +338,10 @@ export class GridMenuControl { populateCustomMenus(options: GridMenu, customMenuElm: HTMLDivElement) { if (Array.isArray(options?.customItems)) { // user could pass a title on top of the custom section - if (this.controlOptions?.customTitle) { + if (this.addonOptions?.customTitle) { this._customTitleElm = document.createElement('div'); this._customTitleElm.className = 'title'; - this._customTitleElm.textContent = this.controlOptions.customTitle; + this._customTitleElm.textContent = this.addonOptions.customTitle; customMenuElm.appendChild(this._customTitleElm); } @@ -368,13 +369,15 @@ export class GridMenuControl { // when the override is defined, we need to use its result to update the disabled property // so that "handleMenuItemCommandClick" has the correct flag and won't trigger a command clicked event - if (typeof item === 'object' && Object.prototype.hasOwnProperty.call(item, 'itemUsabilityOverride')) { + if (typeof item === 'object' && item.itemUsabilityOverride) { item.disabled = isItemUsable ? false : true; } const liElm = document.createElement('li'); liElm.className = 'slick-gridmenu-item'; - liElm.dataset.command = typeof item === 'object' && item.command || ''; + if (typeof item === 'object' && hasData(item?.command)) { + liElm.dataset.command = item.command; + } customMenuElm.appendChild(liElm); if ((typeof item === 'object' && item.divider) || item === 'divider') { @@ -430,7 +433,7 @@ export class GridMenuControl { this.init(); } - repositionMenu(e: MouseEvent, controlOptions: GridMenu) { + repositionMenu(e: MouseEvent, addonOptions: GridMenu) { let buttonElm = (e.target as HTMLButtonElement).nodeName === 'BUTTON' ? (e.target as HTMLButtonElement) : (e.target as HTMLElement).querySelector('button') as HTMLButtonElement; // get button element if (!buttonElm) { buttonElm = (e.target as HTMLElement).parentElement as HTMLButtonElement; // external grid menu might fall in this last case if wrapped in a span/div @@ -440,16 +443,16 @@ export class GridMenuControl { const buttonWidth = parseInt(buttonComptStyle?.width ?? this._defaults?.menuWidth, 10); const menuWidth = this._gridMenuElm?.offsetWidth ?? 0; - const contentMinWidth = controlOptions?.contentMinWidth ?? this._defaults.contentMinWidth ?? 0; + const contentMinWidth = addonOptions?.contentMinWidth ?? this._defaults.contentMinWidth ?? 0; const currentMenuWidth = ((contentMinWidth > menuWidth) ? contentMinWidth : (menuWidth)) || 0; const nextPositionTop = menuIconOffset?.bottom ?? 0; const nextPositionLeft = menuIconOffset?.right ?? 0; - const menuMarginBottom = ((controlOptions?.marginBottom !== undefined) ? controlOptions.marginBottom : this._defaults.marginBottom) || 0; - const calculatedLeftPosition = controlOptions?.alignDropSide === 'left' ? nextPositionLeft - buttonWidth : nextPositionLeft - currentMenuWidth; + const menuMarginBottom = ((addonOptions?.marginBottom !== undefined) ? addonOptions.marginBottom : this._defaults.marginBottom) || 0; + const calculatedLeftPosition = addonOptions?.alignDropSide === 'left' ? nextPositionLeft - buttonWidth : nextPositionLeft - currentMenuWidth; this._gridMenuElm.style.top = `${nextPositionTop}px`; this._gridMenuElm.style.left = `${calculatedLeftPosition}px`; - this._gridMenuElm.classList.add(controlOptions?.alignDropSide === 'left' ? 'dropleft' : 'dropright'); + this._gridMenuElm.classList.add(addonOptions?.alignDropSide === 'left' ? 'dropleft' : 'dropright'); this._gridMenuElm.appendChild(this._listElm); if (contentMinWidth! > 0) { @@ -457,8 +460,8 @@ export class GridMenuControl { } // set 'height' when defined OR ELSE use the 'max-height' with available window size and optional margin bottom - if (controlOptions?.height !== undefined) { - this._gridMenuElm.style.height = `${controlOptions.height}px`; + if (addonOptions?.height !== undefined) { + this._gridMenuElm.style.height = `${addonOptions.height}px`; } else { this._gridMenuElm.style.maxHeight = `${window.innerHeight - e.clientY - menuMarginBottom}px`; } @@ -475,8 +478,8 @@ export class GridMenuControl { emptyElement(this._listElm); emptyElement(this._customMenuElm); - const controlOptions: GridMenu = { ...this.controlOptions, ...options }; // merge optional picker option - this.populateCustomMenus(controlOptions, this._customMenuElm); + const addonOptions: GridMenu = { ...this.addonOptions, ...options }; // merge optional picker option + this.populateCustomMenus(addonOptions, this._customMenuElm); updateColumnPickerOrder.call(this); this._columnCheckboxes = []; @@ -488,26 +491,26 @@ export class GridMenuControl { } as GridMenuEventWithElementCallbackArgs; // run the override function (when defined), if the result is false then we won't go further - if (controlOptions && !this.extensionUtility.runOverrideFunctionWhenExists(controlOptions.menuUsabilityOverride, callbackArgs)) { + if (addonOptions && !this.extensionUtility.runOverrideFunctionWhenExists(addonOptions.menuUsabilityOverride, callbackArgs)) { return; } // 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('gridMenu:onBeforeMenuShow', callbackArgs); - if (typeof controlOptions?.onBeforeMenuShow === 'function' && controlOptions.onBeforeMenuShow(e, callbackArgs) === false) { + if (typeof addonOptions?.onBeforeMenuShow === 'function' && addonOptions.onBeforeMenuShow(e, callbackArgs) === false) { return; } } // load the column & create column picker list - populateColumnPicker.call(this, controlOptions); - this.repositionMenu(e, controlOptions); + populateColumnPicker.call(this, addonOptions); + this.repositionMenu(e, addonOptions); // execute optional callback method defined by the user this.pubSubService.publish('gridMenu:onAfterMenuShow', callbackArgs); - if (typeof controlOptions?.onAfterMenuShow === 'function') { - controlOptions.onAfterMenuShow(e, callbackArgs); + if (typeof addonOptions?.onAfterMenuShow === 'function') { + addonOptions.onAfterMenuShow(e, callbackArgs); } } @@ -525,33 +528,33 @@ export class GridMenuControl { translateGridMenu() { // update the properties by pointers, that is the only way to get Grid Menu Control to see the new values // we also need to call the control init so that it takes the new Grid object with latest values - if (this.controlOptions) { - this.controlOptions.customItems = []; - this.controlOptions.customTitle = ''; - this.controlOptions.columnTitle = ''; - this.controlOptions.forceFitTitle = ''; - this.controlOptions.syncResizeTitle = ''; + if (this.addonOptions) { + this.addonOptions.customItems = []; + this.addonOptions.customTitle = ''; + this.addonOptions.columnTitle = ''; + this.addonOptions.forceFitTitle = ''; + this.addonOptions.syncResizeTitle = ''; // merge original user grid menu items with internal items // then sort all Grid Menu Custom Items (sorted by pointer, no need to use the return) const originalCustomItems = this._userOriginalGridMenu && Array.isArray(this._userOriginalGridMenu.customItems) ? this._userOriginalGridMenu.customItems : []; - this.controlOptions.customItems = [...originalCustomItems, ...this.addGridMenuCustomCommands(originalCustomItems)]; - this.extensionUtility.translateItems(this.controlOptions.customItems, 'titleKey', 'title'); - this.extensionUtility.sortItems(this.controlOptions.customItems, 'positionOrder'); + this.addonOptions.customItems = [...originalCustomItems, ...this.addGridMenuCustomCommands(originalCustomItems)]; + this.extensionUtility.translateItems(this.addonOptions.customItems, 'titleKey', 'title'); + this.extensionUtility.sortItems(this.addonOptions.customItems, 'positionOrder'); this.translateTitleLabels(); // translate all columns (including non-visible) this.extensionUtility.translateItems(this._columns, 'nameKey', 'name'); // update the Titles of each sections (command, customTitle, ...) - this.updateAllTitles(this.controlOptions); + this.updateAllTitles(this.addonOptions); } } translateTitleLabels() { - this.controlOptions.columnTitle = this.extensionUtility.getPickerTitleOutputString('columnTitle', 'gridMenu'); - this.controlOptions.forceFitTitle = this.extensionUtility.getPickerTitleOutputString('forceFitTitle', 'gridMenu'); - this.controlOptions.syncResizeTitle = this.extensionUtility.getPickerTitleOutputString('syncResizeTitle', 'gridMenu'); + this.addonOptions.columnTitle = this.extensionUtility.getPickerTitleOutputString('columnTitle', 'gridMenu'); + this.addonOptions.forceFitTitle = this.extensionUtility.getPickerTitleOutputString('forceFitTitle', 'gridMenu'); + this.addonOptions.syncResizeTitle = this.extensionUtility.getPickerTitleOutputString('syncResizeTitle', 'gridMenu'); } // -- @@ -564,15 +567,15 @@ export class GridMenuControl { const gridMenuCustomItems: Array = []; const gridOptions = this.gridOptions; const translationPrefix = getTranslationPrefix(gridOptions); - const commandLabels = this.controlOptions?.commandLabels; + const commandLabels = this.addonOptions?.commandLabels; // show grid menu: Unfreeze Columns/Rows - if (this.gridOptions && this.controlOptions && !this.controlOptions.hideClearFrozenColumnsCommand) { + if (this.gridOptions && this.addonOptions && !this.addonOptions.hideClearFrozenColumnsCommand) { const commandName = 'clear-pinning'; if (!originalCustomItems.some(item => item !== 'divider' && item.hasOwnProperty('command') && item.command === commandName)) { gridMenuCustomItems.push( { - iconCssClass: this.controlOptions.iconClearFrozenColumnsCommand || 'fa fa-times', + iconCssClass: this.addonOptions.iconClearFrozenColumnsCommand || 'fa fa-times', title: this.extensionUtility.translateWhenEnabledAndServiceExist(`${translationPrefix}${commandLabels?.clearFrozenColumnsCommandKey}`, 'TEXT_CLEAR_PINNING', commandLabels?.clearFrozenColumnsCommand), disabled: false, command: commandName, @@ -584,12 +587,12 @@ export class GridMenuControl { if (this.gridOptions && (this.gridOptions.enableFiltering && !this.sharedService.hideHeaderRowAfterPageLoad)) { // show grid menu: Clear all Filters - if (this.gridOptions && this.controlOptions && !this.controlOptions.hideClearAllFiltersCommand) { + if (this.gridOptions && this.addonOptions && !this.addonOptions.hideClearAllFiltersCommand) { const commandName = 'clear-filter'; if (!originalCustomItems.some(item => item !== 'divider' && item.hasOwnProperty('command') && item.command === commandName)) { gridMenuCustomItems.push( { - iconCssClass: this.controlOptions.iconClearAllFiltersCommand || 'fa fa-filter text-danger', + iconCssClass: this.addonOptions.iconClearAllFiltersCommand || 'fa fa-filter text-danger', title: this.extensionUtility.translateWhenEnabledAndServiceExist(`${translationPrefix}${commandLabels?.clearAllFiltersCommandKey}`, 'TEXT_CLEAR_ALL_FILTERS', commandLabels?.clearAllFiltersCommand), disabled: false, command: commandName, @@ -600,12 +603,12 @@ export class GridMenuControl { } // show grid menu: toggle filter row - if (this.gridOptions && this.controlOptions && !this.controlOptions.hideToggleFilterCommand) { + if (this.gridOptions && this.addonOptions && !this.addonOptions.hideToggleFilterCommand) { const commandName = 'toggle-filter'; if (!originalCustomItems.some(item => item !== 'divider' && item.hasOwnProperty('command') && item.command === commandName)) { gridMenuCustomItems.push( { - iconCssClass: this.controlOptions.iconToggleFilterCommand || 'fa fa-random', + iconCssClass: this.addonOptions.iconToggleFilterCommand || 'fa fa-random', title: this.extensionUtility.translateWhenEnabledAndServiceExist(`${translationPrefix}${commandLabels?.toggleFilterCommandKey}`, 'TEXT_TOGGLE_FILTER_ROW', commandLabels?.toggleFilterCommand), disabled: false, command: commandName, @@ -616,12 +619,12 @@ export class GridMenuControl { } // show grid menu: refresh dataset - if (backendApi && this.gridOptions && this.controlOptions && !this.controlOptions.hideRefreshDatasetCommand) { + if (backendApi && this.gridOptions && this.addonOptions && !this.addonOptions.hideRefreshDatasetCommand) { const commandName = 'refresh-dataset'; if (!originalCustomItems.some(item => item !== 'divider' && item.hasOwnProperty('command') && item.command === commandName)) { gridMenuCustomItems.push( { - iconCssClass: this.controlOptions.iconRefreshDatasetCommand || 'fa fa-refresh', + iconCssClass: this.addonOptions.iconRefreshDatasetCommand || 'fa fa-refresh', title: this.extensionUtility.translateWhenEnabledAndServiceExist(`${translationPrefix}${commandLabels?.refreshDatasetCommandKey}`, 'TEXT_REFRESH_DATASET', commandLabels?.refreshDatasetCommand), disabled: false, command: commandName, @@ -634,12 +637,12 @@ export class GridMenuControl { if (this.gridOptions.showPreHeaderPanel) { // show grid menu: toggle pre-header row - if (this.gridOptions && this.controlOptions && !this.controlOptions.hideTogglePreHeaderCommand) { + if (this.gridOptions && this.addonOptions && !this.addonOptions.hideTogglePreHeaderCommand) { const commandName = 'toggle-preheader'; if (!originalCustomItems.some(item => item !== 'divider' && item.hasOwnProperty('command') && item.command === commandName)) { gridMenuCustomItems.push( { - iconCssClass: this.controlOptions.iconTogglePreHeaderCommand || 'fa fa-random', + iconCssClass: this.addonOptions.iconTogglePreHeaderCommand || 'fa fa-random', title: this.extensionUtility.translateWhenEnabledAndServiceExist(`${translationPrefix}${commandLabels?.togglePreHeaderCommandKey}`, 'TEXT_TOGGLE_PRE_HEADER_ROW', commandLabels?.togglePreHeaderCommand), disabled: false, command: commandName, @@ -652,12 +655,12 @@ export class GridMenuControl { if (this.gridOptions.enableSorting) { // show grid menu: Clear all Sorting - if (this.gridOptions && this.controlOptions && !this.controlOptions.hideClearAllSortingCommand) { + if (this.gridOptions && this.addonOptions && !this.addonOptions.hideClearAllSortingCommand) { const commandName = 'clear-sorting'; if (!originalCustomItems.some(item => item !== 'divider' && item.hasOwnProperty('command') && item.command === commandName)) { gridMenuCustomItems.push( { - iconCssClass: this.controlOptions.iconClearAllSortingCommand || 'fa fa-unsorted text-danger', + iconCssClass: this.addonOptions.iconClearAllSortingCommand || 'fa fa-unsorted text-danger', title: this.extensionUtility.translateWhenEnabledAndServiceExist(`${translationPrefix}${commandLabels?.clearAllSortingCommandKey}`, 'TEXT_CLEAR_ALL_SORTING', commandLabels?.clearAllSortingCommand), disabled: false, command: commandName, @@ -669,12 +672,12 @@ export class GridMenuControl { } // show grid menu: Export to file - if ((this.gridOptions?.enableExport || this.gridOptions?.enableTextExport) && this.controlOptions && !this.controlOptions.hideExportCsvCommand) { + if ((this.gridOptions?.enableExport || this.gridOptions?.enableTextExport) && this.addonOptions && !this.addonOptions.hideExportCsvCommand) { const commandName = 'export-csv'; if (!originalCustomItems.some(item => item !== 'divider' && item.hasOwnProperty('command') && item.command === commandName)) { gridMenuCustomItems.push( { - iconCssClass: this.controlOptions.iconExportCsvCommand || 'fa fa-download', + iconCssClass: this.addonOptions.iconExportCsvCommand || 'fa fa-download', title: this.extensionUtility.translateWhenEnabledAndServiceExist(`${translationPrefix}${commandLabels?.exportCsvCommandKey}`, 'TEXT_EXPORT_TO_CSV', commandLabels?.exportCsvCommand), disabled: false, command: commandName, @@ -685,12 +688,12 @@ export class GridMenuControl { } // show grid menu: Export to Excel - if (this.gridOptions && this.gridOptions.enableExcelExport && this.controlOptions && !this.controlOptions.hideExportExcelCommand) { + if (this.gridOptions && this.gridOptions.enableExcelExport && this.addonOptions && !this.addonOptions.hideExportExcelCommand) { const commandName = 'export-excel'; if (!originalCustomItems.some(item => item !== 'divider' && item.hasOwnProperty('command') && item.command === commandName)) { gridMenuCustomItems.push( { - iconCssClass: this.controlOptions.iconExportExcelCommand || 'fa fa-file-excel-o text-success', + iconCssClass: this.addonOptions.iconExportExcelCommand || 'fa fa-file-excel-o text-success', title: this.extensionUtility.translateWhenEnabledAndServiceExist(`${translationPrefix}${commandLabels?.exportExcelCommandKey}`, 'TEXT_EXPORT_TO_EXCEL', commandLabels?.exportExcelCommand), disabled: false, command: commandName, @@ -701,12 +704,12 @@ export class GridMenuControl { } // show grid menu: export to text file as tab delimited - if ((this.gridOptions?.enableExport || this.gridOptions?.enableTextExport) && this.controlOptions && !this.controlOptions.hideExportTextDelimitedCommand) { + if ((this.gridOptions?.enableExport || this.gridOptions?.enableTextExport) && this.addonOptions && !this.addonOptions.hideExportTextDelimitedCommand) { const commandName = 'export-text-delimited'; if (!originalCustomItems.some(item => item !== 'divider' && item.hasOwnProperty('command') && item.command === commandName)) { gridMenuCustomItems.push( { - iconCssClass: this.controlOptions.iconExportTextDelimitedCommand || 'fa fa-download', + iconCssClass: this.addonOptions.iconExportTextDelimitedCommand || 'fa fa-download', title: this.extensionUtility.translateWhenEnabledAndServiceExist(`${translationPrefix}${commandLabels?.exportTextDelimitedCommandKey}`, 'TEXT_EXPORT_TO_TAB_DELIMITED', commandLabels?.exportTextDelimitedCommand), disabled: false, command: commandName, @@ -717,8 +720,8 @@ export class GridMenuControl { } // add the custom "Commands" title if there are any commands - if (this.gridOptions && this.controlOptions && (Array.isArray(gridMenuCustomItems) && gridMenuCustomItems.length > 0 || (Array.isArray(this.controlOptions.customItems) && this.controlOptions.customItems.length > 0))) { - this.controlOptions.customTitle = this.controlOptions.customTitle || this.extensionUtility.getPickerTitleOutputString('customTitle', 'gridMenu'); + if (this.gridOptions && this.addonOptions && (Array.isArray(gridMenuCustomItems) && gridMenuCustomItems.length > 0 || (Array.isArray(this.addonOptions.customItems) && this.addonOptions.customItems.length > 0))) { + this.addonOptions.customTitle = this.addonOptions.customTitle || this.extensionUtility.getPickerTitleOutputString('customTitle', 'gridMenu'); } return gridMenuCustomItems; @@ -855,8 +858,8 @@ export class GridMenuControl { // we'll also execute optional user defined onCommand callback when provided this.executeGridMenuInternalCustomCommands(event, callbackArgs); this.pubSubService.publish('gridMenu:onCommand', callbackArgs); - if (typeof this.controlOptions?.onCommand === 'function') { - this.controlOptions.onCommand(event, callbackArgs); + if (typeof this.addonOptions?.onCommand === 'function') { + this.addonOptions.onCommand(event, callbackArgs); } // execute action callback when defined @@ -866,7 +869,7 @@ export class GridMenuControl { } // does the user want to leave open the Grid Menu after executing a command? - if (!this.controlOptions.leaveOpen && !event.defaultPrevented) { + if (!this.addonOptions.leaveOpen && !event.defaultPrevented) { this.hideMenu(event); } diff --git a/packages/common/src/editors/__tests__/longTextEditor.spec.ts b/packages/common/src/editors/__tests__/longTextEditor.spec.ts index 6031ff96b..8e099d0e3 100644 --- a/packages/common/src/editors/__tests__/longTextEditor.spec.ts +++ b/packages/common/src/editors/__tests__/longTextEditor.spec.ts @@ -3,10 +3,10 @@ import { LongTextEditor } from '../longTextEditor'; import { KeyCode } from '../../enums/index'; import { AutocompleteOption, Column, ColumnEditor, EditorArguments, GridOption, SlickDataView, SlickGrid, SlickNamespace } from '../../interfaces/index'; import { TranslateServiceStub } from '../../../../../test/translateServiceStub'; -import * as utilities from '../../services/utilities'; +import * as domUtilities from '../../services/domUtilities'; const mockGetHtmlElementOffset = jest.fn(); // @ts-ignore:2540 -utilities.getHtmlElementOffset = mockGetHtmlElementOffset; +domUtilities.getHtmlElementOffset = mockGetHtmlElementOffset; declare const Slick: SlickNamespace; const KEY_CHAR_A = 97; diff --git a/packages/common/src/editors/longTextEditor.ts b/packages/common/src/editors/longTextEditor.ts index 91ef8708d..c12dfa48e 100644 --- a/packages/common/src/editors/longTextEditor.ts +++ b/packages/common/src/editors/longTextEditor.ts @@ -15,7 +15,8 @@ import { SlickGrid, SlickNamespace, } from '../interfaces/index'; -import { getDescendantProperty, getHtmlElementOffset, getTranslationPrefix, setDeepValue, toSentenceCase, } from '../services/utilities'; +import { getHtmlElementOffset, } from '../services/domUtilities'; +import { getDescendantProperty, getTranslationPrefix, setDeepValue, toSentenceCase, } from '../services/utilities'; import { BindingEventService } from '../services/bindingEvent.service'; import { TranslaterService } from '../services/translater.service'; import { textValidator } from '../editorValidators/textValidator'; diff --git a/packages/common/src/extensions/__tests__/cellMenuExtension.spec.ts b/packages/common/src/extensions/__tests__/cellMenuExtension.spec.ts deleted file mode 100644 index d1e2cf205..000000000 --- a/packages/common/src/extensions/__tests__/cellMenuExtension.spec.ts +++ /dev/null @@ -1,411 +0,0 @@ -import { CellMenuExtension } from '../cellMenuExtension'; -import { ExtensionUtility } from '../extensionUtility'; -import { SharedService } from '../../services/shared.service'; -import { Column, SlickDataView, GridOption, SlickGrid, SlickNamespace, CellMenu, SlickCellMenu } from '../../interfaces/index'; -import { TranslateServiceStub } from '../../../../../test/translateServiceStub'; -import { BackendUtilityService } from '../../services'; - -declare const Slick: SlickNamespace; - -const dataViewStub = { - refresh: jest.fn(), -} as unknown as SlickDataView; - -const gridStub = { - autosizeColumns: jest.fn(), - getColumnIndex: jest.fn(), - getColumns: jest.fn(), - getOptions: jest.fn(), - registerPlugin: jest.fn(), - setColumns: jest.fn(), - setHeaderRowVisibility: jest.fn(), - setTopPanelVisibility: jest.fn(), - setPreHeaderPanelVisibility: jest.fn(), - setSortColumns: jest.fn(), - onSort: new Slick.Event(), -} as unknown as SlickGrid; - -const mockAddon = jest.fn().mockImplementation(() => ({ - init: jest.fn(), - destroy: jest.fn(), - onAfterMenuShow: new Slick.Event(), - onBeforeMenuClose: new Slick.Event(), - onBeforeMenuShow: new Slick.Event(), - onColumnsChanged: new Slick.Event(), - onCommand: new Slick.Event(), - onOptionSelected: new Slick.Event(), -})); - -describe('CellMenuExtension', () => { - jest.mock('slickgrid/plugins/slick.cellmenu', () => mockAddon); - Slick.Plugins = { - CellMenu: mockAddon - } as any; - - const columnsMock: Column[] = [{ id: 'field1', field: 'field1', width: 100, nameKey: 'TITLE' }, { id: 'field2', field: 'field2', width: 75 }]; - let extensionUtility: ExtensionUtility; - let backendUtilityService: BackendUtilityService; - let translateService: TranslateServiceStub; - let extension: CellMenuExtension; - let sharedService: SharedService; - - const gridOptionsMock = { - enableAutoSizeColumns: true, - enableCellMenu: true, - enableTranslate: true, - backendServiceApi: { - service: { - buildQuery: jest.fn(), - }, - internalPostProcess: jest.fn(), - preProcess: jest.fn(), - process: jest.fn(), - postProcess: jest.fn(), - }, - cellMenu: { - autoAdjustDrop: true, - autoAlignSide: true, - autoAdjustDropOffset: 0, - autoAlignSideOffset: 0, - hideMenuOnScroll: true, - maxHeight: 'none', - width: 'auto', - onExtensionRegistered: jest.fn(), - onCommand: () => { }, - onAfterMenuShow: () => { }, - onBeforeMenuShow: () => { }, - onBeforeMenuClose: () => { }, - onOptionSelected: () => { }, - }, - multiColumnSort: true, - pagination: { - totalItems: 0 - }, - showHeaderRow: false, - showTopPanel: false, - showPreHeaderPanel: false - } as unknown as GridOption; - - describe('with I18N Service', () => { - beforeEach(() => { - sharedService = new SharedService(); - backendUtilityService = new BackendUtilityService(); - translateService = new TranslateServiceStub(); - extensionUtility = new ExtensionUtility(sharedService, backendUtilityService, translateService); - extension = new CellMenuExtension(extensionUtility, sharedService, translateService); - translateService.use('fr'); - }); - - afterEach(() => { - jest.clearAllMocks(); - }); - - it('should return null when either the grid object or the grid options is missing', () => { - const output = extension.register(); - expect(output).toBeNull(); - }); - - describe('registered addon', () => { - beforeEach(() => { - jest.spyOn(SharedService.prototype, 'dataView', 'get').mockReturnValue(dataViewStub); - jest.spyOn(SharedService.prototype, 'slickGrid', 'get').mockReturnValue(gridStub); - jest.spyOn(SharedService.prototype, 'gridOptions', 'get').mockReturnValue(gridOptionsMock); - jest.spyOn(SharedService.prototype, 'allColumns', 'get').mockReturnValue(columnsMock); - jest.spyOn(SharedService.prototype, 'visibleColumns', 'get').mockReturnValue(columnsMock); - jest.spyOn(SharedService.prototype, 'columnDefinitions', 'get').mockReturnValue(columnsMock); - }); - - it('should register the addon', () => { - const pluginSpy = jest.spyOn(SharedService.prototype.slickGrid, 'registerPlugin'); - const onRegisteredSpy = jest.spyOn(SharedService.prototype.gridOptions.cellMenu as CellMenu, 'onExtensionRegistered'); - - const instance = extension.register() as SlickCellMenu; - const addonInstance = extension.getAddonInstance(); - - expect(instance).toBeTruthy(); - expect(instance).toEqual(addonInstance); - expect(mockAddon).toHaveBeenCalledWith({ - autoAdjustDrop: true, - autoAlignSide: true, - autoAdjustDropOffset: 0, - autoAlignSideOffset: 0, - hideMenuOnScroll: true, - maxHeight: 'none', - width: 'auto', - onCommand: expect.anything(), - onOptionSelected: expect.anything(), - onBeforeMenuClose: expect.anything(), - onBeforeMenuShow: expect.anything(), - onAfterMenuShow: expect.anything(), - onExtensionRegistered: expect.anything(), - }); - expect(onRegisteredSpy).toHaveBeenCalledWith(instance); - expect(pluginSpy).toHaveBeenCalledWith(instance); - }); - - it('should call internal event handler subscribe and expect the "onBeforeMenuShow" option to be called when addon notify is called', () => { - const handlerSpy = jest.spyOn(extension.eventHandler, 'subscribe'); - const onb4CloseSpy = jest.spyOn(SharedService.prototype.gridOptions.cellMenu as CellMenu, 'onBeforeMenuClose'); - const onb4ShowSpy = jest.spyOn(SharedService.prototype.gridOptions.cellMenu as CellMenu, 'onBeforeMenuShow'); - const onAfterShowSpy = jest.spyOn(SharedService.prototype.gridOptions.cellMenu as CellMenu, 'onAfterMenuShow'); - const onCommandSpy = jest.spyOn(SharedService.prototype.gridOptions.cellMenu as CellMenu, 'onCommand'); - const onOptionSpy = jest.spyOn(SharedService.prototype.gridOptions.cellMenu as CellMenu, 'onOptionSelected'); - - const instance = extension.register() as SlickCellMenu; - instance.onBeforeMenuShow.notify({ cell: 0, row: 0, grid: gridStub }, new Slick.EventData(), gridStub); - - expect(handlerSpy).toHaveBeenCalledTimes(5); - expect(handlerSpy).toHaveBeenCalledWith( - { notify: expect.anything(), subscribe: expect.anything(), unsubscribe: expect.anything(), }, - expect.anything() - ); - expect(onb4ShowSpy).toHaveBeenCalledWith(expect.anything(), { cell: 0, row: 0, grid: gridStub }); - expect(onb4CloseSpy).not.toHaveBeenCalled(); - expect(onCommandSpy).not.toHaveBeenCalled(); - expect(onOptionSpy).not.toHaveBeenCalled(); - expect(onAfterShowSpy).not.toHaveBeenCalled(); - }); - - it('should call internal event handler subscribe and expect the "onBeforeMenuClose" option to be called when addon notify is called', () => { - const handlerSpy = jest.spyOn(extension.eventHandler, 'subscribe'); - const onb4CloseSpy = jest.spyOn(SharedService.prototype.gridOptions.cellMenu as CellMenu, 'onBeforeMenuClose'); - const onb4ShowSpy = jest.spyOn(SharedService.prototype.gridOptions.cellMenu as CellMenu, 'onBeforeMenuShow'); - const onAfterShowSpy = jest.spyOn(SharedService.prototype.gridOptions.cellMenu as CellMenu, 'onAfterMenuShow'); - const onCommandSpy = jest.spyOn(SharedService.prototype.gridOptions.cellMenu as CellMenu, 'onCommand'); - const onOptionSpy = jest.spyOn(SharedService.prototype.gridOptions.cellMenu as CellMenu, 'onOptionSelected'); - - const menuElm = document.createElement('div'); - const instance = extension.register() as SlickCellMenu; - instance.onBeforeMenuClose.notify({ cell: 0, row: 0, grid: gridStub, menu: menuElm }, new Slick.EventData(), gridStub); - - expect(handlerSpy).toHaveBeenCalledTimes(5); - expect(handlerSpy).toHaveBeenCalledWith( - { notify: expect.anything(), subscribe: expect.anything(), unsubscribe: expect.anything(), }, - expect.anything() - ); - expect(onb4CloseSpy).toHaveBeenCalledWith(expect.anything(), { cell: 0, row: 0, grid: gridStub, menu: menuElm }); - expect(onb4ShowSpy).not.toHaveBeenCalled(); - expect(onCommandSpy).not.toHaveBeenCalled(); - expect(onOptionSpy).not.toHaveBeenCalled(); - expect(onAfterShowSpy).not.toHaveBeenCalled(); - }); - - it('should call internal event handler subscribe and expect the "onAfterMenuShow" option to be called when addon notify is called', () => { - const handlerSpy = jest.spyOn(extension.eventHandler, 'subscribe'); - const onb4CloseSpy = jest.spyOn(SharedService.prototype.gridOptions.cellMenu, 'onBeforeMenuClose'); - const onb4ShowSpy = jest.spyOn(SharedService.prototype.gridOptions.cellMenu, 'onBeforeMenuShow'); - const onAfterShowSpy = jest.spyOn(SharedService.prototype.gridOptions.cellMenu, 'onAfterMenuShow'); - const onCommandSpy = jest.spyOn(SharedService.prototype.gridOptions.cellMenu, 'onCommand'); - const onOptionSpy = jest.spyOn(SharedService.prototype.gridOptions.cellMenu, 'onOptionSelected'); - - const instance = extension.register(); - instance.onAfterMenuShow.notify({ cell: 0, row: 0, grid: gridStub }, new Slick.EventData(), gridStub); - - expect(handlerSpy).toHaveBeenCalledTimes(5); - expect(handlerSpy).toHaveBeenCalledWith( - { notify: expect.anything(), subscribe: expect.anything(), unsubscribe: expect.anything(), }, - expect.anything() - ); - expect(onAfterShowSpy).toHaveBeenCalledWith(expect.anything(), { cell: 0, row: 0, grid: gridStub }); - expect(onb4CloseSpy).not.toHaveBeenCalled(); - expect(onb4ShowSpy).not.toHaveBeenCalled(); - expect(onCommandSpy).not.toHaveBeenCalled(); - expect(onOptionSpy).not.toHaveBeenCalled(); - }); - - it('should call internal event handler subscribe and expect the "onCommand" option to be called when addon notify is called', () => { - const handlerSpy = jest.spyOn(extension.eventHandler, 'subscribe'); - const onb4CloseSpy = jest.spyOn(SharedService.prototype.gridOptions.cellMenu as CellMenu, 'onBeforeMenuClose'); - const onb4ShowSpy = jest.spyOn(SharedService.prototype.gridOptions.cellMenu as CellMenu, 'onBeforeMenuShow'); - const onAfterShowSpy = jest.spyOn(SharedService.prototype.gridOptions.cellMenu as CellMenu, 'onAfterMenuShow'); - const onCommandSpy = jest.spyOn(SharedService.prototype.gridOptions.cellMenu as CellMenu, 'onCommand'); - const onOptionSpy = jest.spyOn(SharedService.prototype.gridOptions.cellMenu as CellMenu, 'onOptionSelected'); - - const instance = extension.register() as SlickCellMenu; - instance.onCommand.notify({ item: { command: 'help' }, column: {} as Column, grid: gridStub, command: 'help' }, new Slick.EventData(), gridStub); - - expect(handlerSpy).toHaveBeenCalledTimes(5); - expect(handlerSpy).toHaveBeenCalledWith( - { notify: expect.anything(), subscribe: expect.anything(), unsubscribe: expect.anything(), }, - expect.anything() - ); - expect(onCommandSpy).toHaveBeenCalledWith(expect.anything(), { item: { command: 'help' }, column: {} as Column, grid: gridStub, command: 'help' }); - expect(onOptionSpy).not.toHaveBeenCalled(); - expect(onb4CloseSpy).not.toHaveBeenCalled(); - expect(onb4ShowSpy).not.toHaveBeenCalled(); - expect(onAfterShowSpy).not.toHaveBeenCalled(); - }); - - it('should call internal event handler subscribe and expect the "onOptionSelected" option to be called when addon notify is called', () => { - const handlerSpy = jest.spyOn(extension.eventHandler, 'subscribe'); - const onb4CloseSpy = jest.spyOn(SharedService.prototype.gridOptions.cellMenu as CellMenu, 'onBeforeMenuClose'); - const onb4ShowSpy = jest.spyOn(SharedService.prototype.gridOptions.cellMenu as CellMenu, 'onBeforeMenuShow'); - const onAfterShowSpy = jest.spyOn(SharedService.prototype.gridOptions.cellMenu as CellMenu, 'onAfterMenuShow'); - const onCommandSpy = jest.spyOn(SharedService.prototype.gridOptions.cellMenu as CellMenu, 'onCommand'); - const onOptionSpy = jest.spyOn(SharedService.prototype.gridOptions.cellMenu as CellMenu, 'onOptionSelected'); - - const instance = extension.register() as SlickCellMenu; - instance.onOptionSelected.notify({ item: { option: {} }, column: {} as Column, grid: gridStub, option: 'help' }, new Slick.EventData(), gridStub); - - expect(handlerSpy).toHaveBeenCalledTimes(5); - expect(handlerSpy).toHaveBeenCalledWith( - { notify: expect.anything(), subscribe: expect.anything(), unsubscribe: expect.anything(), }, - expect.anything() - ); - expect(onOptionSpy).toHaveBeenCalledWith(expect.anything(), { item: { option: {} }, column: {} as Column, grid: gridStub, option: 'help' }); - expect(onCommandSpy).not.toHaveBeenCalled(); - expect(onb4CloseSpy).not.toHaveBeenCalled(); - expect(onb4ShowSpy).not.toHaveBeenCalled(); - expect(onAfterShowSpy).not.toHaveBeenCalled(); - }); - - it('should dispose of the addon', () => { - const instance = extension.register() as SlickCellMenu; - const destroySpy = jest.spyOn(instance, 'destroy'); - - extension.dispose(); - - expect(destroySpy).toHaveBeenCalled(); - }); - }); - - describe('translateCellMenu method', () => { - it('should call the resetCellMenuTranslations and have all cell menu commands translated', () => { - const mockColumns: Column[] = [{ - id: 'field1', field: 'field1', width: 100, - cellMenu: { - commandTitleKey: 'COMMANDS', - commandItems: [ - { iconCssClass: 'fa fa-sort-asc', title: 'Trier par ordre croissant', titleKey: 'SORT_ASCENDING', command: 'sort-asc', positionOrder: 50 }, - { iconCssClass: 'fa fa-sort-desc', title: 'Trier par ordre décroissant', titleKey: 'SORT_DESCENDING', command: 'sort-desc', positionOrder: 51 }, - { divider: true, command: '', positionOrder: 52 }, - { iconCssClass: 'fa fa-filter', title: 'Supprimer le filtre', titleKey: 'REMOVE_FILTER', command: 'clear-filter', positionOrder: 53 }, - { iconCssClass: 'fa fa-unsorted', title: 'Supprimer le tri', titleKey: 'REMOVE_SORT', command: 'clear-sort', positionOrder: 54 }, - { iconCssClass: 'fa fa-times', command: 'hide', titleKey: 'HIDE_COLUMN', positionOrder: 55, title: 'Cacher la colonne' }, - ] - } - }]; - jest.spyOn(SharedService.prototype, 'allColumns', 'get').mockReturnValue(mockColumns); - - translateService.use('en'); - extension.translateCellMenu(); - - expect(mockColumns).toEqual([{ - id: 'field1', field: 'field1', width: 100, - cellMenu: { - commandTitle: 'Commands', - commandTitleKey: 'COMMANDS', - commandItems: [ - { iconCssClass: 'fa fa-sort-asc', title: 'Sort Ascending', titleKey: 'SORT_ASCENDING', command: 'sort-asc', positionOrder: 50 }, - { iconCssClass: 'fa fa-sort-desc', title: 'Sort Descending', titleKey: 'SORT_DESCENDING', command: 'sort-desc', positionOrder: 51 }, - { divider: true, command: '', positionOrder: 52 }, - { iconCssClass: 'fa fa-filter', title: 'Remove Filter', titleKey: 'REMOVE_FILTER', command: 'clear-filter', positionOrder: 53 }, - { iconCssClass: 'fa fa-unsorted', title: 'Remove Sort', titleKey: 'REMOVE_SORT', command: 'clear-sort', positionOrder: 54 }, - { iconCssClass: 'fa fa-times', titleKey: 'HIDE_COLUMN', command: 'hide', positionOrder: 55, title: 'Hide Column' }, - ] - } - }]); - }); - - it('should call the resetCellMenuTranslations and have all cell menu options translated', () => { - const mockColumns: Column[] = [{ - id: 'field1', field: 'field1', width: 100, - cellMenu: { - optionTitleKey: 'OPTIONS_LIST', - optionItems: [ - { iconCssClass: 'fa fa-sort-asc', title: 'Trier par ordre croissant', titleKey: 'SORT_ASCENDING', option: 'sort-asc', positionOrder: 50 }, - { iconCssClass: 'fa fa-sort-desc', title: 'Trier par ordre décroissant', titleKey: 'SORT_DESCENDING', option: 'sort-desc', positionOrder: 51 }, - 'divider', - { iconCssClass: 'fa fa-filter', title: 'Supprimer le filtre', titleKey: 'REMOVE_FILTER', option: 'clear-filter', positionOrder: 53 }, - { iconCssClass: 'fa fa-unsorted', title: 'Supprimer le tri', titleKey: 'REMOVE_SORT', option: 'clear-sort', positionOrder: 54 }, - { iconCssClass: 'fa fa-times', option: 'hide', titleKey: 'HIDE_COLUMN', positionOrder: 55, title: 'Cacher la colonne' }, - ] - } - }]; - jest.spyOn(SharedService.prototype, 'allColumns', 'get').mockReturnValue(mockColumns); - - translateService.use('en'); - extension.translateCellMenu(); - - expect(mockColumns).toEqual([{ - id: 'field1', field: 'field1', width: 100, - cellMenu: { - optionTitle: 'Options List', - optionTitleKey: 'OPTIONS_LIST', - optionItems: [ - { iconCssClass: 'fa fa-sort-asc', title: 'Sort Ascending', titleKey: 'SORT_ASCENDING', option: 'sort-asc', positionOrder: 50 }, - { iconCssClass: 'fa fa-sort-desc', title: 'Sort Descending', titleKey: 'SORT_DESCENDING', option: 'sort-desc', positionOrder: 51 }, - 'divider', - { iconCssClass: 'fa fa-filter', title: 'Remove Filter', titleKey: 'REMOVE_FILTER', option: 'clear-filter', positionOrder: 53 }, - { iconCssClass: 'fa fa-unsorted', title: 'Remove Sort', titleKey: 'REMOVE_SORT', option: 'clear-sort', positionOrder: 54 }, - { iconCssClass: 'fa fa-times', titleKey: 'HIDE_COLUMN', option: 'hide', positionOrder: 55, title: 'Hide Column' }, - ] - } - }]); - }); - }); - - describe('sortMenuItems method', () => { - it('should sort the columns by their "positionOrder"', () => { - const mockColumns: Column[] = [{ - id: 'field1', field: 'field1', width: 100, - cellMenu: { - commandTitle: 'Commands List', - commandItems: [ - { iconCssClass: 'fa fa-unsorted', title: 'Supprimer le tri', command: 'clear-sort', positionOrder: 54 }, - { iconCssClass: 'fa fa-filter', title: 'Supprimer le filtre', command: 'clear-filter', positionOrder: 53 }, - { iconCssClass: 'fa fa-sort-asc', title: 'Trier par ordre croissant', command: 'sort-asc', positionOrder: 50 }, - { iconCssClass: 'fa fa-sort-desc', title: 'Trier par ordre décroissant', command: 'sort-desc', positionOrder: 51 }, - { divider: true, command: '', positionOrder: 52 }, - { iconCssClass: 'fa fa-times', command: 'hide', positionOrder: 55, title: 'Cacher la colonne' }, - ], - optionTitle: 'Options List', - optionItems: [ - { iconCssClass: 'fa fa-arrow-right', title: 'Option 2', option: 2, positionOrder: 51 }, - { divider: true, option: '', positionOrder: 52 }, - { iconCssClass: 'fa fa-circle-question', title: 'Option 1', option: 1, positionOrder: 50 }, - { iconCssClass: 'fa fa-filter', title: 'Option 3', option: 3, positionOrder: 55 }, - { iconCssClass: 'fa fa-unsorted', title: 'Option 4', option: 4, positionOrder: 54 }, - ] - } - }]; - - extension.sortMenuItems(mockColumns); - - expect(mockColumns).toEqual([{ - id: 'field1', field: 'field1', width: 100, - cellMenu: { - commandTitle: 'Commands List', - commandItems: [ - { iconCssClass: 'fa fa-sort-asc', title: 'Trier par ordre croissant', command: 'sort-asc', positionOrder: 50 }, - { iconCssClass: 'fa fa-sort-desc', title: 'Trier par ordre décroissant', command: 'sort-desc', positionOrder: 51 }, - { divider: true, command: '', positionOrder: 52 }, - { iconCssClass: 'fa fa-filter', title: 'Supprimer le filtre', command: 'clear-filter', positionOrder: 53 }, - { iconCssClass: 'fa fa-unsorted', title: 'Supprimer le tri', command: 'clear-sort', positionOrder: 54 }, - { iconCssClass: 'fa fa-times', command: 'hide', positionOrder: 55, title: 'Cacher la colonne' }, - ], - optionTitle: 'Options List', - optionItems: [ - { iconCssClass: 'fa fa-circle-question', title: 'Option 1', option: 1, positionOrder: 50 }, - { iconCssClass: 'fa fa-arrow-right', title: 'Option 2', option: 2, positionOrder: 51 }, - { divider: true, option: '', positionOrder: 52 }, - { iconCssClass: 'fa fa-unsorted', title: 'Option 4', option: 4, positionOrder: 54 }, - { iconCssClass: 'fa fa-filter', title: 'Option 3', option: 3, positionOrder: 55 }, - ] - } - }]); - }); - }); - - describe('without Translate Service', () => { - beforeEach(() => { - translateService = undefined as any; - extension = new CellMenuExtension({} as ExtensionUtility, { gridOptions: { enableTranslate: true } } as SharedService, translateService); - }); - - it('should throw an error if "enableTranslate" is set but the Translate Service is null', () => { - expect(() => extension.register()).toThrowError('[Slickgrid-Universal] requires a Translate Service to be installed and configured'); - }); - }); - }); -}); diff --git a/packages/common/src/extensions/cellMenuExtension.ts b/packages/common/src/extensions/cellMenuExtension.ts deleted file mode 100644 index 810a75f2e..000000000 --- a/packages/common/src/extensions/cellMenuExtension.ts +++ /dev/null @@ -1,195 +0,0 @@ -import 'slickgrid/plugins/slick.cellmenu'; - -import { Constants } from '../constants'; -import { - CellMenu, - CellMenuOption, - Column, - Extension, - GetSlickEventType, - MenuCommandItem, - MenuOptionItem, - Locale, - SlickCellMenu, - SlickEventHandler, - SlickNamespace, -} from '../interfaces/index'; -import { ExtensionUtility } from './extensionUtility'; -import { SharedService } from '../services/shared.service'; -import { TranslaterService } from '../services'; - -// using external non-typed js libraries -declare const Slick: SlickNamespace; - -export class CellMenuExtension implements Extension { - private _addon: SlickCellMenu | null = null; - private _cellMenuOptions: CellMenu | null = null; - private _eventHandler: SlickEventHandler; - private _locales!: Locale; - - constructor( - private readonly extensionUtility: ExtensionUtility, - private readonly sharedService: SharedService, - private readonly translaterService?: TranslaterService, - ) { - this._eventHandler = new Slick.EventHandler(); - } - - get eventHandler(): SlickEventHandler { - return this._eventHandler; - } - - dispose() { - // unsubscribe all SlickGrid events - this._eventHandler.unsubscribeAll(); - - if (this._addon && this._addon.destroy) { - this._addon.destroy(); - } - this.extensionUtility.nullifyFunctionNameStartingWithOn(this._cellMenuOptions); - this._addon = null; - this._cellMenuOptions = null; - } - - /** Get the instance of the SlickGrid addon (control or plugin). */ - getAddonInstance(): SlickCellMenu | null { - return this._addon; - } - - /** Register the 3rd party addon (plugin) */ - register(): SlickCellMenu | null { - if (this.sharedService.gridOptions && this.sharedService.gridOptions.enableTranslate && (!this.translaterService || !this.translaterService.translate)) { - throw new Error('[Slickgrid-Universal] requires a Translate Service to be installed and configured when the grid option "enableTranslate" is enabled.'); - } - - if (this.sharedService && this.sharedService.slickGrid && this.sharedService.gridOptions) { - const cellMenu = this.sharedService.gridOptions.cellMenu; - // get locales provided by user in main file or else use default English locales via the Constants - this._locales = this.sharedService.gridOptions && this.sharedService.gridOptions.locales || Constants.locales; - - this._cellMenuOptions = { ...this.getDefaultCellMenuOptions(), ...this.sharedService.gridOptions.cellMenu }; - this.sharedService.gridOptions.cellMenu = this._cellMenuOptions; - - // translate the item keys when necessary - if (this.sharedService.gridOptions.enableTranslate) { - this.translateCellMenu(); - } - - // sort all menu items by their position order when defined - this.sortMenuItems(this.sharedService.allColumns); - - this._addon = new Slick.Plugins.CellMenu(this._cellMenuOptions); - if (this._addon) { - this.sharedService.slickGrid.registerPlugin(this._addon); - } - - // hook all events - if (this.sharedService.slickGrid && this._cellMenuOptions) { - if (this._addon && this._cellMenuOptions.onExtensionRegistered) { - this._cellMenuOptions.onExtensionRegistered(this._addon); - } - if (cellMenu && typeof cellMenu.onCommand === 'function') { - const onCommandHandler = this._addon.onCommand; - (this._eventHandler as SlickEventHandler>).subscribe(onCommandHandler, (event, args) => { - if (cellMenu.onCommand) { - cellMenu.onCommand(event, args); - } - }); - } - if (cellMenu && typeof cellMenu.onOptionSelected === 'function') { - const onOptionSelectedHandler = this._addon.onOptionSelected; - (this._eventHandler as SlickEventHandler>).subscribe(onOptionSelectedHandler, (event, args) => { - if (cellMenu.onOptionSelected) { - cellMenu.onOptionSelected(event, args); - } - }); - } - if (cellMenu && typeof cellMenu.onBeforeMenuShow === 'function') { - const onBeforeMenuShowHandler = this._addon.onBeforeMenuShow; - (this._eventHandler as SlickEventHandler>).subscribe(onBeforeMenuShowHandler, (event, args) => { - if (cellMenu.onBeforeMenuShow) { - cellMenu.onBeforeMenuShow(event, args); - } - }); - } - if (cellMenu && typeof cellMenu.onBeforeMenuClose === 'function') { - const onBeforeMenuCloseHandler = this._addon.onBeforeMenuClose; - (this._eventHandler as SlickEventHandler>).subscribe(onBeforeMenuCloseHandler, (event, args) => { - if (cellMenu.onBeforeMenuClose) { - cellMenu.onBeforeMenuClose(event, args); - } - }); - } - if (cellMenu && typeof cellMenu.onAfterMenuShow === 'function') { - const onAfterMenuShowHandler = this._addon.onAfterMenuShow; - (this._eventHandler as SlickEventHandler>).subscribe(onAfterMenuShowHandler, (event, args) => { - if (cellMenu.onAfterMenuShow) { - cellMenu.onAfterMenuShow(event, args); - } - }); - } - } - return this._addon; - } - return null; - } - - /** Translate the Cell Menu titles, we need to loop through all column definition to re-translate them */ - translateCellMenu() { - if (this.sharedService.gridOptions?.cellMenu) { - this.resetMenuTranslations(this.sharedService.allColumns); - } - } - - /** - * @return default Action Cell Menu options - */ - private getDefaultCellMenuOptions(): CellMenuOption { - return { - width: 180, - }; - } - - /** - * Reset all the internal Menu options which have text to translate - * @param grid menu object - */ - private resetMenuTranslations(columnDefinitions: Column[]) { - const gridOptions = this.sharedService && this.sharedService.gridOptions; - - if (gridOptions && gridOptions.enableTranslate) { - columnDefinitions.forEach((columnDef: Column) => { - if (columnDef && columnDef.cellMenu && (Array.isArray(columnDef.cellMenu.commandItems) || Array.isArray(columnDef.cellMenu.optionItems))) { - // get both items list - const columnCellMenuCommandItems: Array = columnDef.cellMenu.commandItems || []; - const columnCellMenuOptionItems: Array = columnDef.cellMenu.optionItems || []; - - // translate their titles only if they have a titleKey defined - if (columnDef.cellMenu.commandTitleKey) { - columnDef.cellMenu.commandTitle = this.translaterService && this.translaterService.getCurrentLanguage && this.translaterService.translate && this.translaterService.translate(columnDef.cellMenu.commandTitleKey) || this._locales && this._locales.TEXT_COMMANDS || columnDef.cellMenu.commandTitle; - } - if (columnDef.cellMenu.optionTitleKey) { - columnDef.cellMenu.optionTitle = this.translaterService && this.translaterService.getCurrentLanguage && this.translaterService.translate && this.translaterService.translate(columnDef.cellMenu.optionTitleKey) || columnDef.cellMenu.optionTitle; - } - - // translate both command/option items (whichever is provided) - this.extensionUtility.translateItems(columnCellMenuCommandItems, 'titleKey', 'title'); - this.extensionUtility.translateItems(columnCellMenuOptionItems, 'titleKey', 'title'); - } - }); - } - } - - sortMenuItems(columnDefinitions: Column[]) { - columnDefinitions.forEach((columnDef: Column) => { - if (columnDef && columnDef.cellMenu && columnDef.cellMenu.commandItems) { - // get both items list - const columnCellMenuCommandItems: Array = columnDef.cellMenu.commandItems || []; - const columnCellMenuOptionItems: Array = columnDef.cellMenu.optionItems || []; - - this.extensionUtility.sortItems(columnCellMenuCommandItems, 'positionOrder'); - this.extensionUtility.sortItems(columnCellMenuOptionItems, 'positionOrder'); - } - }); - } -} diff --git a/packages/common/src/extensions/extensionCommonUtils.ts b/packages/common/src/extensions/extensionCommonUtils.ts index 07f4e3c2d..45203c394 100644 --- a/packages/common/src/extensions/extensionCommonUtils.ts +++ b/packages/common/src/extensions/extensionCommonUtils.ts @@ -79,13 +79,13 @@ export function handleColumnPickerItemClick(this: ColumnPickerControl | GridMenu // execute user callback when defined context.pubSubService.publish(`${controlType}:onColumnsChanged`, callbackArgs); - if (typeof context.controlOptions?.onColumnsChanged === 'function') { - context.controlOptions.onColumnsChanged(event, callbackArgs); + if (typeof context.addonOptions?.onColumnsChanged === 'function') { + context.addonOptions.onColumnsChanged(event, callbackArgs); } } } -export function populateColumnPicker(this: ColumnPickerControl | GridMenuControl, controlOptions: ColumnPickerOption | GridMenuOption) { +export function populateColumnPicker(this: ColumnPickerControl | GridMenuControl, addonOptions: ColumnPickerOption | GridMenuOption) { const context: any = this; const menuPrefix = context instanceof GridMenuControl ? 'gridmenu-' : ''; @@ -105,7 +105,7 @@ export function populateColumnPicker(this: ColumnPickerControl | GridMenuControl columnLiElm.appendChild(colInputElm); context._columnCheckboxes.push(colInputElm); - const headerColumnValueExtractorFn = typeof controlOptions?.headerColumnValueExtractor === 'function' ? controlOptions.headerColumnValueExtractor : context._defaults.headerColumnValueExtractor; + const headerColumnValueExtractorFn = typeof addonOptions?.headerColumnValueExtractor === 'function' ? addonOptions.headerColumnValueExtractor : context._defaults.headerColumnValueExtractor; const columnLabel = headerColumnValueExtractorFn!(column, context.gridOptions); const labelElm = document.createElement('label'); @@ -115,12 +115,12 @@ export function populateColumnPicker(this: ColumnPickerControl | GridMenuControl context._listElm.appendChild(columnLiElm); } - if (!controlOptions.hideForceFitButton || !controlOptions.hideSyncResizeButton) { + if (!addonOptions.hideForceFitButton || !addonOptions.hideSyncResizeButton) { context._listElm.appendChild(document.createElement('hr')); } - if (!(controlOptions?.hideForceFitButton)) { - const forceFitTitle = controlOptions?.forceFitTitle; + if (!(addonOptions?.hideForceFitButton)) { + const forceFitTitle = addonOptions?.forceFitTitle; const fitInputElm = document.createElement('input'); fitInputElm.type = 'checkbox'; @@ -140,8 +140,8 @@ export function populateColumnPicker(this: ColumnPickerControl | GridMenuControl context._listElm.appendChild(fitLiElm); } - if (!(controlOptions?.hideSyncResizeButton)) { - const syncResizeTitle = (controlOptions?.syncResizeTitle) || controlOptions.syncResizeTitle; + if (!(addonOptions?.hideSyncResizeButton)) { + const syncResizeTitle = (addonOptions?.syncResizeTitle) || addonOptions.syncResizeTitle; const labelElm = document.createElement('label'); labelElm.htmlFor = `${context._gridUid}-${menuPrefix}colpicker-syncresize`; labelElm.textContent = syncResizeTitle ?? ''; diff --git a/packages/common/src/extensions/extensionUtility.ts b/packages/common/src/extensions/extensionUtility.ts index 3cd94e844..4ffba3ec8 100644 --- a/packages/common/src/extensions/extensionUtility.ts +++ b/packages/common/src/extensions/extensionUtility.ts @@ -1,5 +1,5 @@ import { Constants } from '../constants'; -import { Column, GridOption, Locale, MenuCommandItem } from '../interfaces'; +import { Column, GridOption, Locale, MenuCommandItem, MenuOptionItem, } from '../interfaces'; import { BackendUtilityService } from '../services/backendUtility.service'; import { SharedService } from '../services/shared.service'; import { TranslaterService } from '../services/translater.service'; @@ -111,7 +111,7 @@ export class ExtensionUtility { /** Run the Override function when it exists, if it returns True then it is usable/visible */ runOverrideFunctionWhenExists(overrideFn: ((args: any) => boolean) | undefined, args: T): boolean { if (typeof overrideFn === 'function') { - return overrideFn.call(this, args); + return !!(overrideFn.call(this, args)); } return true; } @@ -149,7 +149,7 @@ export class ExtensionUtility { * @param {Array} items - Menu Command Items array * @param {Object} gridOptions - Grid Options */ - translateMenuItemsFromTitleKey(items: Array) { + translateMenuItemsFromTitleKey(items: Array) { const translationPrefix = getTranslationPrefix(this.sharedService.gridOptions); for (const item of items) { if (typeof item === 'object' && item.titleKey) { diff --git a/packages/common/src/extensions/index.ts b/packages/common/src/extensions/index.ts index 897cbd824..96fa2de85 100644 --- a/packages/common/src/extensions/index.ts +++ b/packages/common/src/extensions/index.ts @@ -1,5 +1,4 @@ export * from './cellExternalCopyManagerExtension'; -export * from './cellMenuExtension'; export * from './checkboxSelectorExtension'; export * from './contextMenuExtension'; export * from './draggableGroupingExtension'; diff --git a/packages/common/src/interfaces/cellMenu.interface.ts b/packages/common/src/interfaces/cellMenu.interface.ts index 8aa2cb63c..ef96fdf68 100644 --- a/packages/common/src/interfaces/cellMenu.interface.ts +++ b/packages/common/src/interfaces/cellMenu.interface.ts @@ -1,11 +1,10 @@ import { CellMenuOption, MenuCommandItemCallbackArgs, + MenuFromCellCallbackArgs, MenuOptionItemCallbackArgs, - SlickCellMenu, - SlickEventData, - SlickGrid, } from './index'; +import { CellMenuPlugin } from '../plugins/cellMenu.plugin'; export interface CellMenu extends CellMenuOption { @@ -13,20 +12,20 @@ export interface CellMenu extends CellMenuOption { // Events /** Fired after extension (control) is registered by SlickGrid */ - onExtensionRegistered?: (plugin: SlickCellMenu) => void; + onExtensionRegistered?: (plugin: CellMenuPlugin) => void; /** SlickGrid Event fired After the menu is shown. */ - onAfterMenuShow?: (e: SlickEventData, args: { cell: number; row: number; grid: SlickGrid; }) => void; + onAfterMenuShow?: (e: Event, args: MenuFromCellCallbackArgs) => boolean | void; /** SlickGrid Event fired Before the menu is shown. */ - onBeforeMenuShow?: (e: SlickEventData, args: { cell: number; row: number; grid: SlickGrid; }) => void; + onBeforeMenuShow?: (e: Event, args: MenuFromCellCallbackArgs) => boolean | void; /** SlickGrid Event fired when the menu is closing. */ - onBeforeMenuClose?: (e: SlickEventData, args: { cell: number; row: number; grid: SlickGrid; menu: HTMLElement; }) => void; + onBeforeMenuClose?: (e: Event, args: MenuFromCellCallbackArgs) => boolean | void; /** SlickGrid Event fired on menu option clicked from the Command items list */ - onCommand?: (e: SlickEventData, args: MenuCommandItemCallbackArgs) => void; + onCommand?: (e: Event, args: MenuCommandItemCallbackArgs) => void; /** SlickGrid Event fired on menu option selected from the Option items list. */ - onOptionSelected?: (e: SlickEventData, args: MenuOptionItemCallbackArgs) => void; + onOptionSelected?: (e: Event, args: MenuOptionItemCallbackArgs) => void; } diff --git a/packages/common/src/interfaces/cellMenuOption.interface.ts b/packages/common/src/interfaces/cellMenuOption.interface.ts index 4a000a4e0..51486980d 100644 --- a/packages/common/src/interfaces/cellMenuOption.interface.ts +++ b/packages/common/src/interfaces/cellMenuOption.interface.ts @@ -5,6 +5,12 @@ import { } from './index'; export interface CellMenuOption { + /** Defaults to "bottom", user can optionally force the Cell Menu drop to be aligned to the top or bottom. */ + alignDropDirection?: 'top' | 'bottom'; + + /** Defaults to "right", user can optionally force the Cell Menu drop to be aligned to the left or right. */ + alignDropSide?: 'left' | 'right'; + /** Defaults to true, Auto-align dropup or dropdown menu to the left or right depending on grid viewport available space */ autoAdjustDrop?: boolean; diff --git a/packages/common/src/interfaces/headerMenu.interface.ts b/packages/common/src/interfaces/headerMenu.interface.ts index a780c16dd..3eed83a84 100644 --- a/packages/common/src/interfaces/headerMenu.interface.ts +++ b/packages/common/src/interfaces/headerMenu.interface.ts @@ -1,5 +1,5 @@ import { MenuCommandItem } from '..'; -import { HeaderMenuPlugin } from '../plugins'; +import { HeaderMenuPlugin } from '../plugins/headerMenu.plugin'; import { Column, HeaderMenuOption, diff --git a/packages/common/src/interfaces/index.ts b/packages/common/src/interfaces/index.ts index c354b124c..187d92bad 100644 --- a/packages/common/src/interfaces/index.ts +++ b/packages/common/src/interfaces/index.ts @@ -105,6 +105,7 @@ export * from './longTextEditorOption.interface'; export * from './menuCallbackArgs.interface'; export * from './menuCommandItem.interface'; export * from './menuCommandItemCallbackArgs.interface'; +export * from './menuFromCellCallbackArgs.interface'; export * from './menuItem.interface'; export * from './menuOptionItem.interface'; export * from './menuOptionItemCallbackArgs.interface'; diff --git a/packages/common/src/interfaces/menuFromCellCallbackArgs.interface.ts b/packages/common/src/interfaces/menuFromCellCallbackArgs.interface.ts new file mode 100644 index 000000000..4bc3758bd --- /dev/null +++ b/packages/common/src/interfaces/menuFromCellCallbackArgs.interface.ts @@ -0,0 +1,18 @@ +import { Column } from './column.interface'; +import { SlickGrid } from './slickGrid.interface'; + +export interface MenuFromCellCallbackArgs { + /** Grid cell/column index */ + cell: number; + + /** Grid row index */ + row: number; + + /** Reference to the grid. */ + grid: SlickGrid; +} + +export interface MenuFromCellWithColumnCallbackArgs extends MenuFromCellCallbackArgs { + /** Cell Column definition */ + column?: Column; +} \ No newline at end of file diff --git a/packages/common/src/interfaces/slickGrid.interface.ts b/packages/common/src/interfaces/slickGrid.interface.ts index 960b91084..513e9bc18 100644 --- a/packages/common/src/interfaces/slickGrid.interface.ts +++ b/packages/common/src/interfaces/slickGrid.interface.ts @@ -123,20 +123,20 @@ export interface SlickGrid { getCellCssStyles(key: string): any; /** Returns the active cell editor. If there is no actively edited cell, null is returned. */ - getCellEditor(): Editor; + getCellEditor(): Editor | null; /** * Returns a hash containing row and cell indexes from a standard W3C/jQuery event. * @param e A standard W3C/jQuery event. */ - getCellFromEvent(e: Event): any; + getCellFromEvent(e: Event): { cell: number; row: number; } | null; /** * Returns a hash containing row and cell indexes. Coordinates are relative to the top left corner of the grid beginning with the first row (not including the column headers). * @param x An x coordinate. * @param y A y coordinate. */ - getCellFromPoint(x: number, y: number): any; + getCellFromPoint(x: number, y: number): { cell: number; row: number; }; /** * Returns a DOM element containing a cell at a given row and cell. diff --git a/packages/common/src/plugins/__tests__/autoTooltip.plugin.spec.ts b/packages/common/src/plugins/__tests__/autoTooltip.plugin.spec.ts index c778f980c..2e20787f4 100644 --- a/packages/common/src/plugins/__tests__/autoTooltip.plugin.spec.ts +++ b/packages/common/src/plugins/__tests__/autoTooltip.plugin.spec.ts @@ -4,7 +4,7 @@ import { AutoTooltipPlugin } from '../autoTooltip.plugin'; declare const Slick: SlickNamespace; -let pluginOptions: AutoTooltipOption = { +let addonOptions: AutoTooltipOption = { enableForCells: true, enableForHeaderCells: true, maxToolTipLength: 20, @@ -33,7 +33,7 @@ describe('AutoTooltip Plugin', () => { let plugin: AutoTooltipPlugin; beforeEach(() => { - plugin = new AutoTooltipPlugin(pluginOptions); + plugin = new AutoTooltipPlugin(addonOptions); }); it('should create the plugin', () => { @@ -44,7 +44,7 @@ describe('AutoTooltip Plugin', () => { it('should use default options when instantiating the plugin without passing any arguments', () => { plugin.init(gridStub); - expect(plugin.options).toEqual({ + expect(plugin.addonOptions).toEqual({ enableForCells: true, enableForHeaderCells: true, maxToolTipLength: 20, diff --git a/packages/common/src/plugins/__tests__/cellMenu.plugin.spec.ts b/packages/common/src/plugins/__tests__/cellMenu.plugin.spec.ts new file mode 100644 index 000000000..c7531db51 --- /dev/null +++ b/packages/common/src/plugins/__tests__/cellMenu.plugin.spec.ts @@ -0,0 +1,976 @@ +import { CellMenu, Column, ColumnSort, ElementPosition, GridOption, MenuCommandItem, MenuOptionItem, SlickDataView, SlickEventData, SlickGrid, SlickNamespace, } from '../../interfaces/index'; +import { CellMenuPlugin } from '../cellMenu.plugin'; +import { BackendUtilityService, deepCopy, PubSubService, SharedService, } from '../../services'; +import { ExtensionUtility } from '../../extensions/extensionUtility'; +import { TranslateServiceStub } from '../../../../../test/translateServiceStub'; + +declare const Slick: SlickNamespace; + +const removeExtraSpaces = (textS) => `${textS}`.replace(/[\n\r]\s+/g, ''); + +const gridOptionsMock = { + enableAutoSizeColumns: true, + enableColumnResizeOnDoubleClick: true, + enableCellMenu: true, + enableTranslate: true, + backendServiceApi: { + service: { + buildQuery: jest.fn(), + }, + internalPostProcess: jest.fn(), + preProcess: jest.fn(), + process: jest.fn(), + postProcess: jest.fn(), + }, + cellMenu: { + autoAdjustDrop: true, + autoAlignSide: true, + autoAdjustDropOffset: 0, + autoAlignSideOffset: 0, + hideMenuOnScroll: true, + maxHeight: 'none', + width: 175, + onExtensionRegistered: jest.fn(), + onCommand: () => { }, + onAfterMenuShow: () => { }, + onBeforeMenuShow: () => { }, + onBeforeMenuClose: () => { }, + onOptionSelected: () => { }, + }, + multiColumnSort: true, + pagination: { + totalItems: 0 + }, + showHeaderRow: false, + showTopPanel: false, + showPreHeaderPanel: false +} as unknown as GridOption; + +const getEditorLockMock = { + commitCurrentEdit: jest.fn(), +}; + +const gridStub = { + autosizeColumns: jest.fn(), + getCellNode: jest.fn(), + getCellFromEvent: jest.fn(), + getColumns: jest.fn(), + getColumnIndex: jest.fn(), + getContainerNode: jest.fn(), + getDataItem: jest.fn(), + getEditorLock: () => getEditorLockMock, + getGridPosition: jest.fn(), + getOptions: () => gridOptionsMock, + getUID: () => 'slickgrid12345', + registerPlugin: jest.fn(), + setColumns: jest.fn(), + setOptions: jest.fn(), + setSortColumns: jest.fn(), + updateColumnHeader: jest.fn(), + onClick: new Slick.Event(), + onScroll: new Slick.Event(), + onSort: new Slick.Event(), +} as unknown as SlickGrid; + +const dataViewStub = { + refresh: jest.fn(), +} as unknown as SlickDataView; + +const pubSubServiceStub = { + publish: jest.fn(), + subscribe: jest.fn(), + unsubscribe: jest.fn(), + unsubscribeAll: jest.fn(), +} as PubSubService; + +const commandItemsMock = [ + { command: 'command2', title: 'Command 2', positionOrder: 62, }, + { command: 'command1', title: 'Command 1', cssClass: 'orange', positionOrder: 61 }, + { divider: true, command: '', positionOrder: 63 }, + { + command: 'delete-row', title: 'Delete Row', positionOrder: 64, + iconCssClass: 'mdi mdi-close', cssClass: 'red', textCssClass: 'bold', + }, + 'divider', +] as MenuCommandItem[]; +const optionItemsMock = [ + { option: 'option2', title: 'Option 2', positionOrder: 62, }, + { option: 'option1', title: 'Option 1', cssClass: 'purple', positionOrder: 61 }, + { divider: true, option: '', positionOrder: 63 }, + { + option: 'delete-row', title: 'Delete Row', positionOrder: 64, + iconCssClass: 'mdi mdi-checked', cssClass: 'sky', textCssClass: 'underline', + }, + 'divider', +] as MenuOptionItem[]; +const cellMenuMockWithCommands = { commandItems: deepCopy(commandItemsMock) } as CellMenu; +const cellMenuMockWithOptions = { optionItems: deepCopy(optionItemsMock) } as CellMenu; + +const columnsMock: Column[] = [ + { id: 'firstName', field: 'firstName', name: 'First Name', width: 100, }, + { id: 'lastName', field: 'lastName', name: 'Last Name', width: 75, nameKey: 'LAST_NAME', sortable: true, filterable: true }, + { id: 'age', field: 'age', name: 'Age', width: 50, }, + { id: 'action', field: 'action', name: 'Action', width: 50, cellMenu: cellMenuMockWithCommands, }, + { id: 'action2', field: 'action2', name: 'Action2', width: 50, cellMenu: cellMenuMockWithOptions, }, +]; + +describe('CellMenu Plugin', () => { + const consoleWarnSpy = jest.spyOn(global.console, 'warn').mockReturnValue(); + let backendUtilityService: BackendUtilityService; + let extensionUtility: ExtensionUtility; + let translateService: TranslateServiceStub; + let plugin: CellMenuPlugin; + let sharedService: SharedService; + + beforeEach(() => { + backendUtilityService = new BackendUtilityService(); + sharedService = new SharedService(); + translateService = new TranslateServiceStub(); + extensionUtility = new ExtensionUtility(sharedService, backendUtilityService, translateService); + jest.spyOn(SharedService.prototype, 'slickGrid', 'get').mockReturnValue(gridStub); + jest.spyOn(SharedService.prototype, 'gridOptions', 'get').mockReturnValue(gridOptionsMock); + jest.spyOn(SharedService.prototype, 'columnDefinitions', 'get').mockReturnValue(columnsMock); + jest.spyOn(SharedService.prototype, 'allColumns', 'get').mockReturnValue(columnsMock); + jest.spyOn(SharedService.prototype, 'visibleColumns', 'get').mockReturnValue(columnsMock.slice(0, 2)); + jest.spyOn(gridStub, 'getColumns').mockReturnValue(columnsMock); + plugin = new CellMenuPlugin(extensionUtility, pubSubServiceStub, sharedService); + }); + + afterEach(() => { + plugin?.dispose(); + }); + + it('should create the plugin', () => { + expect(plugin).toBeTruthy(); + expect(plugin.eventHandler).toBeTruthy(); + }); + + it('should use default options when instantiating the plugin without passing any arguments', () => { + plugin.init(); + + expect(plugin.addonOptions).toEqual({ + autoAdjustDrop: true, // dropup/dropdown + autoAlignSide: true, // left/right + autoAdjustDropOffset: 0, + autoAlignSideOffset: 0, + hideMenuOnScroll: true, + maxHeight: 'none', + width: 'auto', + }); + }); + + it('should be able to change Cell Menu options', () => { + plugin.init(); + plugin.addonOptions = { + commandTitle: 'test', + autoAdjustDrop: true, + }; + + expect(plugin.addonOptions).toEqual({ + commandTitle: 'test', + autoAdjustDrop: true, + }); + }); + + describe('plugins - Cell Menu', () => { + let gridContainerDiv: HTMLDivElement; + let cellMenuDiv: HTMLDivElement; + let eventData: any; + let slickCellElm: HTMLDivElement; + + beforeEach(() => { + slickCellElm = document.createElement('div'); + slickCellElm.className = 'slick-cell'; + eventData = { ...new Slick.EventData(), preventDefault: jest.fn() }; + eventData.target = slickCellElm; + + jest.spyOn(SharedService.prototype, 'slickGrid', 'get').mockReturnValue(gridStub); + columnsMock[3].cellMenu.commandItems = deepCopy(commandItemsMock); + delete (columnsMock[3].cellMenu.commandItems[1] as MenuCommandItem).action; + delete (columnsMock[3].cellMenu.commandItems[1] as MenuCommandItem).itemVisibilityOverride; + delete (columnsMock[3].cellMenu.commandItems[1] as MenuCommandItem).itemUsabilityOverride; + cellMenuDiv = document.createElement('div'); + cellMenuDiv.className = 'slick-header-column'; + gridContainerDiv = document.createElement('div'); + gridContainerDiv.className = 'slickgrid-container'; + jest.spyOn(gridStub, 'getContainerNode').mockReturnValue(gridContainerDiv); + jest.spyOn(gridStub, 'getGridPosition').mockReturnValue({ top: 10, bottom: 5, left: 15, right: 22, width: 225 } as ElementPosition); + jest.spyOn(gridStub, 'getCellFromEvent').mockReturnValue({ cell: 3, row: 1 }); + jest.spyOn(gridStub, 'getDataItem').mockReturnValue({ firstName: 'John', lastName: 'Doe', age: 33 }); + }); + + afterEach(() => { + plugin.dispose(); + jest.clearAllMocks(); + }); + + it('should open the Cell Menu and then expect it to hide when clicking anywhere in the DOM body', () => { + const hideMenuSpy = jest.spyOn(plugin, 'hideMenu'); + const closeSpy = jest.spyOn(plugin, 'closeMenu'); + jest.spyOn(SharedService.prototype, 'gridOptions', 'get').mockReturnValue({ ...gridOptionsMock, enableSorting: true, }); + + plugin.dispose(); + plugin.init(); + gridStub.onClick.notify(null, eventData, gridStub); + + let cellMenuElm = document.body.querySelector('.slick-cell-menu.slickgrid12345') as HTMLDivElement; + expect(cellMenuElm).toBeTruthy(); + + document.body.dispatchEvent(new Event('mousedown', { bubbles: true })); + cellMenuElm = document.body.querySelector('.slick-cell-menu.slickgrid12345') as HTMLDivElement; + + expect(cellMenuElm).toBeNull(); + expect(closeSpy).toHaveBeenCalled(); + expect(hideMenuSpy).toHaveBeenCalled(); + }); + + it('should "autoAlignSide" and expect menu to aligned left with a calculate offset when showing menu', () => { + jest.spyOn(gridStub, 'getGridPosition').mockReturnValue({ top: 10, bottom: 5, left: 15, right: 22, width: 225 } as ElementPosition); + plugin.dispose(); + plugin.init({ autoAdjustDrop: true, autoAlignSide: true, alignDropDirection: 'top', alignDropSide: 'left' }); + + const actionBtnElm = document.createElement('button'); + slickCellElm.appendChild(actionBtnElm); + const eventDataCopy = deepCopy(eventData); + Object.defineProperty(actionBtnElm, 'clientWidth', { writable: true, configurable: true, value: 275 }); + Object.defineProperty(slickCellElm, 'clientWidth', { writable: true, configurable: true, value: 300 }); + gridStub.onClick.notify({ cell: 3, row: 1, grid: gridStub }, eventDataCopy as any, gridStub); + + let cellMenuElm = document.body.querySelector('.slick-cell-menu.slickgrid12345') as HTMLDivElement; + + Object.defineProperty(cellMenuElm, 'clientHeight', { writable: true, configurable: true, value: 300 }); + Object.defineProperty(plugin.menuElement, 'clientWidth', { writable: true, configurable: true, value: 350 }); + // Object.defineProperty(eventDataCopy, 'target', { writable: true, configurable: true, value: cellMenuElm }); + + + // plugin.showMenu(clickEvent, columnsMock[0], columnsMock[0].header.menu); + gridStub.onClick.notify({ cell: 3, row: 1, grid: gridStub }, eventDataCopy as any, gridStub); + + expect(cellMenuElm.classList.contains('dropup')).toBeTruthy(); + expect(cellMenuElm.classList.contains('dropleft')).toBeTruthy(); + // expect(plugin.menuElement.clientWidth).toBe(275); + // expect(plugin.menuElement.style.left).toBe('75px'); + }); + + describe('with Command Items', () => { + it('should not populate and automatically return when the Cell Menu item "commandItems" array of the cell menu is undefined', () => { + plugin.dispose(); + plugin.init(); + (columnsMock[3].cellMenu.commandItems) = undefined as any; + gridStub.onClick.notify({ cell: 3, row: 1, grid: gridStub }, eventData, gridStub); + + const cellMenuElm = document.body.querySelector('.slick-cell-menu.slickgrid12345') as HTMLDivElement; + + expect(cellMenuElm).toBeNull(); + }); + + it('should create a Cell Menu to be create and show up when item visibility & usability callbacks returns true', () => { + plugin.dispose(); + plugin.init(); + (columnsMock[3].cellMenu.commandItems[1] as MenuCommandItem).itemVisibilityOverride = () => true; + (columnsMock[3].cellMenu.commandItems[1] as MenuCommandItem).itemUsabilityOverride = () => true; + gridStub.onClick.notify({ cell: 3, row: 1, grid: gridStub }, eventData, gridStub); + + const cellMenuElm = document.body.querySelector('.slick-cell-menu.slickgrid12345') as HTMLDivElement; + const commandListElm = cellMenuElm.querySelector('.slick-cell-menu-command-list') as HTMLDivElement; + + expect(cellMenuElm.classList.contains('dropdown')); + expect(cellMenuElm.classList.contains('dropright')); + expect(commandListElm.querySelectorAll('.slick-cell-menu-item').length).toBe(5); + expect(removeExtraSpaces(document.body.innerHTML)).toBe(removeExtraSpaces( + ``)); + }); + + it('should expect a Cell Menu to be created when cell is clicked with a list of commands defined but without "Command 1" when "itemVisibilityOverride" and "itemUsabilityOverride" return undefined', () => { + plugin.dispose(); + plugin.init(); + (columnsMock[3].cellMenu.commandItems[1] as MenuCommandItem).itemVisibilityOverride = () => undefined; + (columnsMock[3].cellMenu.commandItems[1] as MenuCommandItem).itemUsabilityOverride = () => undefined; + gridStub.onClick.notify({ cell: 3, row: 1, grid: gridStub }, eventData, gridStub); + + const cellMenuElm = document.body.querySelector('.slick-cell-menu.slickgrid12345') as HTMLDivElement; + const closeBtnElm = cellMenuElm.querySelector('.close') as HTMLButtonElement; + const commandListElm = cellMenuElm.querySelector('.slick-cell-menu-command-list') as HTMLDivElement; + const commandItemElm1 = commandListElm.querySelectorAll('.slick-cell-menu-item')[0] as HTMLDivElement; + const commandItemElm2 = commandListElm.querySelectorAll('.slick-cell-menu-item')[1] as HTMLDivElement; + const commandItemElm3 = commandListElm.querySelectorAll('.slick-cell-menu-item')[2] as HTMLDivElement; + const commandLabelElm1 = commandItemElm1.querySelector('.slick-cell-menu-content') as HTMLSpanElement; + const commandIconElm1 = commandItemElm1.querySelector('.slick-cell-menu-icon') as HTMLDivElement; + const commandLabelElm3 = commandItemElm3.querySelector('.slick-cell-menu-content') as HTMLSpanElement; + const commandIconElm3 = commandItemElm3.querySelector('.slick-cell-menu-icon') as HTMLDivElement; + + expect(plugin.menuElement).toBeTruthy(); + expect(closeBtnElm).toBeTruthy(); + expect(commandListElm.querySelectorAll('.slick-cell-menu-item').length).toBe(4); + expect(commandItemElm1.classList.contains('orange')).toBeTruthy(); + expect(commandIconElm1.className).toBe('slick-cell-menu-icon'); + expect(commandLabelElm1.textContent).toBe('Command 1'); + expect(commandItemElm2.classList.contains('slick-cell-menu-item-divider')).toBeTruthy(); + expect(commandItemElm2.innerHTML).toBe(''); + expect(commandIconElm3.classList.contains('mdi-close')).toBeTruthy(); + expect(commandLabelElm3.textContent).toBe('Delete Row'); + }); + + it('should expect a Cell Menu to be created when cell is clicked with a list of commands defined but without "Command 1" when "itemVisibilityOverride" and "itemUsabilityOverride" return false', () => { + plugin.dispose(); + plugin.init(); + (columnsMock[3].cellMenu.commandItems[1] as MenuCommandItem).itemVisibilityOverride = () => false; + (columnsMock[3].cellMenu.commandItems[1] as MenuCommandItem).itemUsabilityOverride = () => false; + gridStub.onClick.notify({ cell: 3, row: 1, grid: gridStub }, eventData, gridStub); + + const cellMenuElm = document.body.querySelector('.slick-cell-menu.slickgrid12345') as HTMLDivElement; + const closeBtnElm = cellMenuElm.querySelector('.close') as HTMLButtonElement; + const commandListElm = cellMenuElm.querySelector('.slick-cell-menu-command-list') as HTMLDivElement; + const commandItemElm1 = commandListElm.querySelectorAll('.slick-cell-menu-item')[0] as HTMLDivElement; + const commandLabelElm1 = commandItemElm1.querySelector('.slick-cell-menu-content') as HTMLSpanElement; + const commandIconElm1 = commandItemElm1.querySelector('.slick-cell-menu-icon') as HTMLDivElement; + + expect(closeBtnElm).toBeTruthy(); + expect(commandListElm.querySelectorAll('.slick-cell-menu-item').length).toBe(4); + expect(commandItemElm1.classList.contains('orange')).toBeTruthy(); + expect(commandIconElm1.className).toBe('slick-cell-menu-icon'); + expect(commandLabelElm1.textContent).toBe('Command 1'); + expect(document.body.innerHTML.includes('Command 2')).not.toBeTruthy(); + }); + + it('should create a Cell Menu and a 2nd button item usability callback returns false and expect button to be disabled', () => { + plugin.dispose(); + plugin.init(); + (columnsMock[3].cellMenu.commandItems[1] as MenuCommandItem).itemVisibilityOverride = () => true; + (columnsMock[3].cellMenu.commandItems[1] as MenuCommandItem).itemUsabilityOverride = () => false; + gridStub.onClick.notify({ cell: 3, row: 1, grid: gridStub }, eventData, gridStub); + + const cellMenuElm = document.body.querySelector('.slick-cell-menu.slickgrid12345') as HTMLDivElement; + const closeBtnElm = cellMenuElm.querySelector('.close') as HTMLButtonElement; + const commandListElm = cellMenuElm.querySelector('.slick-cell-menu-command-list') as HTMLDivElement; + const commandItemElm1 = commandListElm.querySelectorAll('.slick-cell-menu-item')[0] as HTMLDivElement; + const commandLabelElm1 = commandItemElm1.querySelector('.slick-cell-menu-content') as HTMLSpanElement; + const commandIconElm1 = commandItemElm1.querySelector('.slick-cell-menu-icon') as HTMLDivElement; + + expect(closeBtnElm).toBeTruthy(); + expect(commandListElm.querySelectorAll('.slick-cell-menu-item').length).toBe(5); + expect(commandItemElm1.classList.contains('orange')).toBeTruthy(); + expect(commandIconElm1.className).toBe('slick-cell-menu-icon'); + expect(commandLabelElm1.textContent).toBe('Command 1'); + expect(document.body.innerHTML.includes('Command 2')).toBeTruthy(); + }); + + it('should create a Cell Menu and a 2nd item is "disabled" and expect button to be disabled', () => { + plugin.dispose(); + plugin.init(); + (columnsMock[3].cellMenu.commandItems[1] as MenuCommandItem).disabled = true; + gridStub.onClick.notify({ cell: 3, row: 1, grid: gridStub }, eventData, gridStub); + + const cellMenuElm = document.body.querySelector('.slick-cell-menu.slickgrid12345') as HTMLDivElement; + const commandListElm = cellMenuElm.querySelector('.slick-cell-menu-command-list') as HTMLDivElement; + const commandItemElm2 = commandListElm.querySelector('[data-command="command2"]') as HTMLDivElement; + const commandContentElm2 = commandItemElm2.querySelector('.slick-cell-menu-content') as HTMLDivElement; + + expect(commandListElm.querySelectorAll('.slick-cell-menu-item').length).toBe(5); + expect(commandContentElm2.textContent).toBe('Command 2'); + expect(commandItemElm2.classList.contains('slick-cell-menu-item-disabled')).toBeTruthy(); + }); + + it('should create a Cell Menu and expect button to be disabled when command property is hidden', () => { + plugin.dispose(); + plugin.init(); + (columnsMock[3].cellMenu.commandItems[1] as MenuCommandItem).hidden = true; + gridStub.onClick.notify({ cell: 3, row: 1, grid: gridStub }, eventData, gridStub); + + const cellMenuElm = document.body.querySelector('.slick-cell-menu.slickgrid12345') as HTMLDivElement; + const commandListElm = cellMenuElm.querySelector('.slick-cell-menu-command-list') as HTMLDivElement; + const commandItemElm2 = commandListElm.querySelector('[data-command="command2"]') as HTMLDivElement; + const commandContentElm2 = commandItemElm2.querySelector('.slick-cell-menu-content') as HTMLDivElement; + + expect(commandListElm.querySelectorAll('.slick-cell-menu-item').length).toBe(5); + expect(commandContentElm2.textContent).toBe('Command 2'); + expect(commandItemElm2.classList.contains('slick-cell-menu-item-hidden')).toBeTruthy(); + }); + + it('should create a Cell Menu with an icon having a background image when property "iconImage" is filled', () => { + plugin.dispose(); + plugin.init(); + (columnsMock[3].cellMenu.commandItems[1] as MenuCommandItem).iconImage = '/images/some-image.png'; + gridStub.onClick.notify({ cell: 3, row: 1, grid: gridStub }, eventData, gridStub); + + const cellMenuElm = document.body.querySelector('.slick-cell-menu.slickgrid12345') as HTMLDivElement; + const commandListElm = cellMenuElm.querySelector('.slick-cell-menu-command-list') as HTMLDivElement; + const commandItemElm2 = commandListElm.querySelector('[data-command="command2"]') as HTMLDivElement; + const commandContentElm2 = commandItemElm2.querySelector('.slick-cell-menu-content') as HTMLDivElement; + const commandIconElm2 = commandItemElm2.querySelector('.slick-cell-menu-icon') as HTMLDivElement; + + expect(commandListElm.querySelectorAll('.slick-cell-menu-item').length).toBe(5); + expect(commandContentElm2.textContent).toBe('Command 2'); + expect(commandIconElm2.style.backgroundImage).toBe('url(/images/some-image.png)'); + expect(consoleWarnSpy).toHaveBeenCalledWith('[Slickgrid-Universal] The "iconImage" property of a Cell Menu item is now deprecated and will be removed in future version, consider using "iconCssClass" instead.'); + }); + + it('should create a Cell Menu item with "iconCssClass" and expect extra css classes added to the icon element', () => { + plugin.dispose(); + plugin.init(); + (columnsMock[3].cellMenu.commandItems[1] as MenuCommandItem).iconCssClass = 'bold red'; + gridStub.onClick.notify({ cell: 3, row: 1, grid: gridStub }, eventData, gridStub); + + const cellMenuElm = document.body.querySelector('.slick-cell-menu.slickgrid12345') as HTMLDivElement; + const commandListElm = cellMenuElm.querySelector('.slick-cell-menu-command-list') as HTMLDivElement; + const commandItemElm2 = commandListElm.querySelector('[data-command="command2"]') as HTMLDivElement; + const commandContentElm2 = commandItemElm2.querySelector('.slick-cell-menu-content') as HTMLDivElement; + const commandIconElm2 = commandItemElm2.querySelector('.slick-cell-menu-icon') as HTMLDivElement; + + expect(commandListElm.querySelectorAll('.slick-cell-menu-item').length).toBe(5); + expect(commandContentElm2.textContent).toBe('Command 2'); + expect(commandIconElm2.classList.contains('bold')).toBeTruthy(); + expect(commandIconElm2.classList.contains('red')).toBeTruthy(); + }); + + it('should create a Cell Menu item with "textCssClass" and expect extra css classes added to the item text DOM element', () => { + plugin.dispose(); + plugin.init(); + (columnsMock[3].cellMenu.commandItems[1] as MenuCommandItem).title = 'Help'; + (columnsMock[3].cellMenu.commandItems[1] as MenuCommandItem).textCssClass = 'italic blue'; + gridStub.onClick.notify({ cell: 3, row: 1, grid: gridStub }, eventData, gridStub); + + const cellMenuElm = document.body.querySelector('.slick-cell-menu.slickgrid12345') as HTMLDivElement; + const commandListElm = cellMenuElm.querySelector('.slick-cell-menu-command-list') as HTMLDivElement; + const commandItemElm2 = commandListElm.querySelector('[data-command="command2"]') as HTMLDivElement; + const commandContentElm2 = commandItemElm2.querySelector('.slick-cell-menu-content') as HTMLDivElement; + + expect(commandListElm.querySelectorAll('.slick-cell-menu-item').length).toBe(5); + expect(commandContentElm2.textContent).toBe('Help'); + expect(commandContentElm2.classList.contains('italic')).toBeTruthy(); + expect(commandContentElm2.classList.contains('blue')).toBeTruthy(); + }); + + it('should create a Cell Menu item with "tooltip" and expect a title attribute to be added the item text DOM element', () => { + plugin.dispose(); + plugin.init(); + (columnsMock[3].cellMenu.commandItems[1] as MenuCommandItem).tooltip = 'some tooltip'; + gridStub.onClick.notify({ cell: 3, row: 1, grid: gridStub }, eventData, gridStub); + + const cellMenuElm = document.body.querySelector('.slick-cell-menu.slickgrid12345') as HTMLDivElement; + const commandListElm = cellMenuElm.querySelector('.slick-cell-menu-command-list') as HTMLDivElement; + const commandItemElm2 = commandListElm.querySelector('[data-command="command2"]') as HTMLDivElement; + const commandContentElm2 = commandItemElm2.querySelector('.slick-cell-menu-content') as HTMLDivElement; + + expect(commandListElm.querySelectorAll('.slick-cell-menu-item').length).toBe(5); + expect(commandContentElm2.textContent).toBe('Command 2'); + expect(commandItemElm2.title).toBe('some tooltip'); + }); + + it('should create a Cell Menu item with a title for the command list when "commandTitle" is provided', () => { + plugin.dispose(); + plugin.init({ commandTitle: 'The Commands!' }); + (columnsMock[3].cellMenu.commandItems[1] as MenuCommandItem).title = 'Help'; + (columnsMock[3].cellMenu.commandItems[1] as MenuCommandItem).textCssClass = 'italic blue'; + gridStub.onClick.notify({ cell: 3, row: 1, grid: gridStub }, eventData, gridStub); + + const cellMenuElm = document.body.querySelector('.slick-cell-menu.slickgrid12345') as HTMLDivElement; + const commandListElm = cellMenuElm.querySelector('.slick-cell-menu-command-list') as HTMLDivElement; + const commandListTitleElm = commandListElm.querySelector('.title') as HTMLDivElement; + + expect(commandListElm.querySelectorAll('.slick-cell-menu-item').length).toBe(5); + expect(commandListTitleElm.textContent).toBe('The Commands!'); + }); + + it('should expect all menu related to Sorting when "enableSorting" is set', () => { + plugin.dispose(); + plugin.init(); + (columnsMock[3].cellMenu as CellMenu).commandTitleKey = 'COMMANDS'; + (columnsMock[3].cellMenu.commandItems[1] as MenuCommandItem).command = 'help'; + (columnsMock[3].cellMenu.commandItems[1] as MenuCommandItem).titleKey = 'HELP'; + translateService.use('fr'); + plugin.translateCellMenu(); + + gridStub.onClick.notify({ cell: 3, row: 1, grid: gridStub }, eventData, gridStub); + + const cellMenuElm = document.body.querySelector('.slick-cell-menu.slickgrid12345') as HTMLDivElement; + const commandListElm = cellMenuElm.querySelector('.slick-cell-menu-command-list') as HTMLDivElement; + const commandItemElm = commandListElm.querySelector('[data-command="help"]') as HTMLDivElement; + const commandContentElm = commandItemElm.querySelector('.slick-cell-menu-content') as HTMLDivElement; + const commandListTitleElm = commandListElm.querySelector('.title') as HTMLDivElement; + + expect(commandListElm.querySelectorAll('.slick-cell-menu-item').length).toBe(5); + expect(commandListTitleElm.textContent).toBe('Commandes'); + expect(commandContentElm.textContent).toBe('Aide'); + }); + + it('should create a Cell Menu element and expect menu to hide when Close button is clicked', () => { + const closeSpy = jest.spyOn(plugin, 'closeMenu'); + + plugin.dispose(); + plugin.init(); + gridStub.onClick.notify({ cell: 3, row: 1, grid: gridStub }, eventData, gridStub); + + let cellMenuElm = document.body.querySelector('.slick-cell-menu.slickgrid12345') as HTMLDivElement; + const closeBtnElm = cellMenuElm.querySelector('.close') as HTMLButtonElement; + closeBtnElm.dispatchEvent(new Event('click')); + cellMenuElm = document.body.querySelector('.slick-cell-menu.slickgrid12345') as HTMLDivElement; + + expect(cellMenuElm).toBeNull(); + expect(closeBtnElm).toBeTruthy(); + expect(closeSpy).toHaveBeenCalled(); + }); + + it('should create a Cell Menu element then call "closeMenu" and expect "hideMenu" NOT to be called when "onBeforeMenuClose" returns false', () => { + const onBeforeSpy = jest.fn().mockReturnValue(false); + const hideSpy = jest.spyOn(plugin, 'hideMenu'); + + plugin.dispose(); + plugin.init({ onBeforeMenuClose: onBeforeSpy }); + gridStub.onClick.notify({ cell: 3, row: 1, grid: gridStub }, eventData, gridStub); + plugin.closeMenu(new Event('click') as any, {} as any); + + const cellMenuElm = document.body.querySelector('.slick-cell-menu.slickgrid12345') as HTMLDivElement; + const commandListElm = cellMenuElm.querySelector('.slick-cell-menu-command-list') as HTMLDivElement; + + expect(commandListElm.querySelectorAll('.slick-cell-menu-item').length).toBe(5); + expect(onBeforeSpy).toHaveBeenCalled(); + expect(hideSpy).not.toHaveBeenCalled(); + }); + + it('should not create a Cell Menu element then call "closeMenu" and expect "hideMenu" to be called when "onBeforeMenuClose" returns true', () => { + const onBeforeSpy = jest.fn().mockReturnValue(true); + const hideSpy = jest.spyOn(plugin, 'hideMenu'); + + plugin.dispose(); + plugin.init({ onBeforeMenuClose: onBeforeSpy }); + gridStub.onClick.notify({ cell: 3, row: 1, grid: gridStub }, eventData, gridStub); + plugin.closeMenu(new Event('click') as any, {} as any); + + const cellMenuElm = document.body.querySelector('.slick-cell-menu.slickgrid12345') as HTMLDivElement; + + expect(cellMenuElm).toBeNull(); + expect(onBeforeSpy).toHaveBeenCalled(); + expect(hideSpy).toHaveBeenCalled(); + }); + + it('should NOT create a Cell Menu element then call "closeMenu" and expect "hideMenu" NOT to be called when "onBeforeMenuShow" returns false', () => { + const onBeforeSpy = jest.fn().mockReturnValue(false); + + plugin.dispose(); + plugin.init({ onBeforeMenuShow: onBeforeSpy }); + gridStub.onClick.notify({ cell: 3, row: 1, grid: gridStub }, eventData, gridStub); + + const cellMenuElm = document.body.querySelector('.slick-cell-menu.slickgrid12345') as HTMLDivElement; + + expect(cellMenuElm).toBeNull(); + expect(onBeforeSpy).toHaveBeenCalled(); + }); + + it('should create a Cell Menu element then call "closeMenu" and expect "hideMenu" NOT to be called when "onBeforeMenuShow" returns true', () => { + const onBeforeSpy = jest.fn().mockReturnValue(true); + const onAfterSpy = jest.fn().mockReturnValue(false); + + plugin.dispose(); + plugin.init({ onBeforeMenuClose: () => true, onBeforeMenuShow: onBeforeSpy, onAfterMenuShow: onAfterSpy }); + gridStub.onClick.notify({ cell: 3, row: 1, grid: gridStub }, eventData, gridStub); + + const cellMenuElm = document.body.querySelector('.slick-cell-menu.slickgrid12345') as HTMLDivElement; + const commandListElm = cellMenuElm.querySelector('.slick-cell-menu-command-list') as HTMLDivElement; + + expect(commandListElm.querySelectorAll('.slick-cell-menu-item').length).toBe(5); + expect(onBeforeSpy).toHaveBeenCalled(); + expect(onAfterSpy).toHaveBeenCalled(); + }); + + it('should create a Cell Menu and expect the button click handler & "action" callback to be executed when defined', () => { + const actionMock = jest.fn(); + + plugin.dispose(); + plugin.init(); + (columnsMock[3].cellMenu.commandItems[1] as MenuCommandItem).action = actionMock; + gridStub.onClick.notify({ cell: 3, row: 1, grid: gridStub }, eventData, gridStub); + + const cellMenuElm = document.body.querySelector('.slick-cell-menu.slickgrid12345') as HTMLDivElement; + const commandListElm = cellMenuElm.querySelector('.slick-cell-menu-command-list') as HTMLDivElement; + commandListElm.querySelector('[data-command="command2"]').dispatchEvent(new Event('click')); + + expect(commandListElm.querySelectorAll('.slick-cell-menu-item').length).toBe(5); + expect(actionMock).toHaveBeenCalled(); + }); + + it('should create a Cell Menu and expect the "onCommand" handler to be executed when defined', () => { + const onCommandMock = jest.fn(); + + plugin.dispose(); + plugin.init(); + gridStub.onClick.notify({ cell: 3, row: 1, grid: gridStub }, eventData, gridStub); + plugin.addonOptions.onCommand = onCommandMock; + + const cellMenuElm = document.body.querySelector('.slick-cell-menu.slickgrid12345') as HTMLDivElement; + const commandListElm = cellMenuElm.querySelector('.slick-cell-menu-command-list') as HTMLDivElement; + commandListElm.querySelector('[data-command="command2"]').dispatchEvent(new Event('click')); + + expect(commandListElm.querySelectorAll('.slick-cell-menu-item').length).toBe(5); + expect(onCommandMock).toHaveBeenCalled(); + }); + + it('should not populate a Cell Menu when "menuUsabilityOverride" is defined and returns False', () => { + plugin.dispose(); + plugin.init({ menuUsabilityOverride: () => false }); + (columnsMock[3].cellMenu.commandItems[1] as MenuCommandItem).itemVisibilityOverride = () => true; + (columnsMock[3].cellMenu.commandItems[1] as MenuCommandItem).itemUsabilityOverride = () => true; + gridStub.onClick.notify({ cell: 3, row: 1, grid: gridStub }, eventData, gridStub); + + expect(plugin.menuElement).toBeFalsy(); + }); + }); + + describe('with Options Items', () => { + beforeEach(() => { + columnsMock[4].cellMenu.optionItems = deepCopy(optionItemsMock); + delete (columnsMock[4].cellMenu.optionItems[1] as MenuOptionItem).itemVisibilityOverride; + delete (columnsMock[4].cellMenu.optionItems[1] as MenuOptionItem).itemUsabilityOverride; + jest.spyOn(gridStub, 'getCellFromEvent').mockReturnValue({ cell: 4, row: 1 }); + }); + + it('should not populate and automatically return when the Cell Menu item "optionItems" array of the cell menu is undefined', () => { + plugin.dispose(); + plugin.init({ onAfterMenuShow: undefined }); + (columnsMock[4].cellMenu.optionItems) = undefined; + gridStub.onClick.notify({ cell: 4, row: 1, grid: gridStub }, eventData, gridStub); + + const cellMenuElm = document.body.querySelector('.slick-cell-menu.slickgrid12345') as HTMLDivElement; + + expect(cellMenuElm).toBeNull(); + }); + + it('should create a Cell Menu to be create and show up when item visibility & usability callbacks returns true', () => { + plugin.dispose(); + plugin.init(); + (columnsMock[4].cellMenu.optionItems[1] as MenuOptionItem).itemVisibilityOverride = () => true; + (columnsMock[4].cellMenu.optionItems[1] as MenuOptionItem).itemUsabilityOverride = () => true; + gridStub.onClick.notify({ cell: 4, row: 1, grid: gridStub }, eventData, gridStub); + + const cellMenuElm = document.body.querySelector('.slick-cell-menu.slickgrid12345') as HTMLDivElement; + const optionListElm = cellMenuElm.querySelector('.slick-cell-menu-option-list') as HTMLDivElement; + + expect(optionListElm.querySelectorAll('.slick-cell-menu-item').length).toBe(5); + expect(removeExtraSpaces(document.body.innerHTML)).toBe(removeExtraSpaces( + ``)); + }); + + it('should expect a Cell Menu to be created when cell is clicked with a list of commands defined but without "Option 1" when "itemVisibilityOverride" and "itemUsabilityOverride" return undefined', () => { + plugin.dispose(); + plugin.init(); + (columnsMock[4].cellMenu.optionItems[1] as MenuOptionItem).itemVisibilityOverride = () => undefined; + (columnsMock[4].cellMenu.optionItems[1] as MenuOptionItem).itemUsabilityOverride = () => undefined; + gridStub.onClick.notify({ cell: 4, row: 1, grid: gridStub }, eventData, gridStub); + + const cellMenuElm = document.body.querySelector('.slick-cell-menu.slickgrid12345') as HTMLDivElement; + const closeBtnElm = cellMenuElm.querySelector('.close') as HTMLButtonElement; + const optionListElm = cellMenuElm.querySelector('.slick-cell-menu-option-list') as HTMLDivElement; + const optionItemElm1 = optionListElm.querySelectorAll('.slick-cell-menu-item')[0] as HTMLDivElement; + const optionItemElm2 = optionListElm.querySelectorAll('.slick-cell-menu-item')[1] as HTMLDivElement; + const optionItemElm3 = optionListElm.querySelectorAll('.slick-cell-menu-item')[2] as HTMLDivElement; + const optionLabelElm1 = optionItemElm1.querySelector('.slick-cell-menu-content') as HTMLSpanElement; + const optionIconElm1 = optionItemElm1.querySelector('.slick-cell-menu-icon') as HTMLDivElement; + const optionLabelElm3 = optionItemElm3.querySelector('.slick-cell-menu-content') as HTMLSpanElement; + const optionIconElm3 = optionItemElm3.querySelector('.slick-cell-menu-icon') as HTMLDivElement; + + expect(plugin.menuElement).toBeTruthy(); + expect(closeBtnElm).toBeTruthy(); + expect(optionListElm.querySelectorAll('.slick-cell-menu-item').length).toBe(4); + expect(optionItemElm1.classList.contains('purple')).toBeTruthy(); + expect(optionIconElm1.className).toBe('slick-cell-menu-icon'); + expect(optionLabelElm1.textContent).toBe('Option 1'); + expect(optionItemElm2.classList.contains('slick-cell-menu-item-divider')).toBeTruthy(); + expect(optionItemElm2.innerHTML).toBe(''); + expect(optionIconElm3.classList.contains('mdi-checked')).toBeTruthy(); + expect(optionLabelElm3.textContent).toBe('Delete Row'); + }); + + it('should expect a Cell Menu to be created when cell is clicked with a list of options defined but without "Option 1" when "itemVisibilityOverride" and "itemUsabilityOverride" return false', () => { + plugin.dispose(); + plugin.init(); + (columnsMock[4].cellMenu.optionItems[1] as MenuOptionItem).itemVisibilityOverride = () => false; + (columnsMock[4].cellMenu.optionItems[1] as MenuOptionItem).itemUsabilityOverride = () => false; + gridStub.onClick.notify({ cell: 4, row: 1, grid: gridStub }, eventData, gridStub); + + const cellMenuElm = document.body.querySelector('.slick-cell-menu.slickgrid12345') as HTMLDivElement; + const closeBtnElm = cellMenuElm.querySelector('.close') as HTMLButtonElement; + const optionListElm = cellMenuElm.querySelector('.slick-cell-menu-option-list') as HTMLDivElement; + const optionItemElm1 = optionListElm.querySelectorAll('.slick-cell-menu-item')[0] as HTMLDivElement; + const optionLabelElm1 = optionItemElm1.querySelector('.slick-cell-menu-content') as HTMLSpanElement; + const optionIconElm1 = optionItemElm1.querySelector('.slick-cell-menu-icon') as HTMLDivElement; + + expect(closeBtnElm).toBeTruthy(); + expect(optionListElm.querySelectorAll('.slick-cell-menu-item').length).toBe(4); + expect(optionItemElm1.classList.contains('purple')).toBeTruthy(); + expect(optionIconElm1.className).toBe('slick-cell-menu-icon'); + expect(optionLabelElm1.textContent).toBe('Option 1'); + expect(document.body.innerHTML.includes('Option 2')).not.toBeTruthy(); + }); + + it('should create a Cell Menu and a 2nd button item usability callback returns false and expect button to be disabled', () => { + plugin.dispose(); + plugin.init(); + (columnsMock[4].cellMenu.optionItems[1] as MenuOptionItem).itemVisibilityOverride = () => true; + (columnsMock[4].cellMenu.optionItems[1] as MenuOptionItem).itemUsabilityOverride = () => false; + gridStub.onClick.notify({ cell: 4, row: 1, grid: gridStub }, eventData, gridStub); + + const cellMenuElm = document.body.querySelector('.slick-cell-menu.slickgrid12345') as HTMLDivElement; + const closeBtnElm = cellMenuElm.querySelector('.close') as HTMLButtonElement; + const optionListElm = cellMenuElm.querySelector('.slick-cell-menu-option-list') as HTMLDivElement; + const optionItemElm1 = optionListElm.querySelectorAll('.slick-cell-menu-item')[0] as HTMLDivElement; + const optionLabelElm1 = optionItemElm1.querySelector('.slick-cell-menu-content') as HTMLSpanElement; + const optionIconElm1 = optionItemElm1.querySelector('.slick-cell-menu-icon') as HTMLDivElement; + + expect(closeBtnElm).toBeTruthy(); + expect(optionListElm.querySelectorAll('.slick-cell-menu-item').length).toBe(5); + expect(optionItemElm1.classList.contains('purple')).toBeTruthy(); + expect(optionIconElm1.className).toBe('slick-cell-menu-icon'); + expect(optionLabelElm1.textContent).toBe('Option 1'); + expect(document.body.innerHTML.includes('Option 2')).toBeTruthy(); + }); + + it('should create a Cell Menu and a 2nd item is "disabled" and expect button to be disabled', () => { + plugin.dispose(); + plugin.init(); + (columnsMock[4].cellMenu.optionItems[1] as MenuOptionItem).disabled = true; + gridStub.onClick.notify({ cell: 4, row: 1, grid: gridStub }, eventData, gridStub); + + const cellMenuElm = document.body.querySelector('.slick-cell-menu.slickgrid12345') as HTMLDivElement; + const optionListElm = cellMenuElm.querySelector('.slick-cell-menu-option-list') as HTMLDivElement; + const optionItemElm2 = optionListElm.querySelector('[data-option="option2"]') as HTMLDivElement; + const optionContentElm2 = optionItemElm2.querySelector('.slick-cell-menu-content') as HTMLDivElement; + + expect(optionListElm.querySelectorAll('.slick-cell-menu-item').length).toBe(5); + expect(optionContentElm2.textContent).toBe('Option 2'); + expect(optionItemElm2.classList.contains('slick-cell-menu-item-disabled')).toBeTruthy(); + }); + + it('should create a Cell Menu and expect button to be disabled when option property is hidden', () => { + plugin.dispose(); + plugin.init(); + (columnsMock[4].cellMenu.optionItems[1] as MenuOptionItem).hidden = true; + gridStub.onClick.notify({ cell: 4, row: 1, grid: gridStub }, eventData, gridStub); + + const cellMenuElm = document.body.querySelector('.slick-cell-menu.slickgrid12345') as HTMLDivElement; + const optionListElm = cellMenuElm.querySelector('.slick-cell-menu-option-list') as HTMLDivElement; + const optionItemElm2 = optionListElm.querySelector('[data-option="option2"]') as HTMLDivElement; + const optionContentElm2 = optionItemElm2.querySelector('.slick-cell-menu-content') as HTMLDivElement; + + expect(optionListElm.querySelectorAll('.slick-cell-menu-item').length).toBe(5); + expect(optionContentElm2.textContent).toBe('Option 2'); + expect(optionItemElm2.classList.contains('slick-cell-menu-item-hidden')).toBeTruthy(); + }); + + it('should create a Cell Menu with an icon having a background image when property "iconImage" is filled', () => { + plugin.dispose(); + plugin.init(); + (columnsMock[4].cellMenu.optionItems[1] as MenuOptionItem).iconImage = '/images/some-image.png'; + gridStub.onClick.notify({ cell: 4, row: 1, grid: gridStub }, eventData, gridStub); + + const cellMenuElm = document.body.querySelector('.slick-cell-menu.slickgrid12345') as HTMLDivElement; + const optionListElm = cellMenuElm.querySelector('.slick-cell-menu-option-list') as HTMLDivElement; + const optionItemElm2 = optionListElm.querySelector('[data-option="option2"]') as HTMLDivElement; + const optionContentElm2 = optionItemElm2.querySelector('.slick-cell-menu-content') as HTMLDivElement; + const optionIconElm2 = optionItemElm2.querySelector('.slick-cell-menu-icon') as HTMLDivElement; + + expect(optionListElm.querySelectorAll('.slick-cell-menu-item').length).toBe(5); + expect(optionContentElm2.textContent).toBe('Option 2'); + expect(optionIconElm2.style.backgroundImage).toBe('url(/images/some-image.png)'); + expect(consoleWarnSpy).toHaveBeenCalledWith('[Slickgrid-Universal] The "iconImage" property of a Cell Menu item is now deprecated and will be removed in future version, consider using "iconCssClass" instead.'); + }); + + it('should create a Cell Menu item with "iconCssClass" and expect extra css classes added to the icon element', () => { + plugin.dispose(); + plugin.init(); + (columnsMock[4].cellMenu.optionItems[1] as MenuOptionItem).iconCssClass = 'underline sky'; + gridStub.onClick.notify({ cell: 4, row: 1, grid: gridStub }, eventData, gridStub); + + const cellMenuElm = document.body.querySelector('.slick-cell-menu.slickgrid12345') as HTMLDivElement; + const optionListElm = cellMenuElm.querySelector('.slick-cell-menu-option-list') as HTMLDivElement; + const optionItemElm2 = optionListElm.querySelector('[data-option="option2"]') as HTMLDivElement; + const optionContentElm2 = optionItemElm2.querySelector('.slick-cell-menu-content') as HTMLDivElement; + const optionIconElm2 = optionItemElm2.querySelector('.slick-cell-menu-icon') as HTMLDivElement; + + expect(optionListElm.querySelectorAll('.slick-cell-menu-item').length).toBe(5); + expect(optionContentElm2.textContent).toBe('Option 2'); + expect(optionIconElm2.classList.contains('underline')).toBeTruthy(); + expect(optionIconElm2.classList.contains('sky')).toBeTruthy(); + }); + + it('should create a Cell Menu item with "textCssClass" and expect extra css classes added to the item text DOM element', () => { + plugin.dispose(); + plugin.init(); + (columnsMock[4].cellMenu.optionItems[1] as MenuOptionItem).title = 'Help'; + (columnsMock[4].cellMenu.optionItems[1] as MenuOptionItem).textCssClass = 'italic blue'; + gridStub.onClick.notify({ cell: 4, row: 1, grid: gridStub }, eventData, gridStub); + + const cellMenuElm = document.body.querySelector('.slick-cell-menu.slickgrid12345') as HTMLDivElement; + const optionListElm = cellMenuElm.querySelector('.slick-cell-menu-option-list') as HTMLDivElement; + const optionItemElm2 = optionListElm.querySelector('[data-option="option2"]') as HTMLDivElement; + const optionContentElm2 = optionItemElm2.querySelector('.slick-cell-menu-content') as HTMLDivElement; + + expect(optionListElm.querySelectorAll('.slick-cell-menu-item').length).toBe(5); + expect(optionContentElm2.textContent).toBe('Help'); + expect(optionContentElm2.classList.contains('italic')).toBeTruthy(); + expect(optionContentElm2.classList.contains('blue')).toBeTruthy(); + }); + + it('should create a Cell Menu item with "tooltip" and expect a title attribute to be added the item text DOM element', () => { + plugin.dispose(); + plugin.init(); + (columnsMock[4].cellMenu.optionItems[1] as MenuOptionItem).tooltip = 'some tooltip'; + gridStub.onClick.notify({ cell: 4, row: 1, grid: gridStub }, eventData, gridStub); + + const cellMenuElm = document.body.querySelector('.slick-cell-menu.slickgrid12345') as HTMLDivElement; + const optionListElm = cellMenuElm.querySelector('.slick-cell-menu-option-list') as HTMLDivElement; + const optionItemElm2 = optionListElm.querySelector('[data-option="option2"]') as HTMLDivElement; + const optionContentElm2 = optionItemElm2.querySelector('.slick-cell-menu-content') as HTMLDivElement; + + expect(optionListElm.querySelectorAll('.slick-cell-menu-item').length).toBe(5); + expect(optionContentElm2.textContent).toBe('Option 2'); + expect(optionItemElm2.title).toBe('some tooltip'); + }); + + it('should create a Cell Menu item with a title for the option list when "optionTitle" is provided', () => { + plugin.dispose(); + plugin.init(); + plugin.setOptions({ optionTitle: 'The Options!' }); + (columnsMock[4].cellMenu.optionItems[1] as MenuOptionItem).title = 'Help'; + (columnsMock[4].cellMenu.optionItems[1] as MenuOptionItem).textCssClass = 'italic blue'; + gridStub.onClick.notify({ cell: 4, row: 1, grid: gridStub }, eventData, gridStub); + + const cellMenuElm = document.body.querySelector('.slick-cell-menu.slickgrid12345') as HTMLDivElement; + const optionListElm = cellMenuElm.querySelector('.slick-cell-menu-option-list') as HTMLDivElement; + const optionListTitleElm = optionListElm.querySelector('.title') as HTMLDivElement; + + expect(optionListElm.querySelectorAll('.slick-cell-menu-item').length).toBe(5); + expect(optionListTitleElm.textContent).toBe('The Options!'); + }); + + it('should expect all menu related to Sorting when "enableSorting" is set', () => { + plugin.dispose(); + plugin.init(); + (columnsMock[4].cellMenu as CellMenu).optionTitleKey = 'OPTIONS_LIST'; + (columnsMock[4].cellMenu.optionItems[1] as MenuOptionItem).option = 'none'; + (columnsMock[4].cellMenu.optionItems[1] as MenuOptionItem).titleKey = 'NONE'; + translateService.use('fr'); + plugin.translateCellMenu(); + + gridStub.onClick.notify({ cell: 4, row: 1, grid: gridStub }, eventData, gridStub); + + const cellMenuElm = document.body.querySelector('.slick-cell-menu.slickgrid12345') as HTMLDivElement; + const optionListElm = cellMenuElm.querySelector('.slick-cell-menu-option-list') as HTMLDivElement; + const optionItemElm = optionListElm.querySelector('[data-option="none"]') as HTMLDivElement; + const optionContentElm = optionItemElm.querySelector('.slick-cell-menu-content') as HTMLDivElement; + const optionListTitleElm = optionListElm.querySelector('.title') as HTMLDivElement; + + expect(optionListElm.querySelectorAll('.slick-cell-menu-item').length).toBe(5); + expect(optionListTitleElm.textContent).toBe(`Liste d'options`); + expect(optionContentElm.textContent).toBe(`Aucun`); + }); + + it('should create a Cell Menu element and expect menu to hide when Close button is clicked', () => { + const closeSpy = jest.spyOn(plugin, 'closeMenu'); + + plugin.dispose(); + plugin.init(); + gridStub.onClick.notify({ cell: 4, row: 1, grid: gridStub }, eventData, gridStub); + + let cellMenuElm = document.body.querySelector('.slick-cell-menu.slickgrid12345') as HTMLDivElement; + const closeBtnElm = cellMenuElm.querySelector('.close') as HTMLButtonElement; + closeBtnElm.dispatchEvent(new Event('click')); + cellMenuElm = document.body.querySelector('.slick-cell-menu.slickgrid12345') as HTMLDivElement; + + expect(cellMenuElm).toBeNull(); + expect(closeBtnElm).toBeTruthy(); + expect(closeSpy).toHaveBeenCalled(); + }); + + it('should create a Cell Menu and expect the button click handler & "action" callback to be executed when defined', () => { + const actionMock = jest.fn(); + jest.spyOn(getEditorLockMock, 'commitCurrentEdit').mockReturnValue(true); + + plugin.dispose(); + plugin.init(); + (columnsMock[4].cellMenu.optionItems[1] as MenuOptionItem).action = actionMock; + gridStub.onClick.notify({ cell: 4, row: 1, grid: gridStub }, eventData, gridStub); + + const cellMenuElm = document.body.querySelector('.slick-cell-menu.slickgrid12345') as HTMLDivElement; + const optionListElm = cellMenuElm.querySelector('.slick-cell-menu-option-list') as HTMLDivElement; + optionListElm.querySelector('[data-option="option2"]').dispatchEvent(new Event('click')); + + expect(optionListElm.querySelectorAll('.slick-cell-menu-item').length).toBe(5); + expect(actionMock).toHaveBeenCalled(); + }); + + it('should create a Cell Menu and expect the "onOptionSelected" handler to be executed when defined', () => { + const onOptionSelectedMock = jest.fn(); + jest.spyOn(getEditorLockMock, 'commitCurrentEdit').mockReturnValue(true); + + plugin.dispose(); + plugin.init({ onOptionSelected: onOptionSelectedMock }); + // plugin.setOptions({ onOptionSelected: onOptionSelectedMock }); + gridStub.onClick.notify({ cell: 4, row: 1, grid: gridStub }, eventData, gridStub); + + const cellMenuElm = document.body.querySelector('.slick-cell-menu.slickgrid12345') as HTMLDivElement; + const optionListElm = cellMenuElm.querySelector('.slick-cell-menu-option-list') as HTMLDivElement; + optionListElm.querySelector('[data-option="option2"]').dispatchEvent(new Event('click')); + + expect(optionListElm.querySelectorAll('.slick-cell-menu-item').length).toBe(5); + expect(onOptionSelectedMock).toHaveBeenCalled(); + }); + + it('should create a Cell Menu and NOT expect the "onOptionSelected" handler to be executed when "commitCurrentEdit" returns false', () => { + const onOptionSelectedMock = jest.fn(); + jest.spyOn(getEditorLockMock, 'commitCurrentEdit').mockReturnValue(false); + + plugin.dispose(); + plugin.init(); + plugin.setOptions({ onOptionSelected: onOptionSelectedMock }); + gridStub.onClick.notify({ cell: 4, row: 1, grid: gridStub }, eventData, gridStub); + + const cellMenuElm = document.body.querySelector('.slick-cell-menu.slickgrid12345') as HTMLDivElement; + const optionListElm = cellMenuElm.querySelector('.slick-cell-menu-option-list') as HTMLDivElement; + optionListElm.querySelector('[data-option="option2"]').dispatchEvent(new Event('click')); + + expect(optionListElm.querySelectorAll('.slick-cell-menu-item').length).toBe(5); + expect(onOptionSelectedMock).not.toHaveBeenCalled(); + }); + }); + }); +}); \ No newline at end of file diff --git a/packages/common/src/plugins/__tests__/headerButton.plugin.spec.ts b/packages/common/src/plugins/__tests__/headerButton.plugin.spec.ts index b10ab5099..2d318e67c 100644 --- a/packages/common/src/plugins/__tests__/headerButton.plugin.spec.ts +++ b/packages/common/src/plugins/__tests__/headerButton.plugin.spec.ts @@ -88,18 +88,18 @@ describe('HeaderButton Plugin', () => { it('should use default options when instantiating the plugin without passing any arguments', () => { plugin.init(); - expect(plugin.options).toEqual({ + expect(plugin.addonOptions).toEqual({ buttonCssClass: 'slick-header-button', }); }); it('should be able to change Header Button options', () => { plugin.init(); - plugin.options = { + plugin.addonOptions = { buttonCssClass: 'some-class' } - expect(plugin.options).toEqual({ + expect(plugin.addonOptions).toEqual({ buttonCssClass: 'some-class', }); }); @@ -232,14 +232,14 @@ describe('HeaderButton Plugin', () => { plugin.dispose(); plugin.init(); - columnsMock[0].header.buttons[1].image = '/images/some-gridmenu-image.png'; + columnsMock[0].header.buttons[1].image = '/images/some-image.png'; const eventData = { ...new Slick.EventData(), preventDefault: jest.fn() }; gridStub.onHeaderCellRendered.notify({ column: columnsMock[0], node: headerDiv, grid: gridStub }, eventData, gridStub); // add Header Buttons which are visible (2x buttons) expect(removeExtraSpaces(headerDiv.innerHTML)).toBe(removeExtraSpaces( - `
+ `
`)); expect(consoleWarnSpy).toHaveBeenCalledWith('[Slickgrid-Universal] The "image" property of a Header Button is now deprecated and will be removed in future version, consider using "cssClass" instead.'); }); @@ -310,7 +310,7 @@ describe('HeaderButton Plugin', () => { plugin.dispose(); plugin.init(); - plugin.options.onCommand = onCommandMock; + plugin.addonOptions.onCommand = onCommandMock; const eventData = { ...new Slick.EventData(), preventDefault: jest.fn() }; gridStub.onHeaderCellRendered.notify({ column: columnsMock[0], node: headerDiv, grid: gridStub }, eventData, gridStub); diff --git a/packages/common/src/plugins/__tests__/headerMenu.plugin.spec.ts b/packages/common/src/plugins/__tests__/headerMenu.plugin.spec.ts index bf490b432..6a0700d1d 100644 --- a/packages/common/src/plugins/__tests__/headerMenu.plugin.spec.ts +++ b/packages/common/src/plugins/__tests__/headerMenu.plugin.spec.ts @@ -49,6 +49,7 @@ const gridStub = { getColumnIndex: jest.fn(), getContainerNode: jest.fn(), getGridPosition: jest.fn(), + getUID: () => 'slickgrid12345', getOptions: () => gridOptionsMock, registerPlugin: jest.fn(), setColumns: jest.fn(), @@ -142,7 +143,7 @@ describe('HeaderMenu Plugin', () => { it('should use default options when instantiating the plugin without passing any arguments', () => { plugin.init(); - expect(plugin.options).toEqual({ + expect(plugin.addonOptions).toEqual({ autoAlign: true, autoAlignOffset: 0, buttonCssClass: null, @@ -156,11 +157,11 @@ describe('HeaderMenu Plugin', () => { it('should be able to change Header Menu options', () => { plugin.init(); - plugin.options = { + plugin.addonOptions = { buttonCssClass: 'some-class' }; - expect(plugin.options).toEqual({ + expect(plugin.addonOptions).toEqual({ buttonCssClass: 'some-class', }); }); @@ -189,7 +190,7 @@ describe('HeaderMenu Plugin', () => { plugin.dispose(); }); - it('should populate a Header Menu with extra button css classes when header menu option "buttonCssClass" and cell is being rendered', () => { + it('should populate a Header Menu button with extra button css classes when header menu option "buttonCssClass" and cell is being rendered', () => { plugin.dispose(); plugin.init({ buttonCssClass: 'mdi mdi-chevron-down' }); (columnsMock[0].header.menu.items[1] as MenuCommandItem).itemVisibilityOverride = () => undefined; @@ -201,7 +202,7 @@ describe('HeaderMenu Plugin', () => { `
`)); }); - it('should populate a Header Menu with extra button image when header menu option "buttonImage" and cell is being rendered', () => { + it('should populate a Header Menu button with extra button image when header menu option "buttonImage" and cell is being rendered', () => { plugin.dispose(); plugin.init({ buttonImage: '/image.png' }); (columnsMock[0].header.menu.items[1] as MenuCommandItem).itemVisibilityOverride = () => undefined; @@ -213,7 +214,7 @@ describe('HeaderMenu Plugin', () => { `
`)); }); - it('should populate a Header Menu with extra tooltip title attribute when header menu option "tooltip" and cell is being rendered', () => { + it('should populate a Header Menu button with extra tooltip title attribute when header menu option "tooltip" and cell is being rendered', () => { plugin.dispose(); plugin.init({ tooltip: 'some tooltip text' }); (columnsMock[0].header.menu.items[1] as MenuCommandItem).itemVisibilityOverride = () => undefined; @@ -343,7 +344,7 @@ describe('HeaderMenu Plugin', () => { it('should populate a Header Menu and a 2nd button and property "iconImage" is filled and expect button to include an image background', () => { plugin.dispose(); plugin.init(); - (columnsMock[0].header.menu.items[1] as MenuCommandItem).iconImage = '/images/some-gridmenu-image.png'; + (columnsMock[0].header.menu.items[1] as MenuCommandItem).iconImage = '/images/some-image.png'; const eventData = { ...new Slick.EventData(), preventDefault: jest.fn() }; gridStub.onHeaderCellRendered.notify({ column: columnsMock[0], node: headerDiv, grid: gridStub }, eventData, gridStub); @@ -354,7 +355,7 @@ describe('HeaderMenu Plugin', () => { expect(commandElm).toBeTruthy(); expect(removeExtraSpaces(commandElm.outerHTML)).toBe(removeExtraSpaces( `
-
+
` )); @@ -434,7 +435,7 @@ describe('HeaderMenu Plugin', () => { plugin.dispose(); plugin.init(); - plugin.options.onCommand = onCommandMock; + plugin.addonOptions.onCommand = onCommandMock; const eventData = { ...new Slick.EventData(), preventDefault: jest.fn() }; gridStub.onHeaderCellRendered.notify({ column: columnsMock[0], node: headerDiv, grid: gridStub }, eventData, gridStub); @@ -676,7 +677,7 @@ describe('HeaderMenu Plugin', () => { }); plugin.init({ onAfterMenuShow: () => false }); - const onAfterSpy = jest.spyOn(plugin.options, 'onAfterMenuShow').mockReturnValue(false); + const onAfterSpy = jest.spyOn(plugin.addonOptions, 'onAfterMenuShow').mockReturnValue(false); gridStub.onBeforeSetColumns.notify({ previousColumns: [], newColumns: columnsMock, grid: gridStub }, eventData, gridStub); gridStub.onHeaderCellRendered.notify({ column: columnsMock[1], node: headerDiv, grid: gridStub }, eventData, gridStub); const headerButtonElm = headerDiv.querySelector('.slick-header-menubutton') as HTMLDivElement; diff --git a/packages/common/src/plugins/autoTooltip.plugin.ts b/packages/common/src/plugins/autoTooltip.plugin.ts index 52fa890c8..c954ca35f 100644 --- a/packages/common/src/plugins/autoTooltip.plugin.ts +++ b/packages/common/src/plugins/autoTooltip.plugin.ts @@ -20,7 +20,7 @@ declare const Slick: SlickNamespace; export class AutoTooltipPlugin { private _eventHandler!: SlickEventHandler; private _grid!: SlickGrid; - private _options?: AutoTooltipOption; + private _addonOptions?: AutoTooltipOption; private _defaults = { enableForCells: true, enableForHeaderCells: false, @@ -32,26 +32,26 @@ export class AutoTooltipPlugin { /** Constructor of the SlickGrid 3rd party plugin, it can optionally receive options */ constructor(options?: AutoTooltipOption) { this._eventHandler = new Slick.EventHandler(); - this._options = options; + this._addonOptions = options; } - get eventHandler(): SlickEventHandler { - return this._eventHandler; + get addonOptions(): AutoTooltipOption { + return this._addonOptions as AutoTooltipOption; } - get options(): AutoTooltipOption { - return this._options as AutoTooltipOption; + get eventHandler(): SlickEventHandler { + return this._eventHandler; } /** Initialize plugin. */ init(grid: SlickGrid) { - this._options = { ...this._defaults, ...this.options }; + this._addonOptions = { ...this._defaults, ...this.addonOptions }; this._grid = grid; - if (this._options.enableForCells) { + if (this._addonOptions.enableForCells) { const onMouseEnterHandler = this._grid.onMouseEnter; (this._eventHandler as SlickEventHandler>).subscribe(onMouseEnterHandler, this.handleMouseEnter.bind(this)); } - if (this._options.enableForHeaderCells) { + if (this._addonOptions.enableForHeaderCells) { const onHeaderMouseEnterHandler = this._grid.onHeaderMouseEnter; (this._eventHandler as SlickEventHandler>).subscribe(onHeaderMouseEnterHandler, this.handleHeaderMouseEnter.bind(this)); } @@ -75,11 +75,11 @@ export class AutoTooltipPlugin { if (cell) { let node: HTMLElement | null = this._grid.getCellNode(cell.row, cell.cell); let text; - if (this._options && node && (!node.title || this._options?.replaceExisting)) { + if (this._addonOptions && node && (!node.title || this._addonOptions?.replaceExisting)) { if (node.clientWidth < node.scrollWidth) { text = node.textContent?.trim() ?? ''; - if (this._options?.maxToolTipLength && text.length > this._options?.maxToolTipLength) { - text = text.substr(0, this._options.maxToolTipLength - 3) + '...'; + if (this._addonOptions?.maxToolTipLength && text.length > this._addonOptions?.maxToolTipLength) { + text = text.substr(0, this._addonOptions.maxToolTipLength - 3) + '...'; } } else { text = ''; diff --git a/packages/common/src/plugins/cellMenu.plugin.ts b/packages/common/src/plugins/cellMenu.plugin.ts new file mode 100644 index 000000000..774df27f0 --- /dev/null +++ b/packages/common/src/plugins/cellMenu.plugin.ts @@ -0,0 +1,695 @@ +import { + CellMenu, + CellMenuOption, + Column, + DOMEvent, + GetSlickEventType, + GridOption, + MenuCommandItem, + MenuCommandItemCallbackArgs, + MenuFromCellCallbackArgs, + MenuOptionItem, + MenuOptionItemCallbackArgs, + SlickEventHandler, + SlickGrid, + SlickNamespace, +} from '../interfaces/index'; +import { getHtmlElementOffset, hasData, isNumber, windowScrollPosition } from '../services/index'; +import { ExtensionUtility } from '../extensions/extensionUtility'; +import { BindingEventService } from '../services/bindingEvent.service'; +import { PubSubService } from '../services/pubSub.service'; +import { SharedService } from '../services/shared.service'; + +// using external SlickGrid JS libraries +declare const Slick: SlickNamespace; + +/** + * A plugin to add Menu on a Cell click (click on the cell that has the cellMenu object defined) + * The "cellMenu" is defined in a Column Definition object + * Similar to the ContextMenu plugin (could be used in combo), + * except that it subscribes to the cell "onClick" event (regular mouse click or touch). + * + * A general use of this plugin is for an Action Dropdown Menu to do certain things on the row that was clicked + * You can use it to change the cell data property through a list of Options AND/OR through a list of Commands. + * + * To specify a custom button in a column header, extend the column definition like so: + * this.columnDefinitions = [{ + * id: 'myColumn', name: 'My column', + * cellMenu: { + * // ... cell menu options + * commandItems: [{ ...menu item options... }, { ...menu item options... }] + * } + * }]; + */ +export class CellMenuPlugin { + protected _bindEventService: BindingEventService; + protected _currentCell = -1; + protected _currentRow = -1; + protected _eventHandler!: SlickEventHandler; + protected _commandTitleElm?: HTMLDivElement; + protected _optionTitleElm?: HTMLDivElement; + protected _addonOptions: CellMenu = {}; + protected _menuElm?: HTMLDivElement | null; + protected _defaults = { + autoAdjustDrop: true, // dropup/dropdown + autoAlignSide: true, // left/right + autoAdjustDropOffset: 0, + autoAlignSideOffset: 0, + hideMenuOnScroll: true, + maxHeight: 'none', + width: 'auto', + } as unknown as CellMenuOption; + pluginName: 'CellMenu' = 'CellMenu'; + + /** Constructor of the SlickGrid 3rd party plugin, it can optionally receive options */ + constructor( + protected readonly extensionUtility: ExtensionUtility, + protected readonly pubSubService: PubSubService, + protected readonly sharedService: SharedService, + ) { + this._bindEventService = new BindingEventService(); + this._eventHandler = new Slick.EventHandler(); + this.init(sharedService.gridOptions.cellMenu); + } + + get addonOptions(): CellMenu { + return this._addonOptions as CellMenu; + } + set addonOptions(newOptions: CellMenu) { + this._addonOptions = newOptions; + } + + get eventHandler(): SlickEventHandler { + return this._eventHandler; + } + + get grid(): SlickGrid { + return this.sharedService.slickGrid; + } + + get gridOptions(): GridOption { + return this.sharedService.gridOptions ?? {}; + } + + /** Getter for the grid uid */ + get gridUid(): string { + return this.grid?.getUID() ?? ''; + } + get gridUidSelector(): string { + return this.gridUid ? `.${this.gridUid}` : ''; + } + + get menuElement(): HTMLDivElement | null { + return this._menuElm || document.querySelector(`.slick-cell-menu${this.gridUidSelector}`); + } + + /** Initialize plugin. */ + init(cellMenuOptions?: CellMenu) { + this._addonOptions = { ...this._defaults, ...cellMenuOptions }; + + // sort all menu items by their position order when defined + this.sortMenuItems(this.sharedService.allColumns); + + const onClickHandler = this.grid.onClick; + (this._eventHandler as SlickEventHandler>).subscribe(onClickHandler, this.handleCellClick.bind(this) as EventListener); + + if (this._addonOptions.hideMenuOnScroll) { + const onScrollHandler = this.grid.onScroll; + (this._eventHandler as SlickEventHandler>).subscribe(onScrollHandler, this.closeMenu.bind(this) as EventListener); + } + } + + /** Dispose (destroy) of the plugin */ + dispose() { + this._eventHandler?.unsubscribeAll(); + this._bindEventService.unbindAll(); + this.pubSubService.unsubscribeAll(); + this._commandTitleElm?.remove(); + this._optionTitleElm?.remove(); + this.menuElement?.remove(); + } + + createMenu(event: DOMEvent) { + this.menuElement?.remove(); + this._menuElm = undefined; + const cell = this.grid.getCellFromEvent(event); + + if (cell) { + this._currentCell = cell.cell ?? 0; + this._currentRow = cell.row ?? 0; + const columnDef = this.grid.getColumns()[this._currentCell]; + const dataContext = this.grid.getDataItem(this._currentRow); + + const commandItems = this._addonOptions?.commandItems || []; + const optionItems = this._addonOptions?.optionItems || []; + + // make sure there's at least something to show before creating the Cell Menu + if (!columnDef || !columnDef.cellMenu || (!commandItems.length && !optionItems.length)) { + return; + } + + // Let the user modify the menu or cancel altogether, + // or provide alternative menu implementation. + const callbackArgs = { + cell: this._currentCell, + row: this._currentRow, + grid: this.grid, + // menu: this._pluginOptions, + } as MenuFromCellCallbackArgs; + + // delete any prior Cell Menu + this.closeMenu(event, callbackArgs); + + // execute optional callback method defined by the user, if it returns false then we won't go further and not open the cell menu + if (typeof event.stopPropagation === 'function') { + this.pubSubService.publish('cellMenu:onBeforeMenuShow', callbackArgs); + if (typeof this.addonOptions?.onBeforeMenuShow === 'function' && this.addonOptions?.onBeforeMenuShow(event, callbackArgs) === false) { + return; + } + } + + const maxHeight = isNaN(this.addonOptions.maxHeight as any) ? this.addonOptions.maxHeight : `${this.addonOptions.maxHeight ?? 0}px`; + + // create a new cell menu + this._menuElm = document.createElement('div'); + this._menuElm.className = `slick-cell-menu ${this.gridUid}`; + this._menuElm.style.maxHeight = maxHeight as string; + this._menuElm.style.width = `${+(this.addonOptions?.width ?? 0)}px`; + this._menuElm.style.top = `${(event as unknown as MouseEvent).pageY + 5}px`; + this._menuElm.style.left = `${(event as unknown as MouseEvent).pageX}px`; + this._menuElm.style.visibility = 'hidden'; + + const closeButtonElm = document.createElement('button'); + closeButtonElm.className = 'close'; + closeButtonElm.type = 'button'; + closeButtonElm.dataset.dismiss = 'slick-cell-menu'; + closeButtonElm.setAttribute('aria-label', 'Close'); + + const closeSpanElm = document.createElement('span'); + closeSpanElm.className = 'close'; + closeSpanElm.innerHTML = '×'; + closeSpanElm.setAttribute('aria-hidden', 'true'); + closeButtonElm.appendChild(closeSpanElm); + + // -- Option List section + if (!this.addonOptions.hideOptionSection && optionItems.length > 0) { + const optionMenuElm = document.createElement('div'); + optionMenuElm.className = 'slick-cell-menu-option-list'; + if (!this.addonOptions.hideCloseButton) { + this._bindEventService.bind(closeButtonElm, 'click', ((e: DOMEvent) => this.handleCloseButtonClicked(e)) as EventListener); + this._menuElm.appendChild(closeButtonElm); + } + this._menuElm.appendChild(optionMenuElm); + this.populateOptionItems( + this.addonOptions, + optionMenuElm, + optionItems, + { cell: this._currentCell, row: this._currentRow, column: columnDef, dataContext, grid: this.grid } + ); + } + + // -- Command List section + if (!this.addonOptions.hideCommandSection && commandItems.length > 0) { + const commandMenuElm = document.createElement('div'); + commandMenuElm.className = 'slick-cell-menu-command-list'; + if (!this.addonOptions.hideCloseButton && (optionItems.length === 0 || this.addonOptions.hideOptionSection)) { + this._bindEventService.bind(closeButtonElm, 'click', ((e: DOMEvent) => this.handleCloseButtonClicked(e)) as EventListener); + this._menuElm.appendChild(closeButtonElm); + } + this._menuElm.appendChild(commandMenuElm); + this.populateCommandItems( + this.addonOptions, + commandMenuElm, + commandItems, + { cell: this._currentCell, row: this._currentRow, column: columnDef, dataContext, grid: this.grid } + ); + } + + this._menuElm.style.visibility = 'visible'; + document.body.appendChild(this._menuElm); + + // execute optional callback method defined by the user + this.pubSubService.publish('cellMenu:onAfterMenuShow', callbackArgs); + if (typeof this.addonOptions?.onAfterMenuShow === 'function' && this.addonOptions?.onAfterMenuShow(event, callbackArgs) === false) { + return; + } + } + return this._menuElm; + } + + closeMenu(e: DOMEvent, args: MenuFromCellCallbackArgs) { + if (this.menuElement) { + if (typeof this.addonOptions?.onBeforeMenuClose === 'function' && this.addonOptions?.onBeforeMenuClose(e, args) === false) { + return; + } + this.hideMenu(); + } + } + + /** Hide the Cell Menu */ + hideMenu() { + this.menuElement?.remove(); + this._menuElm = null; + } + + setOptions(newOptions: CellMenu) { + this._addonOptions = { ...this._addonOptions, ...newOptions }; + } + + /** Translate the Cell Menu titles, we need to loop through all column definition to re-translate all list titles & all commands/options */ + translateCellMenu() { + const gridOptions = this.sharedService?.gridOptions; + const columnDefinitions = this.sharedService.allColumns; + + if (gridOptions?.enableTranslate && Array.isArray(columnDefinitions)) { + columnDefinitions.forEach((columnDef: Column) => { + if (columnDef?.cellMenu && (Array.isArray(columnDef.cellMenu.commandItems) || Array.isArray(columnDef.cellMenu.optionItems))) { + // get both items list + const columnCellMenuCommandItems: Array = columnDef.cellMenu.commandItems || []; + const columnCellMenuOptionItems: Array = columnDef.cellMenu.optionItems || []; + + // translate their titles only if they have a titleKey defined + if (columnDef.cellMenu.commandTitleKey) { + columnDef.cellMenu.commandTitle = this.extensionUtility.translateWhenEnabledAndServiceExist(columnDef.cellMenu.commandTitleKey, 'TEXT_COMMANDS') || columnDef.cellMenu.commandTitle; + } + if (columnDef.cellMenu.optionTitleKey) { + columnDef.cellMenu.optionTitle = this.extensionUtility.translateWhenEnabledAndServiceExist(columnDef.cellMenu.optionTitleKey, 'TEXT_COMMANDS') || columnDef.cellMenu.optionTitle; + } + + // translate both command/option items (whichever is provided) + this.extensionUtility.translateMenuItemsFromTitleKey(columnCellMenuCommandItems); + this.extensionUtility.translateMenuItemsFromTitleKey(columnCellMenuOptionItems); + + this.extensionUtility.translateItems(columnCellMenuCommandItems, 'titleKey', 'title'); + this.extensionUtility.translateItems(columnCellMenuOptionItems, 'titleKey', 'title'); + } + }); + } + } + + // -- + // event handlers + // ------------------ + + protected handleCellClick(event: DOMEvent, args: MenuCommandItemCallbackArgs) { + const cell = this.grid.getCellFromEvent(event); + if (cell) { + const dataContext = this.grid.getDataItem(cell.row); + const columnDef = this.grid.getColumns()[cell.cell]; + + // prevent event from bubbling but only on column that has a cell menu defined + if (columnDef?.cellMenu) { + event.preventDefault(); + } + + // merge the cellMenu of the column definition with the default properties + this._addonOptions = { ...this._addonOptions, ...columnDef.cellMenu }; + + // run the override function (when defined), if the result is false it won't go further + if (!args) { + args = {} as MenuCommandItemCallbackArgs; + } + args.column = columnDef; + args.dataContext = dataContext; + args.grid = this.grid; + if (!this.extensionUtility.runOverrideFunctionWhenExists(this._addonOptions.menuUsabilityOverride, args)) { + return; + } + + // create the DOM element + this._menuElm = this.createMenu(event); + + // reposition the menu to where the user clicked + if (this._menuElm) { + this.repositionMenu(event); + this._menuElm.style.visibility = 'visible'; + } + + // Hide the menu on outside click. + this._bindEventService.bind(document.body, 'mousedown', this.handleBodyMouseDown.bind(this) as EventListener); + } + } + + protected handleCloseButtonClicked(e: DOMEvent) { + if (!e.defaultPrevented) { + this.closeMenu(e, { cell: 0, row: 0, grid: this.grid, }); + } + } + + /** Mouse down handler when clicking anywhere in the DOM body */ + protected handleBodyMouseDown(e: DOMEvent) { + if ((this.menuElement !== e.target && !this.menuElement?.contains(e.target)) || e.target.className === 'close') { + this.closeMenu(e, { cell: this._currentCell, row: this._currentRow, grid: this.grid }); + } + } + + protected handleMenuItemCommandClick(e: DOMEvent, item: MenuCommandItem, row: number, cell: number) { + if (item?.command !== undefined && !item.disabled && !item.divider) { + const columnDef = this.grid.getColumns()[cell]; + const dataContext = this.grid.getDataItem(row); + + // user could execute a callback through 2 ways + // via the onCommand event and/or an action callback + const callbackArgs = { + cell, + row, + grid: this.grid, + command: item.command, + item, + column: columnDef, + dataContext, + } as MenuCommandItemCallbackArgs; + + // execute Cell Menu callback with command, + // we'll also execute optional user defined onCommand callback when provided + // this.executeCellMenuInternalCustomCommands(event, callbackArgs); + this.pubSubService.publish('cellMenu:onCommand', callbackArgs); + if (typeof this._addonOptions?.onCommand === 'function') { + this._addonOptions.onCommand(e, callbackArgs); + } + + // execute action callback when defined + if (typeof item.action === 'function') { + item.action.call(this, e, callbackArgs); + } + + // does the user want to leave open the Cell Menu after executing a command? + if (!e.defaultPrevented) { + this.closeMenu(e, { cell, row, grid: this.grid }); + } + } + } + + protected handleMenuItemOptionClick(event: DOMEvent, item: MenuOptionItem, row: number, cell: number) { + if (item?.option !== undefined && !item.disabled && !item.divider) { + if (!this.grid.getEditorLock().commitCurrentEdit()) { + return; + } + + const columnDef = this.grid.getColumns()[cell]; + const dataContext = this.grid.getDataItem(row); + + // user could execute a callback through 2 ways + // via the onOptionSelected event and/or an action callback + const callbackArgs = { + cell, + row, + grid: this.grid, + option: item.option, + item, + column: columnDef, + dataContext, + } as MenuOptionItemCallbackArgs; + + // execute Cell Menu callback with command, + // we'll also execute optional user defined onOptionSelected callback when provided + // this.executeCellMenuInternalCustomCommands(event, callbackArgs); + this.pubSubService.publish('cellMenu:onOptionSelected', callbackArgs); + if (typeof this._addonOptions?.onOptionSelected === 'function') { + this._addonOptions.onOptionSelected(event, callbackArgs); + } + + // execute action callback when defined + if (typeof item.action === 'function') { + item.action.call(this, event, callbackArgs); + } + + // does the user want to leave open the Cell Menu after executing a command? + if (!event.defaultPrevented) { + this.closeMenu(event, { cell, row, grid: this.grid }); + } + } + } + + // -- + // protected functions + // ------------------ + + protected calculateAvailableSpaceBottom(element: HTMLElement) { + let availableSpace = 0; + const windowHeight = window.innerHeight ?? 0; + const pageScrollTop = windowScrollPosition()?.top ?? 0; + const elmOffset = getHtmlElementOffset(element); + if (elmOffset) { + const elementOffsetTop = elmOffset.top ?? 0; + availableSpace = windowHeight - (elementOffsetTop - pageScrollTop); + } + return availableSpace; + } + + protected calculateAvailableSpaceTop(element: HTMLElement) { + let availableSpace = 0; + const pageScrollTop = windowScrollPosition()?.top ?? 0; + const elmOffset = getHtmlElementOffset(element); + if (elmOffset) { + const elementOffsetTop = elmOffset.top ?? 0; + availableSpace = elementOffsetTop - pageScrollTop; + } + return availableSpace; + } + + /** Construct the Option Items section. */ + protected populateOptionItems(cellMenu: CellMenu, optionMenuElm: HTMLElement, optionItems: Array, args: Partial) { + if (args && optionItems && cellMenu) { + // user could pass a title on top of the Options section + if (cellMenu?.optionTitle) { + this._optionTitleElm = document.createElement('div'); + this._optionTitleElm.className = 'title'; + this._optionTitleElm.textContent = cellMenu.optionTitle; + optionMenuElm.appendChild(this._optionTitleElm); + } + + for (let i = 0, ln = optionItems.length; i < ln; i++) { + const item = optionItems[i]; + + // run each override functions to know if the item is visible and usable + let isItemVisible = true; + let isItemUsable = true; + if (typeof item === 'object') { + isItemVisible = this.extensionUtility.runOverrideFunctionWhenExists(item.itemVisibilityOverride, args); + isItemUsable = this.extensionUtility.runOverrideFunctionWhenExists(item.itemUsabilityOverride, args); + } + + // if the result is not visible then there's no need to go further + if (!isItemVisible) { + continue; + } + + // when the override is defined, we need to use its result to update the disabled property + // so that "handleMenuItemOptionClick" has the correct flag and won't trigger an option clicked event + if (typeof item === 'object' && item.itemUsabilityOverride) { + item.disabled = isItemUsable ? false : true; + } + + const divOptionElm = document.createElement('div'); + divOptionElm.className = 'slick-cell-menu-item'; + if (typeof item === 'object' && hasData(item?.option)) { + divOptionElm.dataset.option = item.option; + } + optionMenuElm.appendChild(divOptionElm); + + if ((typeof item === 'object' && item.divider) || item === 'divider') { + divOptionElm.classList.add('slick-cell-menu-item-divider'); + continue; + } + + if (item.disabled) { + divOptionElm.classList.add('slick-cell-menu-item-disabled'); + } + + if (item.hidden) { + divOptionElm.classList.add('slick-cell-menu-item-hidden'); + } + + if (item.cssClass) { + divOptionElm.classList.add(...item.cssClass.split(' ')); + } + + if (item.tooltip) { + divOptionElm.title = item.tooltip; + } + + const iconElm = document.createElement('div'); + iconElm.className = 'slick-cell-menu-icon'; + divOptionElm.appendChild(iconElm); + + if (item.iconCssClass) { + iconElm.classList.add(...item.iconCssClass.split(' ')); + } + + if (item.iconImage) { + console.warn('[Slickgrid-Universal] The "iconImage" property of a Cell Menu item is now deprecated and will be removed in future version, consider using "iconCssClass" instead.'); + iconElm.style.backgroundImage = `url(${item.iconImage})`; + } + + const textElm = document.createElement('span'); + textElm.className = 'slick-cell-menu-content'; + textElm.textContent = typeof item === 'object' && item.title || ''; + divOptionElm.appendChild(textElm); + + if (item.textCssClass) { + textElm.classList.add(...item.textCssClass.split(' ')); + } + // execute command on menu item clicked + this._bindEventService.bind(divOptionElm, 'click', ((e: DOMEvent) => this.handleMenuItemOptionClick(e, item, this._currentRow, this._currentCell)) as EventListener); + } + } + } + + /** Construct the Command Items section. */ + protected populateCommandItems(cellMenu: CellMenu, commandMenuElm: HTMLElement, commandItems: Array, args: Partial) { + if (args && commandItems && cellMenu) { + // user could pass a title on top of the Commands section + if (cellMenu?.commandTitle) { + this._commandTitleElm = document.createElement('div'); + this._commandTitleElm.className = 'title'; + this._commandTitleElm.textContent = cellMenu.commandTitle; + commandMenuElm.appendChild(this._commandTitleElm); + } + + for (let i = 0, ln = commandItems.length; i < ln; i++) { + const item = commandItems[i]; + + // run each override functions to know if the item is visible and usable + let isItemVisible = true; + let isItemUsable = true; + if (typeof item === 'object') { + isItemVisible = this.extensionUtility.runOverrideFunctionWhenExists(item.itemVisibilityOverride, args); + isItemUsable = this.extensionUtility.runOverrideFunctionWhenExists(item.itemUsabilityOverride, args); + } + + // if the result is not visible then there's no need to go further + if (!isItemVisible) { + continue; + } + + // when the override is defined (and previously executed), we need to use its result to update the disabled property + // so that "handleMenuItemCommandClick" has the correct flag and won't trigger a command clicked event + if (typeof item === 'object' && item.itemUsabilityOverride) { + item.disabled = isItemUsable ? false : true; + } + + const divCommandElm = document.createElement('div'); + divCommandElm.className = 'slick-cell-menu-item'; + if (typeof item === 'object' && hasData(item?.command)) { + divCommandElm.dataset.command = item.command; + } + commandMenuElm.appendChild(divCommandElm); + + if ((typeof item === 'object' && item.divider) || item === 'divider') { + divCommandElm.classList.add('slick-cell-menu-item-divider'); + continue; + } + + if (item.disabled) { + divCommandElm.classList.add('slick-cell-menu-item-disabled'); + } + + if (item.hidden) { + divCommandElm.classList.add('slick-cell-menu-item-hidden'); + } + + if (item.cssClass) { + divCommandElm.classList.add(...item.cssClass.split(' ')); + } + + if (item.tooltip) { + divCommandElm.title = item.tooltip; + } + + const iconElm = document.createElement('div'); + iconElm.className = 'slick-cell-menu-icon'; + divCommandElm.appendChild(iconElm); + + if (item.iconCssClass) { + iconElm.classList.add(...item.iconCssClass.split(' ')); + } + + if (item.iconImage) { + console.warn('[Slickgrid-Universal] The "iconImage" property of a Cell Menu item is now deprecated and will be removed in future version, consider using "iconCssClass" instead.'); + iconElm.style.backgroundImage = `url(${item.iconImage})`; + } + + const textElm = document.createElement('span'); + textElm.className = 'slick-cell-menu-content'; + textElm.textContent = typeof item === 'object' && item.title || ''; + divCommandElm.appendChild(textElm); + + if (item.textCssClass) { + textElm.classList.add(...item.textCssClass.split(' ')); + } + // execute command on menu item clicked + this._bindEventService.bind(divCommandElm, 'click', ((e: DOMEvent) => this.handleMenuItemCommandClick(e, item, this._currentRow, this._currentCell)) as EventListener); + } + } + } + + protected repositionMenu(event: DOMEvent) { + if (this._menuElm && event.target) { + const parentElm = event.target.closest('.slick-cell') as HTMLDivElement; + let menuOffsetLeft = parentElm ? getHtmlElementOffset(parentElm)?.left ?? 0 : (event as unknown as MouseEvent).pageX; + let menuOffsetTop = parentElm ? getHtmlElementOffset(parentElm)?.top ?? 0 : (event as unknown as MouseEvent).pageY; + const parentCellWidth = +(parentElm.clientWidth ?? 0); + const menuHeight = this._menuElm?.clientHeight ?? 0; + const menuWidth = this._menuElm?.clientWidth || this._addonOptions.width || 0; + const rowHeight = +(this.gridOptions.rowHeight ?? 0); + const dropOffset = +(this._addonOptions.autoAdjustDropOffset ?? 0); + const sideOffset = +(this._addonOptions.autoAlignSideOffset ?? 0); + + // if autoAdjustDrop is enable, we first need to see what position the drop will be located (defaults to bottom) + // without necessary toggling it's position just yet, we just want to know the future position for calculation + if (this._addonOptions.autoAdjustDrop || this._addonOptions.alignDropDirection) { + // since we reposition menu below slick cell, we need to take it in consideration and do our calculation from that element + const spaceBottom = this.calculateAvailableSpaceBottom(parentElm); + const spaceTop = this.calculateAvailableSpaceTop(parentElm); + const spaceBottomRemaining = spaceBottom + dropOffset - rowHeight; + const spaceTopRemaining = spaceTop - dropOffset + rowHeight; + const dropPosition = ((spaceBottomRemaining < menuHeight) && (spaceTopRemaining > spaceBottomRemaining)) ? 'top' : 'bottom'; + if (dropPosition === 'top' || this._addonOptions.alignDropDirection === 'top') { + this._menuElm.classList.remove('dropdown'); + this._menuElm.classList.add('dropup'); + menuOffsetTop = menuOffsetTop - menuHeight - dropOffset; + } else { + this._menuElm.classList.remove('dropup'); + this._menuElm.classList.add('dropdown'); + menuOffsetTop = menuOffsetTop + rowHeight + dropOffset; + } + } + + // 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 (defaults to the right) + // to simulate an align left, we actually need to know the width of the drop menu + if (this._addonOptions.autoAlignSide || this._addonOptions.alignDropSide === 'left') { + const gridPos = this.grid.getGridPosition(); + const dropSide = (isNumber(menuWidth) || ((menuOffsetLeft + (+menuWidth)) >= gridPos.width)) ? 'left' : 'right'; + if (dropSide === 'left' || this._addonOptions.alignDropSide === 'left') { + this._menuElm.classList.remove('dropright'); + this._menuElm.classList.add('dropleft'); + menuOffsetLeft = (menuOffsetLeft - ((+menuWidth) - parentCellWidth) - sideOffset); + } else { + this._menuElm.classList.remove('dropleft'); + this._menuElm.classList.add('dropright'); + menuOffsetLeft = menuOffsetLeft + sideOffset; + } + } + + // ready to reposition the menu + this._menuElm.style.top = `${menuOffsetTop}px`; + this._menuElm.style.left = `${menuOffsetLeft}px`; + } + } + + sortMenuItems(columnDefinitions: Column[]) { + // sort both items list + columnDefinitions.forEach((columnDef: Column) => { + if (columnDef?.cellMenu?.commandItems) { + const columnCellMenuCommandItems: Array = columnDef.cellMenu.commandItems || []; + this.extensionUtility.sortItems(columnCellMenuCommandItems, 'positionOrder'); + } + if (columnDef?.cellMenu?.optionItems) { + const columnCellMenuOptionItems: Array = columnDef.cellMenu.optionItems || []; + this.extensionUtility.sortItems(columnCellMenuOptionItems, 'positionOrder'); + } + }); + } +} \ No newline at end of file diff --git a/packages/common/src/plugins/headerButton.plugin.ts b/packages/common/src/plugins/headerButton.plugin.ts index 9415cd317..501f4fab4 100644 --- a/packages/common/src/plugins/headerButton.plugin.ts +++ b/packages/common/src/plugins/headerButton.plugin.ts @@ -30,9 +30,9 @@ declare const Slick: SlickNamespace; * }]; */ export class HeaderButtonPlugin { + protected _addonOptions?: HeaderButton; protected _bindEventService: BindingEventService; protected _eventHandler!: SlickEventHandler; - protected _options?: HeaderButton; protected _buttonElms: HTMLDivElement[] = []; protected _defaults = { buttonCssClass: 'slick-header-button', @@ -46,6 +46,13 @@ export class HeaderButtonPlugin { this.init(sharedService.gridOptions.headerButton); } + get addonOptions(): HeaderButton { + return this._addonOptions as HeaderButton; + } + set addonOptions(newOptions: HeaderButton) { + this._addonOptions = newOptions; + } + get eventHandler(): SlickEventHandler { return this._eventHandler; } @@ -54,16 +61,9 @@ export class HeaderButtonPlugin { return this.sharedService.slickGrid; } - get options(): HeaderButton { - return this._options as HeaderButton; - } - set options(newOptions: HeaderButton) { - this._options = newOptions; - } - /** Initialize plugin. */ init(headerButtonOptions?: HeaderButton) { - this._options = { ...this._defaults, ...headerButtonOptions }; + this._addonOptions = { ...this._defaults, ...headerButtonOptions }; const onHeaderCellRenderedHandler = this.grid.onHeaderCellRendered; (this._eventHandler as SlickEventHandler>).subscribe(onHeaderCellRenderedHandler, this.handleHeaderCellRendered.bind(this)); @@ -110,12 +110,12 @@ export class HeaderButtonPlugin { // when the override is defined, we need to use its result to update the disabled property // so that 'handleMenuItemCommandClick' has the correct flag and won't trigger a command clicked event - if (Object.prototype.hasOwnProperty.call(button, 'itemUsabilityOverride')) { + if (typeof button === 'object' && button.itemUsabilityOverride) { button.disabled = isItemUsable ? false : true; } const buttonDivElm = document.createElement('div'); - buttonDivElm.className = this._options?.buttonCssClass ?? ''; + buttonDivElm.className = this._addonOptions?.buttonCssClass ?? ''; if (button.disabled) { buttonDivElm.classList.add('slick-header-button-disabled'); @@ -162,11 +162,11 @@ export class HeaderButtonPlugin { protected handleBeforeHeaderCellDestroy(_e: Event, args: { column: Column; node: HTMLElement; }) { const column = args.column; - if (column.header?.buttons && this._options?.buttonCssClass) { + if (column.header?.buttons && this._addonOptions?.buttonCssClass) { // 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. - const buttonCssClass = (this._options?.buttonCssClass || '').replace(/(\s+)/g, '.'); + const buttonCssClass = (this._addonOptions?.buttonCssClass || '').replace(/(\s+)/g, '.'); if (buttonCssClass) { args.node.querySelectorAll(`.${buttonCssClass}`).forEach(elm => elm.remove()); } @@ -192,9 +192,9 @@ export class HeaderButtonPlugin { button.action.call(this, event, callbackArgs); } - if (command !== null && !button.disabled && this._options?.onCommand) { + if (command !== null && !button.disabled && this._addonOptions?.onCommand) { this.pubSubService.publish('headerButton:onCommand', callbackArgs); - this._options.onCommand(event as any, callbackArgs); + this._addonOptions.onCommand(event as any, callbackArgs); // Update the header in case the user updated the button definition in the handler. this.grid.updateColumnHeader(columnDef.id); diff --git a/packages/common/src/plugins/headerMenu.plugin.ts b/packages/common/src/plugins/headerMenu.plugin.ts index 8141bb250..2d1c6ccff 100644 --- a/packages/common/src/plugins/headerMenu.plugin.ts +++ b/packages/common/src/plugins/headerMenu.plugin.ts @@ -15,7 +15,7 @@ import { SlickGrid, SlickNamespace, } from '../interfaces/index'; -import { arrayRemoveItemByIndex, emptyElement, getElementOffsetRelativeToParent, } from '../services/index'; +import { arrayRemoveItemByIndex, emptyElement, getElementOffsetRelativeToParent, hasData, } from '../services/index'; import { BindingEventService } from '../services/bindingEvent.service'; import { ExtensionUtility } from '../extensions/extensionUtility'; import { FilterService } from '../services/filter.service'; @@ -39,11 +39,11 @@ declare const Slick: SlickNamespace; * }]; */ export class HeaderMenuPlugin { + protected _addonOptions?: HeaderMenu; protected _activeHeaderColumnElm?: HTMLDivElement; protected _bindEventService: BindingEventService; protected _eventHandler!: SlickEventHandler; - protected _options?: HeaderMenu; - protected _menuElm?: HTMLDivElement; + protected _menuElm?: HTMLDivElement | null; protected _defaults = { autoAlign: true, autoAlignOffset: 0, @@ -70,6 +70,13 @@ export class HeaderMenuPlugin { this.init(sharedService.gridOptions.headerMenu); } + get addonOptions(): HeaderMenu { + return this._addonOptions as HeaderMenu; + } + set addonOptions(newOptions: HeaderMenu) { + this._addonOptions = newOptions; + } + get eventHandler(): SlickEventHandler { return this._eventHandler; } @@ -78,20 +85,21 @@ export class HeaderMenuPlugin { return this.sharedService.slickGrid; } - get menuElement(): HTMLDivElement | undefined { - return this._menuElm; + /** Getter for the grid uid */ + get gridUid(): string { + return this.grid?.getUID() ?? ''; } - - get options(): HeaderMenu { - return this._options as HeaderMenu; + get gridUidSelector(): string { + return this.gridUid ? `.${this.gridUid}` : ''; } - set options(newOptions: HeaderMenu) { - this._options = newOptions; + + get menuElement(): HTMLDivElement | undefined | null { + return this._menuElm; } /** Initialize plugin. */ init(headerMenuOptions?: HeaderMenu) { - this._options = { ...this._defaults, ...headerMenuOptions }; + this._addonOptions = { ...this._defaults, ...headerMenuOptions }; // when setColumns is called (could be via toggle filtering/sorting or anything else), // we need to recreate header menu items custom commands array before the `onHeaderCellRendered` gets called @@ -118,8 +126,8 @@ export class HeaderMenuPlugin { this._eventHandler?.unsubscribeAll(); this._bindEventService.unbindAll(); this.pubSubService.unsubscribeAll(); + this._menuElm = this._menuElm || document.body.querySelector(`.slick-header-menu${this.gridUidSelector}`); this._menuElm?.remove(); - this._menuElm = undefined; this._activeHeaderColumnElm = undefined; } @@ -164,7 +172,7 @@ export class HeaderMenuPlugin { // 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('headerMenu:onBeforeMenuShow', callbackArgs); - if (typeof this.options?.onBeforeMenuShow === 'function' && this.options?.onBeforeMenuShow(e, callbackArgs) === false) { + if (typeof this.addonOptions?.onBeforeMenuShow === 'function' && this.addonOptions?.onBeforeMenuShow(e, callbackArgs) === false) { return; } } @@ -172,7 +180,7 @@ export class HeaderMenuPlugin { if (!this._menuElm) { this._menuElm = document.createElement('div'); this._menuElm.className = 'slick-header-menu'; - this._menuElm.style.minWidth = `${this.options.minWidth}px`; + this._menuElm.style.minWidth = `${this.addonOptions.minWidth}px`; this.grid.getContainerNode()?.appendChild(this._menuElm); } @@ -203,23 +211,23 @@ export class HeaderMenuPlugin { if (menu && args.node) { // run the override function (when defined), if the result is false we won't go further - if (!this.extensionUtility.runOverrideFunctionWhenExists(this.options.menuUsabilityOverride, args)) { + if (!this.extensionUtility.runOverrideFunctionWhenExists(this.addonOptions.menuUsabilityOverride, args)) { return; } const headerButtonDivElm = document.createElement('div'); headerButtonDivElm.className = 'slick-header-menubutton'; - if (this.options.buttonCssClass) { - headerButtonDivElm.classList.add(...this.options.buttonCssClass.split(' ')); + if (this.addonOptions.buttonCssClass) { + headerButtonDivElm.classList.add(...this.addonOptions.buttonCssClass.split(' ')); } - if (this.options.buttonImage) { - headerButtonDivElm.style.backgroundImage = `url(${this.options.buttonImage})`; + if (this.addonOptions.buttonImage) { + headerButtonDivElm.style.backgroundImage = `url(${this.addonOptions.buttonImage})`; } - if (this.options.tooltip) { - headerButtonDivElm.title = this.options.tooltip; + if (this.addonOptions.tooltip) { + headerButtonDivElm.title = this.addonOptions.tooltip; } args.node.appendChild(headerButtonDivElm); @@ -265,8 +273,8 @@ export class HeaderMenuPlugin { // we'll also execute optional user defined onCommand callback when provided this.executeHeaderMenuInternalCommands(event, callbackArgs); this.pubSubService.publish('headerMenu:onCommand', callbackArgs); - if (typeof this.options?.onCommand === 'function') { - this.options.onCommand(event, callbackArgs); + if (typeof this.addonOptions?.onCommand === 'function') { + this.addonOptions.onCommand(event, callbackArgs); } // execute action callback when defined @@ -501,14 +509,14 @@ export class HeaderMenuPlugin { // when the override is defined, we need to use its result to update the disabled property // so that "handleMenuItemCommandClick" has the correct flag and won't trigger a command clicked event - if (typeof item === 'object' && Object.prototype.hasOwnProperty.call(item, 'itemUsabilityOverride')) { + if (typeof item === 'object' && item.itemUsabilityOverride) { item.disabled = isItemUsable ? false : true; } const liElm = document.createElement('div'); liElm.className = 'slick-header-menuitem'; - if (typeof item === 'object' && item.command) { - liElm.dataset.command = typeof item === 'object' && item.command || ''; + if (typeof item === 'object' && hasData(item?.command)) { + liElm.dataset.command = item.command; } this._menuElm?.appendChild(liElm); @@ -563,7 +571,7 @@ export class HeaderMenuPlugin { // execute optional callback method defined by the user this.pubSubService.publish('headerMenu:onAfterMenuShow', args); - if (typeof this.options?.onAfterMenuShow === 'function' && this.options?.onAfterMenuShow(e, args) === false) { + if (typeof this.addonOptions?.onAfterMenuShow === 'function' && this.addonOptions?.onAfterMenuShow(e, args) === false) { return; } @@ -581,14 +589,14 @@ export class HeaderMenuPlugin { // 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.options.autoAlign) { + 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.options?.autoAlignOffset ?? 0); + leftPos = leftPos + buttonElm.clientWidth - this._menuElm.clientWidth + (this.addonOptions?.autoAlignOffset ?? 0); } } - this._menuElm.style.top = `${(relativePos?.top ?? 0) + (this.options?.menuOffsetTop ?? 0) + buttonElm.clientHeight}px`; + this._menuElm.style.top = `${(relativePos?.top ?? 0) + (this.addonOptions?.menuOffsetTop ?? 0) + buttonElm.clientHeight}px`; this._menuElm.style.left = `${leftPos}px`; // mark the header as active to keep the highlighting. diff --git a/packages/common/src/plugins/index.ts b/packages/common/src/plugins/index.ts index a9bb1a7c1..ed9d16e9f 100644 --- a/packages/common/src/plugins/index.ts +++ b/packages/common/src/plugins/index.ts @@ -1,3 +1,4 @@ export * from './autoTooltip.plugin'; +export * from './cellMenu.plugin'; export * from './headerButton.plugin'; export * from './headerMenu.plugin'; \ No newline at end of file diff --git a/packages/common/src/services/__tests__/domUtilities.spec.ts b/packages/common/src/services/__tests__/domUtilities.spec.ts new file mode 100644 index 000000000..9fd698b7a --- /dev/null +++ b/packages/common/src/services/__tests__/domUtilities.spec.ts @@ -0,0 +1,60 @@ +import 'jest-extended'; +import { + getElementOffsetRelativeToParent, + getHtmlElementOffset, + windowScrollPosition, +} from '../Domutilities'; + +describe('Service/Utilies', () => { + describe('getElementOffsetRelativeToParent method', () => { + const parentDiv = document.createElement('div'); + const childDiv = document.createElement('div'); + parentDiv.innerHTML = ``; + document.body.appendChild(parentDiv); + + it('should return undefined when element if not a valid html element', () => { + const output = getElementOffsetRelativeToParent(null, null); + expect(output).toEqual(undefined); + }); + + it('should return top/left 0 when creating a new element in the document without positions', () => { + const output = getElementOffsetRelativeToParent(parentDiv, childDiv); + expect(output).toEqual({ top: 0, left: 0, bottom: 0, right: 0 }); + }); + + it('should return same top/left positions as defined in the document/window', () => { + jest.spyOn(parentDiv, 'getBoundingClientRect').mockReturnValue({ top: 20, bottom: 33, left: 25, right: 44 } as any); + jest.spyOn(childDiv, 'getBoundingClientRect').mockReturnValue({ top: 130, bottom: 70, left: 250, right: 66 } as any); + parentDiv.style.top = '10px'; + parentDiv.style.left = '25px'; + + const output = getElementOffsetRelativeToParent(parentDiv, childDiv); + expect(output).toEqual({ top: 110, left: 225, bottom: 37, right: 22 }); + }); + }); + + describe('getHtmlElementOffset method', () => { + const div = document.createElement('div'); + div.innerHTML = ``; + document.body.appendChild(div); + + it('should return undefined when element if not a valid html element', () => { + const output = getHtmlElementOffset(null); + expect(output).toEqual(undefined); + }); + + it('should return top/left 0 when creating a new element in the document without positions', () => { + const output = getHtmlElementOffset(div); + expect(output).toEqual({ top: 0, left: 0, bottom: 0, right: 0 }); + }); + + it('should return same top/left positions as defined in the document/window', () => { + jest.spyOn(div, 'getBoundingClientRect').mockReturnValue({ top: 10, left: 25 } as any); + div.style.top = '10px'; + div.style.left = '25px'; + + const output = getHtmlElementOffset(div); + expect(output).toEqual({ top: 10, left: 25 }); + }); + }); +}); diff --git a/packages/common/src/services/__tests__/extension.service.spec.ts b/packages/common/src/services/__tests__/extension.service.spec.ts index 4cd768ee5..eb6089531 100644 --- a/packages/common/src/services/__tests__/extension.service.spec.ts +++ b/packages/common/src/services/__tests__/extension.service.spec.ts @@ -1,10 +1,9 @@ import 'jest-extended'; import { ExtensionName } from '../../enums/index'; -import { Column, ExtensionModel, GridOption, SlickGrid, SlickHeaderMenu, SlickNamespace } from '../../interfaces/index'; +import { Column, ExtensionModel, GridOption, SlickGrid, SlickNamespace } from '../../interfaces/index'; import { CellExternalCopyManagerExtension, - CellMenuExtension, CheckboxSelectorExtension, ContextMenuExtension, DraggableGroupingExtension, @@ -16,7 +15,7 @@ import { } from '../../extensions'; import { BackendUtilityService, ExtensionService, FilterService, PubSubService, SharedService, SortService } from '..'; import { TranslateServiceStub } from '../../../../../test/translateServiceStub'; -import { AutoTooltipPlugin, HeaderButtonPlugin, HeaderMenuPlugin } from '../../plugins/index'; +import { AutoTooltipPlugin, CellMenuPlugin, HeaderButtonPlugin, HeaderMenuPlugin } from '../../plugins/index'; import { ColumnPickerControl, GridMenuControl } from '../../controls/index'; jest.mock('flatpickr', () => { }); @@ -44,9 +43,11 @@ const gridStub = { onBeforeDestroy: new Slick.Event(), onBeforeHeaderCellDestroy: new Slick.Event(), onBeforeSetColumns: new Slick.Event(), + onClick: new Slick.Event(), + onColumnsReordered: new Slick.Event(), onHeaderCellRendered: new Slick.Event(), onSetOptions: new Slick.Event(), - onColumnsReordered: new Slick.Event(), + onScroll: new Slick.Event(), onHeaderContextMenu: new Slick.Event(), } as unknown as SlickGrid; @@ -110,14 +111,6 @@ const extensionContextMenuStub = { ...extensionStub, translateContextMenu: jest.fn() }; -const extensionHeaderButtonStub = { - ...extensionStub, - translateHeaderMenu: jest.fn() -}; -const extensionHeaderMenuStub = { - ...extensionStub, - translateHeaderMenu: jest.fn() -}; const extensionRowMoveStub = { ...extensionStub, onBeforeMoveRows: jest.fn(), @@ -144,7 +137,6 @@ describe('ExtensionService', () => { sortServiceStub, // extensions extensionStub as unknown as CellExternalCopyManagerExtension, - extensionCellMenuStub as unknown as CellMenuExtension, extensionCheckboxSelectorStub as unknown as CheckboxSelectorExtension, extensionContextMenuStub as unknown as ContextMenuExtension, extensionStub as unknown as DraggableGroupingExtension, @@ -428,16 +420,20 @@ describe('ExtensionService', () => { }); it('should register the CellMenu addon when "enableCellMenu" is set in the grid options', () => { - const gridOptionsMock = { enableCellMenu: true } as GridOption; - const extSpy = jest.spyOn(extensionCellMenuStub, 'register').mockReturnValue(instanceMock); + const onRegisteredMock = jest.fn(); + const gridOptionsMock = { enableCellMenu: true, cellMenu: { onExtensionRegistered: onRegisteredMock } } as GridOption; const gridSpy = jest.spyOn(SharedService.prototype, 'gridOptions', 'get').mockReturnValue(gridOptionsMock); service.bindDifferentExtensions(); const output = service.getExtensionByName(ExtensionName.cellMenu); + const pluginInstance = service.getSlickgridAddonInstance(ExtensionName.cellMenu); + expect(onRegisteredMock).toHaveBeenCalledWith(expect.toBeObject()); + expect(output.instance instanceof CellMenuPlugin).toBeTrue(); expect(gridSpy).toHaveBeenCalled(); - expect(extSpy).toHaveBeenCalled(); - expect(output).toEqual({ name: ExtensionName.cellMenu, instance: instanceMock as unknown, class: extensionCellMenuStub } as ExtensionModel); + expect(pluginInstance).toBeTruthy(); + expect(output!.instance).toEqual(pluginInstance); + expect(output).toEqual({ name: ExtensionName.cellMenu, instance: pluginInstance, class: pluginInstance } as ExtensionModel); }); it('should register the ContextMenu addon when "enableContextMenu" is set in the grid options', () => { @@ -633,9 +629,16 @@ describe('ExtensionService', () => { }); it('should call the translateCellMenu method on the CellMenu Extension when service with same method name is called', () => { - const extSpy = jest.spyOn(extensionCellMenuStub, 'translateCellMenu'); + const gridOptionsMock = { enableCellMenu: true, cellMenu: {} } as GridOption; + jest.spyOn(SharedService.prototype, 'gridOptions', 'get').mockReturnValue(gridOptionsMock); + jest.spyOn(SharedService.prototype, 'visibleColumns', 'get').mockReturnValue([]); + + service.bindDifferentExtensions(); + const pluginInstance = service.getSlickgridAddonInstance(ExtensionName.cellMenu); + const translateSpy = jest.spyOn(pluginInstance, 'translateCellMenu'); service.translateCellMenu(); - expect(extSpy).toHaveBeenCalled(); + + expect(translateSpy).toHaveBeenCalled(); }); it('should call the translateContextMenu method on the ContextMenu Extension when service with same method name is called', () => { @@ -819,7 +822,6 @@ describe('ExtensionService', () => { sortServiceStub, // extensions extensionStub as unknown as CellExternalCopyManagerExtension, - extensionStub as unknown as CellMenuExtension, extensionStub as unknown as CheckboxSelectorExtension, extensionStub as unknown as ContextMenuExtension, extensionStub as unknown as DraggableGroupingExtension, diff --git a/packages/common/src/services/__tests__/utilities.spec.ts b/packages/common/src/services/__tests__/utilities.spec.ts index dc95b0129..e7dda0c0c 100644 --- a/packages/common/src/services/__tests__/utilities.spec.ts +++ b/packages/common/src/services/__tests__/utilities.spec.ts @@ -21,8 +21,6 @@ import { findOrDefault, formatNumber, getDescendantProperty, - getElementOffsetRelativeToParent, - getHtmlElementOffset, getTranslationPrefix, htmlEncode, htmlEntityDecode, @@ -589,58 +587,6 @@ describe('Service/Utilies', () => { }); }); - describe('getElementOffsetRelativeToParent method', () => { - const parentDiv = document.createElement('div'); - const childDiv = document.createElement('div'); - parentDiv.innerHTML = ``; - document.body.appendChild(parentDiv); - - it('should return undefined when element if not a valid html element', () => { - const output = getElementOffsetRelativeToParent(null, null); - expect(output).toEqual(undefined); - }); - - it('should return top/left 0 when creating a new element in the document without positions', () => { - const output = getElementOffsetRelativeToParent(parentDiv, childDiv); - expect(output).toEqual({ top: 0, left: 0, bottom: 0, right: 0 }); - }); - - it('should return same top/left positions as defined in the document/window', () => { - jest.spyOn(parentDiv, 'getBoundingClientRect').mockReturnValue({ top: 20, bottom: 33, left: 25, right: 44 } as any); - jest.spyOn(childDiv, 'getBoundingClientRect').mockReturnValue({ top: 130, bottom: 70, left: 250, right: 66 } as any); - parentDiv.style.top = '10px'; - parentDiv.style.left = '25px'; - - const output = getElementOffsetRelativeToParent(parentDiv, childDiv); - expect(output).toEqual({ top: 110, left: 225, bottom: 37, right: 22 }); - }); - }); - - describe('getHtmlElementOffset method', () => { - const div = document.createElement('div'); - div.innerHTML = ``; - document.body.appendChild(div); - - it('should return undefined when element if not a valid html element', () => { - const output = getHtmlElementOffset(null); - expect(output).toEqual(undefined); - }); - - it('should return top/left 0 when creating a new element in the document without positions', () => { - const output = getHtmlElementOffset(div); - expect(output).toEqual({ top: 0, left: 0, bottom: 0, right: 0 }); - }); - - it('should return same top/left positions as defined in the document/window', () => { - jest.spyOn(div, 'getBoundingClientRect').mockReturnValue({ top: 10, left: 25 } as any); - div.style.top = '10px'; - div.style.left = '25px'; - - const output = getHtmlElementOffset(div); - expect(output).toEqual({ top: 10, left: 25 }); - }); - }); - describe('getDescendantProperty method', () => { let obj = {}; beforeEach(() => { diff --git a/packages/common/src/services/domUtilities.ts b/packages/common/src/services/domUtilities.ts index bf6db1b0b..2c62c7047 100644 --- a/packages/common/src/services/domUtilities.ts +++ b/packages/common/src/services/domUtilities.ts @@ -1,5 +1,5 @@ import { SearchTerm } from '../enums/index'; -import { Column, SelectOption, SlickGrid } from '../interfaces/index'; +import { Column, HtmlElementPosition, SelectOption, SlickGrid, } from '../interfaces/index'; import { TranslaterService } from './translater.service'; import { htmlEncode, sanitizeTextByAvailableSanitizer } from './utilities'; @@ -125,4 +125,50 @@ export function buildSelectEditorOrFilterDomElement(type: 'editor' | 'filter', c selectElement.appendChild(selectOptionsFragment); return { selectElement, hasFoundSearchTerm }; +} + +/** Get offset of HTML element relative to a parent element */ +export function getElementOffsetRelativeToParent(parentElm: HTMLElement | null, childElm: HTMLElement | null) { + if (!parentElm || !childElm) { + return undefined; + } + const parentPos = parentElm.getBoundingClientRect(); + const childPos = childElm.getBoundingClientRect(); + return { + top: childPos.top - parentPos.top, + right: childPos.right - parentPos.right, + bottom: childPos.bottom - parentPos.bottom, + left: childPos.left - parentPos.left, + }; +} + +/** Get HTML element offset with pure JS */ +export function getHtmlElementOffset(element: HTMLElement): HtmlElementPosition | undefined { + if (!element) { + return undefined; + } + const rect = element?.getBoundingClientRect?.(); + let top = 0; + let left = 0; + let bottom = 0; + let right = 0; + + if (rect?.top !== undefined && rect.left !== undefined) { + top = rect.top + window.pageYOffset; + left = rect.left + window.pageXOffset; + right = rect.right; + bottom = rect.bottom; + } + return { top, left, bottom, right }; +} + +/** + * Get the Window Scroll top/left Position + * @returns + */ +export function windowScrollPosition(): { left: number; top: number; } { + return { + left: window.pageXOffset || document.documentElement.scrollLeft || 0, + top: window.pageYOffset || document.documentElement.scrollTop || 0, + }; } \ No newline at end of file diff --git a/packages/common/src/services/extension.service.ts b/packages/common/src/services/extension.service.ts index 0fbb7c516..5e6cee170 100644 --- a/packages/common/src/services/extension.service.ts +++ b/packages/common/src/services/extension.service.ts @@ -7,7 +7,6 @@ import { Column, Extension, ExtensionModel, GridOption, SlickRowSelectionModel, import { ExtensionList, ExtensionName, SlickControlList, SlickPluginList } from '../enums/index'; import { CellExternalCopyManagerExtension, - CellMenuExtension, CheckboxSelectorExtension, ContextMenuExtension, DraggableGroupingExtension, @@ -19,7 +18,7 @@ import { } from '../extensions/index'; import { SharedService } from './shared.service'; import { TranslaterService } from './translater.service'; -import { AutoTooltipPlugin, HeaderButtonPlugin, HeaderMenuPlugin } from '../plugins/index'; +import { AutoTooltipPlugin, CellMenuPlugin, HeaderButtonPlugin, HeaderMenuPlugin } from '../plugins/index'; import { ColumnPickerControl, GridMenuControl } from '../controls/index'; import { FilterService } from './filter.service'; import { PubSubService } from './pubSub.service'; @@ -32,6 +31,7 @@ interface ExtensionWithColumnIndexPosition { } export class ExtensionService { + protected _cellMenuPlugin?: CellMenuPlugin; protected _columnPickerControl?: ColumnPickerControl; protected _gridMenuControl?: GridMenuControl; protected _headerMenuPlugin?: HeaderMenuPlugin; @@ -53,7 +53,6 @@ export class ExtensionService { protected readonly sortService: SortService, protected readonly cellExternalCopyExtension: CellExternalCopyManagerExtension, - protected readonly cellMenuExtension: CellMenuExtension, protected readonly checkboxSelectorExtension: CheckboxSelectorExtension, protected readonly contextMenuExtension: ContextMenuExtension, protected readonly draggableGroupingExtension: DraggableGroupingExtension, @@ -152,10 +151,13 @@ export class ExtensionService { } // (Action) Cell Menu Plugin - if (this.gridOptions.enableCellMenu && this.cellMenuExtension && this.cellMenuExtension.register) { - const instance = this.cellMenuExtension.register(); - if (instance) { - this._extensionList[ExtensionName.cellMenu] = { name: ExtensionName.cellMenu, class: this.cellMenuExtension, instance }; + if (this.gridOptions.enableCellMenu) { + this._cellMenuPlugin = new CellMenuPlugin(this.extensionUtility, this.pubSubService, this.sharedService); + if (this._cellMenuPlugin) { + if (this.gridOptions.cellMenu?.onExtensionRegistered) { + this.gridOptions.cellMenu.onExtensionRegistered(this._cellMenuPlugin); + } + this._extensionList[ExtensionName.cellMenu] = { name: ExtensionName.cellMenu, class: this._cellMenuPlugin, instance: this._cellMenuPlugin }; } } @@ -357,9 +359,7 @@ export class ExtensionService { /** Translate the Cell Menu titles, we need to loop through all column definition to re-translate them */ translateCellMenu() { - if (this.cellMenuExtension && this.cellMenuExtension.translateCellMenu) { - this.cellMenuExtension.translateCellMenu(); - } + this._cellMenuPlugin?.translateCellMenu?.(); } /** Translate the Column Picker and it's last 2 checkboxes */ diff --git a/packages/common/src/services/resizer.service.ts b/packages/common/src/services/resizer.service.ts index 32bb79afe..d821f32ab 100644 --- a/packages/common/src/services/resizer.service.ts +++ b/packages/common/src/services/resizer.service.ts @@ -11,7 +11,7 @@ import { SlickGrid, SlickNamespace, } from '../interfaces/index'; -import { getHtmlElementOffset, sanitizeHtmlToText, } from '../services/utilities'; +import { getHtmlElementOffset, sanitizeHtmlToText, } from '../services/index'; import { parseFormatterWhenExist } from '../formatters/formatterUtilities'; import { PubSubService, } from '../services/pubSub.service'; diff --git a/packages/common/src/services/utilities.ts b/packages/common/src/services/utilities.ts index 859e4d7a8..68c7b9d4d 100644 --- a/packages/common/src/services/utilities.ts +++ b/packages/common/src/services/utilities.ts @@ -6,7 +6,7 @@ const moment = (moment_ as any)['default'] || moment_; // patch to fix rollup "m import { Constants } from '../constants'; import { FieldType, OperatorString, OperatorType } from '../enums/index'; -import { EventSubscription, GridOption, HtmlElementPosition } from '../interfaces/index'; +import { EventSubscription, GridOption, } from '../interfaces/index'; import { Observable, RxJsFacade, Subject, Subscription } from './rxjsFacade'; /** @@ -290,6 +290,11 @@ export function findItemInTreeStructure(treeArray: T[], predicate: (ite return undefined; } +/** Check if a value has any data (undefined, null or empty string will return false... but false boolean is consider as valid data) */ +export function hasData(value: any): boolean { + return value !== undefined && value !== null && value !== ''; +} + /** * HTML encode using jQuery with a
* Create a in-memory div, set it's inner text(which jQuery automatically encodes) @@ -338,7 +343,7 @@ export function htmlEncodedStringWithPadding(inputStr: string, paddingLength: nu * Check if input value is a number, by default it won't be a strict checking * but optionally we could check for strict equality, for example in strict "3" will return False but without strict it will return True * @param value - input value of any type - * @param strict - when using strict it also check for strict equality, for example in strict "3" will return but without strict it will return true + * @param strict - when using strict it also check for strict equality, for example in strict "3" would return False but without strict it would return True */ export function isNumber(value: any, strict = false) { if (strict) { @@ -1017,41 +1022,6 @@ export function findOrDefault(array: T[], logic: (item: T) => boolean, return array; } -/** Get offset of HTML element relative to a parent element */ -export function getElementOffsetRelativeToParent(parentElm: HTMLElement | null, childElm: HTMLElement | null) { - if (!parentElm || !childElm) { - return undefined; - } - const parentPos = parentElm.getBoundingClientRect(); - const childPos = childElm.getBoundingClientRect(); - return { - top: childPos.top - parentPos.top, - right: childPos.right - parentPos.right, - bottom: childPos.bottom - parentPos.bottom, - left: childPos.left - parentPos.left, - }; -} - -/** Get HTML element offset with pure JS */ -export function getHtmlElementOffset(element: HTMLElement): HtmlElementPosition | undefined { - if (!element) { - return undefined; - } - const rect = element?.getBoundingClientRect?.(); - let top = 0; - let left = 0; - let bottom = 0; - let right = 0; - - if (rect?.top !== undefined && rect.left !== undefined) { - top = rect.top + window.pageYOffset; - left = rect.left + window.pageXOffset; - right = rect.right; - bottom = rect.bottom; - } - return { top, left, bottom, right }; -} - /** * Converts a string from camelCase to snake_case (underscore) case * @param str the string to convert diff --git a/packages/vanilla-bundle/src/components/slick-vanilla-grid-bundle.ts b/packages/vanilla-bundle/src/components/slick-vanilla-grid-bundle.ts index 649dbf68d..103d57ca2 100644 --- a/packages/vanilla-bundle/src/components/slick-vanilla-grid-bundle.ts +++ b/packages/vanilla-bundle/src/components/slick-vanilla-grid-bundle.ts @@ -34,7 +34,6 @@ import { // extensions CheckboxSelectorExtension, CellExternalCopyManagerExtension, - CellMenuExtension, ContextMenuExtension, DraggableGroupingExtension, ExtensionUtility, @@ -356,7 +355,6 @@ export class SlickVanillaGridBundle { // extensions const cellExternalCopyManagerExtension = new CellExternalCopyManagerExtension(this.extensionUtility, this.sharedService); - const cellMenuExtension = new CellMenuExtension(this.extensionUtility, this.sharedService, this.translaterService); const contextMenuExtension = new ContextMenuExtension(this.extensionUtility, this.sharedService, this.treeDataService, this.translaterService); const checkboxExtension = new CheckboxSelectorExtension(this.sharedService); const draggableGroupingExtension = new DraggableGroupingExtension(this.extensionUtility, this.sharedService); @@ -371,7 +369,6 @@ export class SlickVanillaGridBundle { this._eventPubSubService, this.sortService, cellExternalCopyManagerExtension, - cellMenuExtension, checkboxExtension, contextMenuExtension, draggableGroupingExtension, @@ -739,11 +736,12 @@ export class SlickVanillaGridBundle { this.translaterService.addPubSubMessaging(this._eventPubSubService); } - // translate some of them on first load, then on each language change + // translate them all on first load, then on each language change if (gridOptions.enableTranslate) { + this.extensionService.translateAllExtensions(); + this.translateCustomFooterTexts(); this.translateColumnHeaderTitleKeys(); this.translateColumnGroupKeys(); - this.translateCustomFooterTexts(); } // on locale change, we have to manually translate the Headers, GridMenu diff --git a/test/cypress/integration/example03.spec.js b/test/cypress/integration/example03.spec.js index 6a4d8ddac..a32f87a50 100644 --- a/test/cypress/integration/example03.spec.js +++ b/test/cypress/integration/example03.spec.js @@ -1,7 +1,7 @@ /// describe('Example 03 - Draggable Grouping', { retries: 1 }, () => { - const fullTitles = ['', 'Title', 'Duration', 'Cost', '% Complete', 'Start', 'Finish', 'Effort Driven', 'Action']; + const fullTitles = ['', 'Title', 'Duration', 'Cost', '% Complete', 'Start', 'Finish', 'Effort-Driven', 'Action']; const GRID_ROW_HEIGHT = 33; it('should display Example title', () => { @@ -17,6 +17,21 @@ describe('Example 03 - Draggable Grouping', { retries: 1 }, () => { .each(($child, index) => expect($child.text()).to.eq(fullTitles[index])); }); + it('should open the Cell Menu on 2nd and 3rd row and change the Effort-Driven to "True" and expect the cell to be updated and have checkmark to be enabled', () => { + cy.get(`[style="top:${GRID_ROW_HEIGHT * 1}px"] > .slick-cell:nth(1)`).should('contain', 'Task 1'); + cy.get(`[style="top:${GRID_ROW_HEIGHT * 1}px"] > .slick-cell:nth(8)`).find('.checkmark-icon').should('have.length', 0); + cy.get(`[style="top:${GRID_ROW_HEIGHT * 2}px"] > .slick-cell:nth(1)`).should('contain', 'Task 2'); + cy.get(`[style="top:${GRID_ROW_HEIGHT * 2}px"] > .slick-cell:nth(8)`).find('.checkmark-icon').should('have.length', 0); + + cy.get('.grid3').find(`[style="top:${GRID_ROW_HEIGHT * 1}px"] > .slick-cell:nth(8)`).contains('Action').click({ force: true }); + cy.get('.slick-cell-menu .slick-cell-menu-option-list .slick-cell-menu-item').contains('True').click(); + cy.get('.grid3').find(`[style="top:${GRID_ROW_HEIGHT * 2}px"] > .slick-cell:nth(8)`).contains('Action').click({ force: true }); + cy.get('.slick-cell-menu .slick-cell-menu-option-list .slick-cell-menu-item').contains('True').click(); + + cy.get(`[style="top:${GRID_ROW_HEIGHT * 1}px"] > .slick-cell:nth(7)`).find('.checkmark-icon').should('have.length', 1); + cy.get(`[style="top:${GRID_ROW_HEIGHT * 2}px"] > .slick-cell:nth(7)`).find('.checkmark-icon').should('have.length', 1); + }); + describe('Grouping Tests', () => { it('should "Group by Duration & sort groups by value" then Collapse All and expect only group titles', () => { cy.get('[data-test="add-50k-rows-btn"]').click(); @@ -61,9 +76,9 @@ describe('Example 03 - Draggable Grouping', { retries: 1 }, () => { cy.get(`[style="top:${GRID_ROW_HEIGHT * 2}px"] > .slick-cell:nth(2)`).should('contain', '0'); }); - it('should show 2 column titles (Duration, Effort Driven) shown in the pre-header section', () => { + it('should show 2 column titles (Duration, Effort-Driven) shown in the pre-header section', () => { cy.get('.slick-dropped-grouping:nth(0) div').contains('Duration'); - cy.get('.slick-dropped-grouping:nth(1) div').contains('Effort Driven'); + cy.get('.slick-dropped-grouping:nth(1) div').contains('Effort-Driven'); }); it('should be able to drag and swap grouped column titles inside the pre-header', () => { @@ -72,7 +87,7 @@ describe('Example 03 - Draggable Grouping', { retries: 1 }, () => { .trigger('mousedown', 'bottom', { which: 1 }); cy.get('.slick-dropped-grouping:nth(1) div') - .contains('Effort Driven') + .contains('Effort-Driven') .trigger('mousemove', 'bottomRight') .trigger('mouseup', 'bottomRight', { force: true }); }); diff --git a/test/cypress/integration/example04.spec.js b/test/cypress/integration/example04.spec.js index b4a4be7f1..567d634b4 100644 --- a/test/cypress/integration/example04.spec.js +++ b/test/cypress/integration/example04.spec.js @@ -4,6 +4,7 @@ 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']; + const GRID_ROW_HEIGHT = 45; it('should display Example title', () => { cy.visit(`${Cypress.config('baseExampleUrl')}/example04`); @@ -218,4 +219,46 @@ describe('Example 04 - Frozen Grid', { retries: 1 }, () => { cy.get('.grid-canvas-left > [style="top:0px"] > .slick-cell:nth(3)').should('contain', '2009-01-01'); cy.get('.grid-canvas-left > [style="top:0px"] > .slick-cell:nth(4)').should('contain', '2009-05-05'); }); + + it('should open the Cell Menu on 2nd and 3rd row and change the Effort-Driven to "True" and expect the cell to be updated and have checkmark icon', () => { + cy.get(`[style="top:${GRID_ROW_HEIGHT * 1}px"] > .slick-cell:nth(1)`).should('contain', 'Task 1'); + cy.get(`[style="top:${GRID_ROW_HEIGHT * 1}px"] > .slick-cell:nth(8)`).find('.checkmark-icon').should('have.length', 0); + cy.get(`[style="top:${GRID_ROW_HEIGHT * 2}px"] > .slick-cell:nth(1)`).should('contain', 'Task 2'); + cy.get(`[style="top:${GRID_ROW_HEIGHT * 2}px"] > .slick-cell:nth(8)`).find('.checkmark-icon').should('have.length', 0); + + cy.get('.grid4').find(`[style="top:${GRID_ROW_HEIGHT * 1}px"] > .slick-cell:nth(8)`).contains('Action').click({ force: true }); + cy.get('.slick-cell-menu .slick-cell-menu-option-list .slick-cell-menu-item').contains('True').click(); + cy.get('.grid4').find(`[style="top:${GRID_ROW_HEIGHT * 2}px"] > .slick-cell:nth(8)`).contains('Action').click({ force: true }); + cy.get('.slick-cell-menu .slick-cell-menu-option-list .slick-cell-menu-item').contains('True').click(); + + cy.get(`[style="top:${GRID_ROW_HEIGHT * 1}px"] > .slick-cell:nth(5)`).find('.checkmark-icon').should('have.length', 1); + cy.get(`[style="top:${GRID_ROW_HEIGHT * 2}px"] > .slick-cell:nth(5)`).find('.checkmark-icon').should('have.length', 1); + }); + + it('should open the Cell Menu on 2nd and 3rd row and change the Effort-Driven to "False" and expect the cell to be updated and no longer have checkmark', () => { + cy.get(`[style="top:${GRID_ROW_HEIGHT * 1}px"] > .slick-cell:nth(5)`).find('.checkmark-icon').should('have.length', 1); + cy.get(`[style="top:${GRID_ROW_HEIGHT * 2}px"] > .slick-cell:nth(5)`).find('.checkmark-icon').should('have.length', 1); + + cy.get('.grid4').find(`[style="top:${GRID_ROW_HEIGHT * 1}px"] > .slick-cell:nth(8)`).contains('Action').click({ force: true }); + cy.get('.slick-cell-menu .slick-cell-menu-option-list .slick-cell-menu-item').contains('False').click(); + cy.get('.grid4').find(`[style="top:${GRID_ROW_HEIGHT * 2}px"] > .slick-cell:nth(8)`).contains('Action').click({ force: true }); + cy.get('.slick-cell-menu .slick-cell-menu-option-list .slick-cell-menu-item').contains('False').click(); + + cy.get(`[style="top:${GRID_ROW_HEIGHT * 1}px"] > .slick-cell:nth(5)`).find('.checkmark-icon').should('have.length', 0); + cy.get(`[style="top:${GRID_ROW_HEIGHT * 2}px"] > .slick-cell:nth(5)`).find('.checkmark-icon').should('have.length', 0); + }); + + it('should open the Cell Menu and delete Row 3 and 4 from the Cell Menu', () => { + const confirmStub = cy.stub(); + cy.on('window:confirm', confirmStub); + + cy.get(`[style="top:${GRID_ROW_HEIGHT * 3}px"] > .slick-cell:nth(1)`).should('contain', 'Task 3'); + cy.get(`[style="top:${GRID_ROW_HEIGHT * 4}px"] > .slick-cell:nth(1)`).should('contain', 'Task 4'); + + cy.get('.grid4').find(`[style="top:${GRID_ROW_HEIGHT * 3}px"] > .slick-cell:nth(8)`).contains('Action').click({ force: true }); + cy.get('.slick-cell-menu .slick-cell-menu-command-list .slick-cell-menu-item').contains('Delete Row').click() + .then(() => expect(confirmStub.getCall(0)).to.be.calledWith('Do you really want to delete row (4) with "Task 3"?')); + + cy.get(`[style="top:${GRID_ROW_HEIGHT * 3}px"] > .slick-cell:nth(1)`).should('contain', 'Task 4'); + }); }); diff --git a/test/cypress/integration/example07.spec.js b/test/cypress/integration/example07.spec.js index b5ac5aa25..3a737559c 100644 --- a/test/cypress/integration/example07.spec.js +++ b/test/cypress/integration/example07.spec.js @@ -6,7 +6,7 @@ function removeExtraSpaces(textS) { describe('Example 07 - Row Move & Checkbox Selector Selector Plugins', { retries: 1 }, () => { const GRID_ROW_HEIGHT = 45; - const fullTitles = ['', '', 'Title', 'Duration', '% Complete', 'Start', 'Finish', 'Completed', 'Prerequisites']; + const fullTitles = ['', '', 'Title', 'Action', 'Duration', '% Complete', 'Start', 'Finish', 'Completed', 'Prerequisites']; it('should display Example title', () => { cy.visit(`${Cypress.config('baseExampleUrl')}/example07`); @@ -163,30 +163,30 @@ describe('Example 07 - Row Move & Checkbox Selector Selector Plugins', { retries .click(); // change duration - cy.get(`[style="top:${GRID_ROW_HEIGHT * 2}px"] > .slick-cell:nth(3)`).should('contain', 'day').click(); + cy.get(`[style="top:${GRID_ROW_HEIGHT * 2}px"] > .slick-cell:nth(4)`).should('contain', 'day').click(); cy.get('.editor-duration').type('2222').type('{enter}'); - cy.get(`[style="top:${GRID_ROW_HEIGHT * 2}px"] > .slick-cell:nth(3)`).should('contain', '2222 days'); + cy.get(`[style="top:${GRID_ROW_HEIGHT * 2}px"] > .slick-cell:nth(4)`).should('contain', '2222 days'); // change % complete - cy.get(`[style="top:${GRID_ROW_HEIGHT * 2}px"] > .slick-cell:nth(4)`).click(); + cy.get(`[style="top:${GRID_ROW_HEIGHT * 2}px"] > .slick-cell:nth(5)`).click(); cy.get('.slider-editor input[type=range]').as('range').invoke('val', 25).trigger('change'); - cy.get(`[style="top:${GRID_ROW_HEIGHT * 2}px"] > .slick-cell:nth(4)`).should('contain', '25'); + cy.get(`[style="top:${GRID_ROW_HEIGHT * 2}px"] > .slick-cell:nth(5)`).should('contain', '25'); // change Finish date - cy.get(`[style="top:${GRID_ROW_HEIGHT * 2}px"] > .slick-cell:nth(6)`).should('contain', '2009-01-05').click(); + cy.get(`[style="top:${GRID_ROW_HEIGHT * 2}px"] > .slick-cell:nth(7)`).should('contain', '2009-01-05').click(); cy.get('.flatpickr-calendar:visible .flatpickr-day').contains('22').click('bottom', { force: true }); - cy.get(`[style="top:${GRID_ROW_HEIGHT * 2}px"] > .slick-cell:nth(6)`).should('contain', '2009-01-22'); + cy.get(`[style="top:${GRID_ROW_HEIGHT * 2}px"] > .slick-cell:nth(7)`).should('contain', '2009-01-22'); cy.get('.slick-viewport.slick-viewport-top.slick-viewport-left') .scrollTo('top'); }); it('should dynamically add 2x new "Title" columns', () => { - const updatedTitles = ['', '', 'Title', 'Duration', '% Complete', 'Start', 'Finish', 'Completed', 'Prerequisites', 'Title', 'Title']; + const updatedTitles = ['', '', 'Title', 'Action', 'Duration', '% Complete', 'Start', 'Finish', 'Completed', 'Prerequisites', 'Title', 'Title']; cy.get(`[style="top:${GRID_ROW_HEIGHT * 0}px"] > .slick-cell:nth(2)`).should('contain', 'Task 0'); - cy.get(`[style="top:${GRID_ROW_HEIGHT * 0}px"] > .slick-cell:nth(9)`).should('not.exist'); cy.get(`[style="top:${GRID_ROW_HEIGHT * 0}px"] > .slick-cell:nth(10)`).should('not.exist'); + cy.get(`[style="top:${GRID_ROW_HEIGHT * 0}px"] > .slick-cell:nth(11)`).should('not.exist'); cy.get(`[style="top:${GRID_ROW_HEIGHT * 0}px"] > .slick-cell:nth(2)`) .should('contain', 'Task 0') @@ -205,12 +205,12 @@ describe('Example 07 - Row Move & Checkbox Selector Selector Plugins', { retries .should('have.length', 9); cy.get(`[style="top:${GRID_ROW_HEIGHT * 0}px"] > .slick-cell:nth(2)`).should('contain', 'Task 0'); - cy.get(`[style="top:${GRID_ROW_HEIGHT * 0}px"] > .slick-cell:nth(9)`).should('contain', 'Task 0'); cy.get(`[style="top:${GRID_ROW_HEIGHT * 0}px"] > .slick-cell:nth(10)`).should('contain', 'Task 0'); + cy.get(`[style="top:${GRID_ROW_HEIGHT * 0}px"] > .slick-cell:nth(11)`).should('contain', 'Task 0'); }); it('should open Grid Menu and expect new columns to be added to the column picker section', () => { - const updatedTitles = ['', '', 'Title', 'Duration', '% Complete', 'Start', 'Finish', 'Completed', 'Prerequisites', 'Title', 'Title']; + const updatedTitles = ['', '', 'Title', 'Action', 'Duration', '% Complete', 'Start', 'Finish', 'Completed', 'Prerequisites', 'Title', 'Title']; cy.get('.grid7') .find('button.slick-gridmenu-button') @@ -242,7 +242,7 @@ describe('Example 07 - Row Move & Checkbox Selector Selector Plugins', { retries }); it('should hover over the last "Title" column and click on "Clear Filter" and expect grid to have all rows shown', () => { - cy.get('.slick-header-column:nth(9)') + cy.get('.slick-header-column:nth(10)') .first() .trigger('mouseover') .children('.slick-header-menubutton') @@ -260,7 +260,7 @@ describe('Example 07 - Row Move & Checkbox Selector Selector Plugins', { retries }); it('should dynamically remove 1x of the new "Title" columns', () => { - const updatedTitles = ['', '', 'Title', 'Duration', '% Complete', 'Start', 'Finish', 'Completed', 'Prerequisites', 'Title']; + const updatedTitles = ['', '', 'Title', 'Action', 'Duration', '% Complete', 'Start', 'Finish', 'Completed', 'Prerequisites', 'Title']; cy.get('[data-test=remove-title-column-btn]') .click(); @@ -281,33 +281,33 @@ describe('Example 07 - Row Move & Checkbox Selector Selector Plugins', { retries .click(); // change duration - cy.get(`[style="top:${GRID_ROW_HEIGHT * 0}px"] > .slick-cell:nth(3)`).should('contain', 'day').click(); + cy.get(`[style="top:${GRID_ROW_HEIGHT * 0}px"] > .slick-cell:nth(4)`).should('contain', 'day').click(); cy.get('.editor-duration').type('0000').type('{enter}'); - cy.get(`[style="top:${GRID_ROW_HEIGHT * 0}px"] > .slick-cell:nth(3)`).should('contain', '0000 day'); + cy.get(`[style="top:${GRID_ROW_HEIGHT * 0}px"] > .slick-cell:nth(4)`).should('contain', '0000 day'); // change % complete - cy.get(`[style="top:${GRID_ROW_HEIGHT * 0}px"] > .slick-cell:nth(4)`).click(); + cy.get(`[style="top:${GRID_ROW_HEIGHT * 0}px"] > .slick-cell:nth(5)`).click(); cy.get('.slider-editor input[type=range]').as('range').invoke('val', 50).trigger('change'); - cy.get(`[style="top:${GRID_ROW_HEIGHT * 0}px"] > .slick-cell:nth(4)`).should('contain', '50'); + cy.get(`[style="top:${GRID_ROW_HEIGHT * 0}px"] > .slick-cell:nth(5)`).should('contain', '50'); // change Finish date - cy.get(`[style="top:${GRID_ROW_HEIGHT * 0}px"] > .slick-cell:nth(6)`).should('contain', '2009-01-05').click(); + cy.get(`[style="top:${GRID_ROW_HEIGHT * 0}px"] > .slick-cell:nth(7)`).should('contain', '2009-01-05').click(); cy.get('.flatpickr-calendar:visible .flatpickr-day').contains('22').click('bottom', { force: true }); - cy.get(`[style="top:${GRID_ROW_HEIGHT * 0}px"] > .slick-cell:nth(6)`).should('contain', '2009-01-22'); + cy.get(`[style="top:${GRID_ROW_HEIGHT * 0}px"] > .slick-cell:nth(7)`).should('contain', '2009-01-22'); - cy.get(`[style="top:${GRID_ROW_HEIGHT * 0}px"] > .slick-cell:nth(9)`).should('contain', 'Task 0000'); + cy.get(`[style="top:${GRID_ROW_HEIGHT * 0}px"] > .slick-cell:nth(10)`).should('contain', 'Task 0000'); }); it('should move "Duration" column to a different position in the grid', () => { - const expectedTitles = ['', '', 'Title', '% Complete', 'Start', 'Finish', 'Duration', 'Completed', 'Prerequisites', 'Title']; + const expectedTitles = ['', '', 'Title', 'Action', '% Complete', 'Start', 'Finish', 'Duration', 'Completed', 'Prerequisites', 'Title']; cy.get('.slick-header-columns') - .children('.slick-header-column:nth(3)') + .children('.slick-header-column:nth(4)') .should('contain', 'Duration') .trigger('mousedown', 'center', { which: 1 }); cy.get('.slick-header-columns') - .children('.slick-header-column:nth(6)') + .children('.slick-header-column:nth(7)') .should('contain', 'Finish') .trigger('mousemove', 'bottomRight') .trigger('mouseup', 'bottomRight', { force: true }); @@ -319,7 +319,7 @@ describe('Example 07 - Row Move & Checkbox Selector Selector Plugins', { retries }); it('should be able to hide "Duration" column', () => { - const expectedTitles = ['', '', 'Title', '% Complete', 'Start', 'Finish', 'Completed', 'Prerequisites', 'Title']; + const expectedTitles = ['', '', 'Title', 'Action', '% Complete', 'Start', 'Finish', 'Completed', 'Prerequisites', 'Title']; cy.get('[data-test="hide-duration-btn"]').click(); @@ -330,7 +330,7 @@ describe('Example 07 - Row Move & Checkbox Selector Selector Plugins', { retries }); it('should be able to click disable Filters functionality button and expect no Filters', () => { - const expectedTitles = ['', '', 'Title', '% Complete', 'Start', 'Finish', 'Completed', 'Prerequisites', 'Title']; + const expectedTitles = ['', '', 'Title', 'Action', '% Complete', 'Start', 'Finish', 'Completed', 'Prerequisites', 'Title']; cy.get('[data-test="disable-filters-btn"]').click().click(); // even clicking twice should have same result @@ -345,6 +345,23 @@ describe('Example 07 - Row Move & Checkbox Selector Selector Plugins', { retries cy.get('[data-test="toggle-filtering-btn"]').click(); // show it back }); + it('should open the Cell Menu on row 9-10 row and change the Completed to "True" and expect the cell to be updated and have checkmark icon', () => { + cy.get(`[style="top:${GRID_ROW_HEIGHT * 9}px"] > .slick-cell:nth(2)`).should('contain', 'Task 9'); + cy.get(`[style="top:${GRID_ROW_HEIGHT * 10}px"] > .slick-cell:nth(2)`).should('contain', 'Task 10'); + + cy.get('.grid7').find(`[style="top:${GRID_ROW_HEIGHT * 9}px"] > .slick-cell:nth(3)`).click({ force: true }); + cy.get('.slick-cell-menu .slick-cell-menu-command-list .title').contains('Commands'); + cy.get('.slick-cell-menu .slick-cell-menu-command-list .slick-cell-menu-content').contains('Delete Row'); + cy.get('.slick-cell-menu .slick-cell-menu-command-list .slick-cell-menu-content').contains('Help'); + cy.get('.slick-cell-menu .slick-cell-menu-option-list .title').contains('Change Completed Flag'); + cy.get('.slick-cell-menu .slick-cell-menu-option-list .slick-cell-menu-item').contains('True').click(); + cy.get('.grid7').find(`[style="top:${GRID_ROW_HEIGHT * 10}px"] > .slick-cell:nth(3)`).click({ force: true }); + cy.get('.slick-cell-menu .slick-cell-menu-option-list .slick-cell-menu-item').contains('True').click(); + + cy.get(`[style="top:${GRID_ROW_HEIGHT * 9}px"] > .slick-cell:nth(7)`).find('.checkmark-icon').should('have.length', 1); + cy.get(`[style="top:${GRID_ROW_HEIGHT * 10}px"] > .slick-cell:nth(7)`).find('.checkmark-icon').should('have.length', 1); + }); + it('should expect "Clear all Filters" command to be hidden in the Grid Menu', () => { const expectedFullHeaderMenuCommands = ['Clear all Filters', 'Clear all Sorting', 'Toggle Filter Row', 'Export to Excel']; @@ -367,7 +384,7 @@ describe('Example 07 - Row Move & Checkbox Selector Selector Plugins', { retries }); it('should be able to toggle Filters functionality', () => { - const expectedTitles = ['', '', 'Title', '% Complete', 'Start', 'Finish', 'Completed', 'Prerequisites', 'Title']; + const expectedTitles = ['', '', 'Title', 'Action', '% Complete', 'Start', 'Finish', 'Completed', 'Prerequisites', 'Title']; cy.get('[data-test="toggle-filtering-btn"]').click(); // hide it @@ -380,7 +397,7 @@ describe('Example 07 - Row Move & Checkbox Selector Selector Plugins', { retries .each(($child, index) => expect($child.text()).to.eq(expectedTitles[index])); cy.get('[data-test="toggle-filtering-btn"]').click(); // show it - cy.get('.slick-headerrow-columns .slick-headerrow-column').should('have.length', 9); + cy.get('.slick-headerrow-columns .slick-headerrow-column').should('have.length', 10); cy.get('.grid7') .find('.slick-header-columns') @@ -557,7 +574,7 @@ describe('Example 07 - Row Move & Checkbox Selector Selector Plugins', { retries it('should open Column Picker and show the "Duration" column back to visible and expect it to have kept its position after toggling filter/sorting', () => { // first 2 cols are hidden but they do count as li item - const expectedFullPickerTitles = ['', '', 'Title', '% Complete', 'Start', 'Finish', 'Duration', 'Completed', 'Prerequisites', 'Title']; + const expectedFullPickerTitles = ['', '', 'Title', 'Action', '% Complete', 'Start', 'Finish', 'Duration', 'Completed', 'Prerequisites', 'Title']; cy.get('.grid7') .find('.slick-header-column') @@ -577,7 +594,7 @@ describe('Example 07 - Row Move & Checkbox Selector Selector Plugins', { retries cy.get('.slick-columnpicker') .find('.slick-columnpicker-list') - .children('li:nth-child(7)') + .children('li:nth-child(8)') .children('label') .should('contain', 'Duration') .click(); @@ -626,7 +643,7 @@ describe('Example 07 - Row Move & Checkbox Selector Selector Plugins', { retries }); it('should open the "Prerequisites" Editor and expect to have Task 500 & 501 in the Editor', () => { - cy.get(`[style="top:${GRID_ROW_HEIGHT * 0}px"] > .slick-cell:nth(8)`) + cy.get(`[style="top:${GRID_ROW_HEIGHT * 0}px"] > .slick-cell:nth(9)`) .should('contain', '') .click(); @@ -645,7 +662,7 @@ describe('Example 07 - Row Move & Checkbox Selector Selector Plugins', { retries .last() .click({ force: true }); - cy.get(`[style="top:${GRID_ROW_HEIGHT * 0}px"] > .slick-cell:nth(8)`).should('contain', 'Task 501'); + cy.get(`[style="top:${GRID_ROW_HEIGHT * 0}px"] > .slick-cell:nth(9)`).should('contain', 'Task 501'); }); it('should delete the last item "Task 501" and expect it to be removed from the Filter', () => { @@ -705,15 +722,15 @@ describe('Example 07 - Row Move & Checkbox Selector Selector Plugins', { retries }); it('should reorder "Start" column to be after the "Completed" column', () => { - const expectedTitles = ['', '', 'Title', '% Complete', 'Finish', 'Duration', 'Completed', 'Start', 'Prerequisites', 'Title']; + const expectedTitles = ['', '', 'Title', 'Action', '% Complete', 'Finish', 'Duration', 'Completed', 'Start', 'Prerequisites', 'Title']; cy.get('.slick-header-columns') - .children('.slick-header-column:nth(4)') + .children('.slick-header-column:nth(5)') .should('contain', 'Start') .trigger('mousedown', 'bottom', { which: 1 }); cy.get('.slick-header-columns') - .children('.slick-header-column:nth(7)') + .children('.slick-header-column:nth(8)') .should('contain', 'Completed') .trigger('mousemove', 'bottomRight') .trigger('mouseup', 'bottomRight', { force: true }); @@ -725,7 +742,7 @@ describe('Example 07 - Row Move & Checkbox Selector Selector Plugins', { retries }); it('should hide "Duration" column from column picker', () => { - const originalColumns = ['', '', 'Title', '% Complete', 'Finish', 'Duration', 'Completed', 'Start', 'Prerequisites', 'Title']; + const originalColumns = ['', '', 'Title', 'Action', '% Complete', 'Finish', 'Duration', 'Completed', 'Start', 'Prerequisites', 'Title']; cy.get('.grid7') .find('.slick-header-column') @@ -745,7 +762,7 @@ describe('Example 07 - Row Move & Checkbox Selector Selector Plugins', { retries cy.get('.slick-columnpicker') .find('.slick-columnpicker-list') - .children('li:nth-child(6)') + .children('li:nth-child(7)') .children('label') .should('contain', 'Duration') .click(); @@ -782,7 +799,7 @@ describe('Example 07 - Row Move & Checkbox Selector Selector Plugins', { retries const expectedFullHeaderMenuCommands = ['Redimensionner par contenu', '', 'Trier par ordre croissant', 'Trier par ordre décroissant', '', 'Supprimer le filtre', 'Supprimer le tri', 'Cacher la colonne']; cy.get('.grid7') - .find('.slick-header-column:nth(8)') + .find('.slick-header-column:nth(9)') .trigger('mouseover') .children('.slick-header-menubutton') .click(); @@ -801,7 +818,7 @@ describe('Example 07 - Row Move & Checkbox Selector Selector Plugins', { retries }); it('should open Grid Menu and expect new columns to be added to the column picker section, also "Duration" to be unchecked while "Finish" to be at new position', () => { - const updatedTitles = ['', '', 'Titre', 'Durée', '% Achevée', 'Fin', 'Terminé', 'Début', 'Prerequisites', 'Titre']; + const updatedTitles = ['', '', 'Titre', 'Action', 'Durée', '% Achevée', 'Fin', 'Terminé', 'Début', 'Prerequisites', 'Titre']; cy.get('.grid7') .find('button.slick-gridmenu-button') @@ -830,4 +847,41 @@ describe('Example 07 - Row Move & Checkbox Selector Selector Plugins', { retries .find('span.close') .click(); }); + + it('should open the Cell Menu on first 2 rows and change the Completed to "True" and expect the cell to be updated and have checkmark icon', () => { + cy.get(`[style="top:${GRID_ROW_HEIGHT * 0}px"] > .slick-cell:nth(2)`).should('contain', 'Task 4'); + cy.get(`[style="top:${GRID_ROW_HEIGHT * 1}px"] > .slick-cell:nth(2)`).should('contain', 'Task 8'); + + cy.get('.grid7').find(`[style="top:${GRID_ROW_HEIGHT * 0}px"] > .slick-cell:nth(3)`).click({ force: true }); + cy.get('.slick-cell-menu .slick-cell-menu-command-list .title').contains('Commandes'); + cy.get('.slick-cell-menu .slick-cell-menu-command-list .slick-cell-menu-content').contains('Supprimer la ligne'); + cy.get('.slick-cell-menu .slick-cell-menu-command-list .slick-cell-menu-content').contains('Aide'); + cy.get('.slick-cell-menu .slick-cell-menu-option-list .title').contains(`Changer l'indicateur terminé`); + cy.get('.slick-cell-menu .slick-cell-menu-option-list .slick-cell-menu-item').contains('Faux').click(); + cy.get('.grid7').find(`[style="top:${GRID_ROW_HEIGHT * 1}px"] > .slick-cell:nth(3)`).click({ force: true }); + cy.get('.slick-cell-menu .slick-cell-menu-option-list .slick-cell-menu-item').contains('Faux').click(); + + cy.get(`[style="top:${GRID_ROW_HEIGHT * 0}px"] > .slick-cell:nth(7)`).find('.checkmark-icon').should('have.length', 0); + 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', () => { + const confirmStub = cy.stub(); + cy.on('window:confirm', confirmStub); + + cy.get(`[style="top:${GRID_ROW_HEIGHT * 0}px"] > .slick-cell:nth(2)`).should('contain', 'Task 4'); + cy.get(`[style="top:${GRID_ROW_HEIGHT * 1}px"] > .slick-cell:nth(2)`).should('contain', 'Task 8'); + + cy.get('.grid7').find(`[style="top:${GRID_ROW_HEIGHT * 1}px"] > .slick-cell:nth(3)`).click({ force: true }); + cy.get('.slick-cell-menu .slick-cell-menu-command-list .title').contains('Commandes'); + cy.get('.slick-cell-menu .slick-cell-menu-command-list .slick-cell-menu-content') + .contains('Supprimer la ligne') + .click() + .then(() => expect(confirmStub.getCall(0)).to.be.calledWith('Do you really want to delete row (2) with "Task 8"?')); + }); + + it('should have "1 de 500 éléments" shown as metrics on the right footer shown in French', () => { + cy.get('.right-footer.metrics') + .contains('1 de 500 éléments'); + }); }); diff --git a/test/translateServiceStub.ts b/test/translateServiceStub.ts index 00033518c..fce37e3b1 100644 --- a/test/translateServiceStub.ts +++ b/test/translateServiceStub.ts @@ -61,9 +61,10 @@ export class TranslateServiceStub implements TranslaterService { case 'ITEMS_SELECTED': output = this._locale === 'en' ? 'items selected' : 'éléments sélectionnés'; break; case 'NOT_CONTAINS': output = this._locale === 'en' ? 'Not contains' : 'Ne contient pas'; break; case 'NOT_EQUAL_TO': output = this._locale === 'en' ? 'Not equal to' : 'Non égal à'; break; + case 'NONE': output = this._locale === 'en' ? 'None' : 'Aucun'; break; case 'OF': output = this._locale === 'en' ? 'of' : 'de'; break; case 'OK': output = this._locale === 'en' ? 'OK' : 'Terminé'; break; - case 'OPTIONS_LIST': output = this._locale === 'en' ? 'Options List' : 'Liste d\'options'; break; + case 'OPTIONS_LIST': output = this._locale === 'en' ? 'Options List' : `Liste d'options`; break; case 'OTHER': output = this._locale === 'en' ? 'Other' : 'Autre'; break; case 'PAGE': output = this._locale === 'en' ? 'Page' : 'Page'; break; case 'PRODUCT': output = this._locale === 'en' ? 'Product' : 'Produit'; break;