From 69711aded5aa835091789800214f82cd7c72753e Mon Sep 17 00:00:00 2001 From: ghiscoding Date: Tue, 24 Aug 2021 23:51:17 -0400 Subject: [PATCH] feat(plugins): move external Header Button into Slickgrid-Universal --- .../__tests__/gridMenuControl.spec.ts | 2 +- .../common/src/controls/gridMenu.control.ts | 10 +- .../__tests__/extensionUtility.spec.ts | 2 - .../__tests__/headerButtonExtension.spec.ts | 105 ------ .../src/extensions/headerButtonExtension.ts | 68 ---- packages/common/src/extensions/index.ts | 1 - .../common/src/interfaces/column.interface.ts | 4 +- .../src/interfaces/headerButton.interface.ts | 4 +- .../interfaces/headerButtonItem.interface.ts | 3 + .../__tests__/headerButton.plugin.spec.ts | 339 ++++++++++++++++++ .../common/src/plugins/headerButton.plugin.ts | 212 +++++++++++ packages/common/src/plugins/index.ts | 3 +- .../__tests__/extension.service.spec.ts | 27 +- .../common/src/services/extension.service.ts | 16 +- .../components/slick-vanilla-grid-bundle.ts | 3 - test/cypress/integration/example13.spec.js | 16 +- 16 files changed, 603 insertions(+), 212 deletions(-) delete mode 100644 packages/common/src/extensions/__tests__/headerButtonExtension.spec.ts delete mode 100644 packages/common/src/extensions/headerButtonExtension.ts create mode 100644 packages/common/src/plugins/__tests__/headerButton.plugin.spec.ts create mode 100644 packages/common/src/plugins/headerButton.plugin.ts diff --git a/packages/common/src/controls/__tests__/gridMenuControl.spec.ts b/packages/common/src/controls/__tests__/gridMenuControl.spec.ts index 3cb903a98..e47243fd8 100644 --- a/packages/common/src/controls/__tests__/gridMenuControl.spec.ts +++ b/packages/common/src/controls/__tests__/gridMenuControl.spec.ts @@ -838,7 +838,7 @@ describe('GridMenuControl', () => { expect(buttonImageElm.src).toBe('/images/some-gridmenu-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 no deprecated and will be removed in future version, consider using "iconCssClass" instead.'); + 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.'); }); it('should add a custom Grid Menu item with "tooltip" and expect the item title attribute to be part of the item DOM element', () => { diff --git a/packages/common/src/controls/gridMenu.control.ts b/packages/common/src/controls/gridMenu.control.ts index 339b78457..147747ed7 100644 --- a/packages/common/src/controls/gridMenu.control.ts +++ b/packages/common/src/controls/gridMenu.control.ts @@ -390,8 +390,8 @@ export class GridMenuControl { let isItemVisible = true; let isItemUsable = true; if (typeof item === 'object') { - isItemVisible = this.runOverrideFunctionWhenExists(item.itemVisibilityOverride, callbackArgs); - isItemUsable = this.runOverrideFunctionWhenExists(item.itemUsabilityOverride, callbackArgs); + isItemVisible = this.runOverrideFunctionWhenExists(item.itemVisibilityOverride, callbackArgs); + isItemUsable = this.runOverrideFunctionWhenExists(item.itemUsabilityOverride, callbackArgs); } // if the result is not visible then there's no need to go further @@ -440,7 +440,7 @@ export class GridMenuControl { } if (item.iconImage) { - console.warn('[Slickgrid-Universal] The "iconImage" property of a Grid Menu item is no deprecated and will be removed in future version, consider using "iconCssClass" instead.'); + console.warn('[Slickgrid-Universal] The "iconImage" property of a Grid Menu item is now deprecated and will be removed in future version, consider using "iconCssClass" instead.'); iconElm.style.backgroundImage = `url(${item.iconImage})`; } @@ -521,7 +521,7 @@ 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.runOverrideFunctionWhenExists(controlOptions.menuUsabilityOverride, callbackArgs)) { + if (controlOptions && !this.runOverrideFunctionWhenExists(controlOptions.menuUsabilityOverride, callbackArgs)) { return; } @@ -874,7 +874,7 @@ export class GridMenuControl { } /** Run the Override function when it exists, if it returns True then it is usable/visible */ - protected runOverrideFunctionWhenExists(overrideFn: any, args: any): boolean { + protected runOverrideFunctionWhenExists(overrideFn: any, args: T): boolean { if (typeof overrideFn === 'function') { return overrideFn.call(this, args); } diff --git a/packages/common/src/extensions/__tests__/extensionUtility.spec.ts b/packages/common/src/extensions/__tests__/extensionUtility.spec.ts index f7de692c9..fa6dd0ae0 100644 --- a/packages/common/src/extensions/__tests__/extensionUtility.spec.ts +++ b/packages/common/src/extensions/__tests__/extensionUtility.spec.ts @@ -17,12 +17,10 @@ const mockAddon = jest.fn().mockImplementation(() => ({ })); jest.mock('slickgrid/slick.groupitemmetadataprovider', () => mockAddon); -jest.mock('slickgrid/controls/slick.gridmenu', () => mockAddon); jest.mock('slickgrid/plugins/slick.cellmenu', () => mockAddon); jest.mock('slickgrid/plugins/slick.cellexternalcopymanager', () => mockAddon); jest.mock('slickgrid/plugins/slick.contextmenu', () => mockAddon); jest.mock('slickgrid/plugins/slick.draggablegrouping', () => mockAddon); -jest.mock('slickgrid/plugins/slick.headerbuttons', () => mockAddon); jest.mock('slickgrid/plugins/slick.headermenu', () => mockAddon); jest.mock('slickgrid/plugins/slick.rowselectionmodel', () => mockAddon); jest.mock('slickgrid/plugins/slick.rowdetailview', () => mockAddon); diff --git a/packages/common/src/extensions/__tests__/headerButtonExtension.spec.ts b/packages/common/src/extensions/__tests__/headerButtonExtension.spec.ts deleted file mode 100644 index b9358ff05..000000000 --- a/packages/common/src/extensions/__tests__/headerButtonExtension.spec.ts +++ /dev/null @@ -1,105 +0,0 @@ -import { HeaderButtonExtension } from '../headerButtonExtension'; -import { ExtensionUtility } from '../extensionUtility'; -import { SharedService } from '../../services/shared.service'; -import { GridOption, HeaderButton, HeaderButtonOnCommandArgs, SlickGrid, SlickHeaderButtons, SlickNamespace } from '../../interfaces/index'; -import { TranslateServiceStub } from '../../../../../test/translateServiceStub'; -import { BackendUtilityService } from '../../services'; - -declare const Slick: SlickNamespace; - -const gridStub = { - getOptions: jest.fn(), - registerPlugin: jest.fn(), -} as unknown as SlickGrid; - -const mockAddon = jest.fn().mockImplementation(() => ({ - onCommand: new Slick.Event(), - init: jest.fn(), - destroy: jest.fn() -})); - -describe('headerButtonExtension', () => { - jest.mock('slickgrid/plugins/slick.headerbuttons', () => mockAddon); - Slick.Plugins = { HeaderButtons: mockAddon } as any; - - let extension: HeaderButtonExtension; - let backendUtilityService: BackendUtilityService; - let extensionUtility: ExtensionUtility; - let sharedService: SharedService; - let translateService: TranslateServiceStub; - const mockOnCommandArgs = { - button: { - command: 'toggle-highlight', - cssClass: 'fa fa-circle red', - tooltip: 'Remove highlight.', - }, - column: { id: 'field1', field: 'field1' }, - command: 'toggle-highlight', - grid: gridStub - } as HeaderButtonOnCommandArgs; - const mockEventCallback = () => { }; - const gridOptionsMock = { - enableHeaderButton: true, - headerButton: { - onExtensionRegistered: jest.fn(), - onCommand: mockEventCallback - } - } as GridOption; - - beforeEach(() => { - sharedService = new SharedService(); - backendUtilityService = new BackendUtilityService(); - translateService = new TranslateServiceStub(); - extensionUtility = new ExtensionUtility(sharedService, backendUtilityService, translateService); - extension = new HeaderButtonExtension(extensionUtility, sharedService); - }); - - 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, 'slickGrid', 'get').mockReturnValue(gridStub); - jest.spyOn(SharedService.prototype, 'gridOptions', 'get').mockReturnValue(gridOptionsMock); - }); - - it('should register the addon', () => { - const pluginSpy = jest.spyOn(SharedService.prototype.slickGrid, 'registerPlugin'); - - const instance = extension.register(); - const addonInstance = extension.getAddonInstance(); - - expect(instance).toBeTruthy(); - expect(instance).toEqual(addonInstance); - expect(mockAddon).toHaveBeenCalledWith({ - onCommand: expect.anything(), - onExtensionRegistered: expect.anything(), - }); - expect(pluginSpy).toHaveBeenCalledWith(instance); - }); - - 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 onCopySpy = jest.spyOn(SharedService.prototype.gridOptions.headerButton as HeaderButton, 'onCommand'); - const instance = extension.register() as SlickHeaderButtons; - instance.onCommand!.notify(mockOnCommandArgs, new Slick.EventData(), gridStub); - - expect(handlerSpy).toHaveBeenCalledWith( - { notify: expect.anything(), subscribe: expect.anything(), unsubscribe: expect.anything(), }, - expect.anything() - ); - expect(onCopySpy).toHaveBeenCalledWith(expect.anything(), mockOnCommandArgs); - }); - - it('should dispose of the addon', () => { - const instance = extension.register() as SlickHeaderButtons; - const destroySpy = jest.spyOn(instance, 'destroy'); - - extension.dispose(); - - expect(destroySpy).toHaveBeenCalled(); - }); - }); -}); diff --git a/packages/common/src/extensions/headerButtonExtension.ts b/packages/common/src/extensions/headerButtonExtension.ts deleted file mode 100644 index f320a965a..000000000 --- a/packages/common/src/extensions/headerButtonExtension.ts +++ /dev/null @@ -1,68 +0,0 @@ -import 'slickgrid/plugins/slick.headerbuttons'; - -import { Extension, GetSlickEventType, SlickEventHandler, SlickNamespace, SlickHeaderButtons, HeaderButton } from '../interfaces/index'; -import { ExtensionUtility } from './extensionUtility'; -import { SharedService } from '../services/shared.service'; - -// using external non-typed js libraries -declare const Slick: SlickNamespace; - -export class HeaderButtonExtension implements Extension { - private _eventHandler: SlickEventHandler; - private _addon: SlickHeaderButtons | null = null; - private _headerButtonOptions: HeaderButton | null = null; - - constructor(private readonly extensionUtility: ExtensionUtility, private readonly sharedService: SharedService) { - 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._headerButtonOptions); - this._addon = null; - this._headerButtonOptions = null; - } - - /** Get the instance of the SlickGrid addon (control or plugin). */ - getAddonInstance(): SlickHeaderButtons | null { - return this._addon; - } - - /** Register the 3rd party addon (plugin) */ - register(): SlickHeaderButtons | null { - if (this.sharedService && this.sharedService.slickGrid && this.sharedService.gridOptions) { - this._headerButtonOptions = this.sharedService.gridOptions.headerButton || {}; - this._addon = new Slick.Plugins.HeaderButtons(this._headerButtonOptions); - if (this._addon) { - this.sharedService.slickGrid.registerPlugin(this._addon); - } - - // hook all events - if (this._addon && this.sharedService.slickGrid && this._headerButtonOptions) { - if (this._headerButtonOptions.onExtensionRegistered) { - this._headerButtonOptions.onExtensionRegistered(this._addon); - } - - const onCommandHandler = this._addon.onCommand; - if (onCommandHandler) { - (this._eventHandler as SlickEventHandler>).subscribe(onCommandHandler, (e, args) => { - if (this._headerButtonOptions && typeof this._headerButtonOptions.onCommand === 'function') { - this._headerButtonOptions.onCommand(e, args); - } - }); - } - } - return this._addon; - } - return null; - } -} diff --git a/packages/common/src/extensions/index.ts b/packages/common/src/extensions/index.ts index f8df6cb6f..683851272 100644 --- a/packages/common/src/extensions/index.ts +++ b/packages/common/src/extensions/index.ts @@ -5,7 +5,6 @@ export * from './contextMenuExtension'; export * from './draggableGroupingExtension'; export * from './extensionUtility'; export * from './groupItemMetaProviderExtension'; -export * from './headerButtonExtension'; export * from './headerMenuExtension'; export * from './rowDetailViewExtension'; export * from './rowMoveManagerExtension'; diff --git a/packages/common/src/interfaces/column.interface.ts b/packages/common/src/interfaces/column.interface.ts index a55e74882..e2fd150fc 100644 --- a/packages/common/src/interfaces/column.interface.ts +++ b/packages/common/src/interfaces/column.interface.ts @@ -145,8 +145,8 @@ export interface Column { /** Options that can be provided to the Header Menu Plugin */ header?: { /** list of Buttons to show in the header */ - buttons?: Array; - menu?: { items: Array }; + buttons?: Array; + menu?: { items: Array }; }; /** CSS class that can be added to the column header */ diff --git a/packages/common/src/interfaces/headerButton.interface.ts b/packages/common/src/interfaces/headerButton.interface.ts index 6efd62537..630ff1a39 100644 --- a/packages/common/src/interfaces/headerButton.interface.ts +++ b/packages/common/src/interfaces/headerButton.interface.ts @@ -1,6 +1,6 @@ +import { HeaderButtonPlugin } from '../plugins'; import { HeaderButtonOnCommandArgs } from './headerButtonOnCommandArgs.interface'; import { SlickEventData } from './slickEventData.interface'; -import { SlickHeaderButtons } from './slickHeaderButtons.interface'; export interface HeaderButton extends HeaderButtonOption { // -- @@ -8,7 +8,7 @@ export interface HeaderButton extends HeaderButtonOption { // ------------ /** Fired after extension (plugin) is registered by SlickGrid */ - onExtensionRegistered?: (plugin: SlickHeaderButtons) => void; + onExtensionRegistered?: (plugin: HeaderButtonPlugin) => void; /** Fired when a command is clicked */ onCommand?: (e: SlickEventData, args: HeaderButtonOnCommandArgs) => void; diff --git a/packages/common/src/interfaces/headerButtonItem.interface.ts b/packages/common/src/interfaces/headerButtonItem.interface.ts index 5bb017bb3..f27e1dcba 100644 --- a/packages/common/src/interfaces/headerButtonItem.interface.ts +++ b/packages/common/src/interfaces/headerButtonItem.interface.ts @@ -8,6 +8,9 @@ export interface HeaderButtonItem { /** CSS class to add to the button. */ cssClass?: string; + /** Defaults to false, whether the item/command is disabled. */ + disabled?: boolean; + /** Button click handler. */ handler?: (e: Event) => void; diff --git a/packages/common/src/plugins/__tests__/headerButton.plugin.spec.ts b/packages/common/src/plugins/__tests__/headerButton.plugin.spec.ts new file mode 100644 index 000000000..ba701a173 --- /dev/null +++ b/packages/common/src/plugins/__tests__/headerButton.plugin.spec.ts @@ -0,0 +1,339 @@ +import { Column, GridOption, SlickGrid, SlickNamespace, } from '../../interfaces/index'; +import { HeaderButtonPlugin } from '../headerButton.plugin'; +import { PubSubService } from '../../services'; +import { SharedService } from '../../services/shared.service'; + +declare const Slick: SlickNamespace; + +const removeExtraSpaces = (textS) => `${textS}`.replace(/[\n\r]\s+/g, ''); + +const gridStub = { + getCellNode: jest.fn(), + getCellFromEvent: jest.fn(), + getColumns: jest.fn(), + getOptions: jest.fn(), + registerPlugin: jest.fn(), + setColumns: jest.fn(), + updateColumnHeader: jest.fn(), + onBeforeHeaderCellDestroy: new Slick.Event(), + onHeaderCellRendered: new Slick.Event(), + onHeaderMouseEnter: new Slick.Event(), + onMouseEnter: new Slick.Event(), +} as unknown as SlickGrid; + +const pubSubServiceStub = { + publish: jest.fn(), + subscribe: jest.fn(), + unsubscribe: jest.fn(), + unsubscribeAll: jest.fn(), +} as PubSubService; + +const headerMock = { + buttons: [ + { + cssClass: 'mdi mdi-lightbulb-outline', + command: 'show-positive-numbers', + }, + { + cssClass: 'mdi mdi-lightbulb-on', + command: 'show-negative-numbers', + tooltip: 'Highlight negative numbers.', + } + ] +}; + +const columnsMock: Column[] = [ + { id: 'field1', field: 'field1', name: 'Field 1', width: 100, header: headerMock }, + { id: 'field2', field: 'field2', name: 'Field 2', width: 75 }, + { id: 'field3', field: 'field3', name: 'Field 3', width: 75, columnGroup: 'Billing' }, +]; + +describe('HeaderButton Plugin', () => { + const consoleWarnSpy = jest.spyOn(global.console, 'warn').mockReturnValue(); + let plugin: HeaderButtonPlugin; + let sharedService: SharedService; + const mockEventCallback = () => { }; + const gridOptionsMock = { + enableHeaderButton: true, + headerButton: { + onExtensionRegistered: jest.fn(), + onCommand: mockEventCallback + } + } as GridOption; + + beforeEach(() => { + sharedService = new SharedService(); + jest.spyOn(SharedService.prototype, 'slickGrid', 'get').mockReturnValue(gridStub); + jest.spyOn(SharedService.prototype, 'gridOptions', 'get').mockReturnValue(gridOptionsMock); + plugin = new HeaderButtonPlugin(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.options).toEqual({ + buttonCssClass: 'slick-header-button', + }); + }); + + it('should be able to change Header Button options', () => { + plugin.init(); + plugin.options = { + buttonCssClass: 'some-class' + } + + expect(plugin.options).toEqual({ + buttonCssClass: 'some-class', + }); + }); + + describe('plugins - Header Button', () => { + beforeEach(() => { + jest.spyOn(SharedService.prototype, 'slickGrid', 'get').mockReturnValue(gridStub); + columnsMock[0].header.buttons[1] = undefined; + columnsMock[0].header.buttons[1] = { + cssClass: 'mdi mdi-lightbulb-on', + command: 'show-negative-numbers', + tooltip: 'Highlight negative numbers.', + }; + }); + + afterEach(() => { + plugin.dispose(); + }); + + it('should populate 1x Header Button when cell is being rendered and a 2nd button item visibility callback returns undefined', () => { + const headerDiv = document.createElement('div'); + headerDiv.className = 'slick-header-column'; + + plugin.dispose(); + plugin.init(); + columnsMock[0].header.buttons[1].itemVisibilityOverride = () => undefined; + + 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 (only 1x) + expect(removeExtraSpaces(headerDiv.innerHTML)).toBe(removeExtraSpaces( + `
`)); + + gridStub.onBeforeHeaderCellDestroy.notify({ column: columnsMock[0], node: headerDiv, grid: gridStub }, eventData, gridStub); + expect(headerDiv.innerHTML).toBe(''); + }); + + it('should populate 1x Header Button when cell is being rendered and a 2nd button item visibility callback returns false', () => { + const headerDiv = document.createElement('div'); + headerDiv.className = 'slick-header-column'; + + plugin.dispose(); + plugin.init(); + columnsMock[0].header.buttons[1].itemVisibilityOverride = () => false; + + 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 (only 1x) + expect(removeExtraSpaces(headerDiv.innerHTML)).toBe(removeExtraSpaces( + `
`)); + }); + + it('should populate 2x Header Buttons when cell is being rendered and a 2nd button item visibility & usability callbacks returns true', () => { + const headerDiv = document.createElement('div'); + headerDiv.className = 'slick-header-column'; + + plugin.dispose(); + plugin.init(); + columnsMock[0].header.buttons[1].itemVisibilityOverride = () => true; + columnsMock[0].header.buttons[1].itemUsabilityOverride = () => true; + + 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( + `
+
`)); + }); + + it('should populate 2x Header Buttons and a 2nd button item usability callback returns false and expect button to be disabled', () => { + const headerDiv = document.createElement('div'); + headerDiv.className = 'slick-header-column'; + + plugin.dispose(); + plugin.init(); + columnsMock[0].header.buttons[1].itemVisibilityOverride = () => true; + columnsMock[0].header.buttons[1].itemUsabilityOverride = () => false; + + 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( + `
+
`)); + }); + + it('should populate 2x Header Buttons and a 2nd button is "disabled" and expect button to be disabled', () => { + const headerDiv = document.createElement('div'); + headerDiv.className = 'slick-header-column'; + + plugin.dispose(); + plugin.init(); + columnsMock[0].header.buttons[1].itemVisibilityOverride = undefined; + columnsMock[0].header.buttons[1].disabled = true; + + 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( + `
+
`)); + }); + + it('should populate 2x Header Buttons and a 2nd button and property "showOnHover" is enabled and expect button to be hidden until we hover it', () => { + const headerDiv = document.createElement('div'); + headerDiv.className = 'slick-header-column'; + + plugin.dispose(); + plugin.init(); + columnsMock[0].header.buttons[1].itemVisibilityOverride = undefined; + columnsMock[0].header.buttons[1].showOnHover = true; + + 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( + `
+
`)); + }); + + it('should populate 2x Header Buttons and a 2nd button and property "image" is filled and expect button to include an image background', () => { + const headerDiv = document.createElement('div'); + headerDiv.className = 'slick-header-column'; + + plugin.dispose(); + plugin.init(); + columnsMock[0].header.buttons[1].image = '/images/some-gridmenu-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.'); + }); + + it('should populate 2x Header Buttons and a 2nd button and property "tooltip" is filled and expect button to include a "title" attribute for the tooltip', () => { + const headerDiv = document.createElement('div'); + headerDiv.className = 'slick-header-column'; + + plugin.dispose(); + plugin.init(); + columnsMock[0].header.buttons[1].tooltip = 'Some Tooltip'; + + 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.'); + }); + + it('should populate 2x Header Buttons and a 2nd button and a "handler" callback to be executed when defined', () => { + const handlerMock = jest.fn(); + const headerDiv = document.createElement('div'); + headerDiv.className = 'slick-header-column'; + + plugin.dispose(); + plugin.init(); + columnsMock[0].header.buttons[1].handler = handlerMock; + + const eventData = { ...new Slick.EventData(), preventDefault: jest.fn() }; + gridStub.onHeaderCellRendered.notify({ column: columnsMock[0], node: headerDiv, grid: gridStub }, eventData, gridStub); + headerDiv.querySelector('.slick-header-button.mdi-lightbulb-on').dispatchEvent(new Event('click', { bubbles: true, cancelable: true, composed: false })); + + // add Header Buttons which are visible (2x buttons) + expect(removeExtraSpaces(headerDiv.innerHTML)).toBe(removeExtraSpaces( + `
+
`)); + expect(handlerMock).toHaveBeenCalled(); + }); + + it('should populate 2x Header Buttons and a 2nd button and expect the button click handler & action callback to be executed when defined', () => { + const actionMock = jest.fn(); + const headerDiv = document.createElement('div'); + headerDiv.className = 'slick-header-column'; + + plugin.dispose(); + plugin.init(); + columnsMock[0].header.buttons[1].action = actionMock; + + const eventData = { ...new Slick.EventData(), preventDefault: jest.fn() }; + gridStub.onHeaderCellRendered.notify({ column: columnsMock[0], node: headerDiv, grid: gridStub }, eventData, gridStub); + headerDiv.querySelector('.slick-header-button.mdi-lightbulb-on').dispatchEvent(new Event('click', { bubbles: true, cancelable: true, composed: false })); + + // add Header Buttons which are visible (2x buttons) + expect(removeExtraSpaces(headerDiv.innerHTML)).toBe(removeExtraSpaces( + `
+
`)); + expect(actionMock).toHaveBeenCalled(); + }); + + it('should populate 2x Header Buttons and a 2nd button and expect the "onCommand" handler to be executed when defined', () => { + const onCommandMock = jest.fn(); + const updateColSpy = jest.spyOn(gridStub, 'updateColumnHeader'); + const headerDiv = document.createElement('div'); + headerDiv.className = 'slick-header-column'; + + plugin.dispose(); + plugin.init(); + plugin.options.onCommand = onCommandMock; + + const eventData = { ...new Slick.EventData(), preventDefault: jest.fn() }; + gridStub.onHeaderCellRendered.notify({ column: columnsMock[0], node: headerDiv, grid: gridStub }, eventData, gridStub); + headerDiv.querySelector('.slick-header-button.mdi-lightbulb-on').dispatchEvent(new Event('click', { bubbles: true, cancelable: true, composed: false })); + + // add Header Buttons which are visible (2x buttons) + expect(removeExtraSpaces(headerDiv.innerHTML)).toBe(removeExtraSpaces( + `
+
`)); + expect(onCommandMock).toHaveBeenCalled(); + expect(updateColSpy).toHaveBeenCalledWith('field1'); + }); + + it('should populate 2x Header Buttons and a 2nd button is "disabled" but still expect the button NOT to be disabled because the "itemUsabilityOverride" has priority over the "disabled" property', () => { + const headerDiv = document.createElement('div'); + headerDiv.className = 'slick-header-column'; + + plugin.dispose(); + plugin.init(); + columnsMock[0].header.buttons[1].itemVisibilityOverride = () => true; + columnsMock[0].header.buttons[1].itemUsabilityOverride = () => true; + delete columnsMock[0].header.buttons[1].showOnHover; + columnsMock[0].header.buttons[1].disabled = true; + + 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( + `
+
`)); + }); + }); +}); diff --git a/packages/common/src/plugins/headerButton.plugin.ts b/packages/common/src/plugins/headerButton.plugin.ts new file mode 100644 index 000000000..4873fb196 --- /dev/null +++ b/packages/common/src/plugins/headerButton.plugin.ts @@ -0,0 +1,212 @@ +import { + Column, + DOMEvent, + GetSlickEventType, + HeaderButton, + HeaderButtonItem, + HeaderButtonOnCommandArgs, + HeaderButtonOption, + OnHeaderCellRenderedEventArgs, + SlickEventHandler, + SlickGrid, + SlickNamespace, +} from '../interfaces/index'; +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 custom buttons to column headers. + */ +export class HeaderButtonPlugin { + protected _bindEventService: BindingEventService; + protected _eventHandler!: SlickEventHandler; + protected _options?: HeaderButton; + protected _buttonElms: HTMLDivElement[] = []; + protected _defaults = { + buttonCssClass: 'slick-header-button', + } as HeaderButtonOption; + pluginName: 'HeaderButtons' = 'HeaderButtons'; + + /** Constructor of the SlickGrid 3rd party plugin, it can optionally receive options */ + constructor(protected readonly pubSubService: PubSubService, protected readonly sharedService: SharedService) { + this._bindEventService = new BindingEventService(); + this._eventHandler = new Slick.EventHandler(); + this.init(sharedService.gridOptions.headerButton); + } + + get eventHandler(): SlickEventHandler { + return this._eventHandler; + } + + get grid(): SlickGrid { + 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 }; + + const onHeaderCellRenderedHandler = this.grid.onHeaderCellRendered; + (this._eventHandler as SlickEventHandler>).subscribe(onHeaderCellRenderedHandler, this.handleHeaderCellRendered.bind(this)); + + const onBeforeHeaderCellDestroyHandler = this.grid.onBeforeHeaderCellDestroy; + (this._eventHandler as SlickEventHandler>).subscribe(onBeforeHeaderCellDestroyHandler, this.handleBeforeHeaderCellDestroy.bind(this)); + + // force the grid to re-render the header after the events are hooked up. + this.grid.setColumns(this.grid.getColumns()); + } + + /** Dispose (destroy) the SlickGrid 3rd party plugin */ + dispose() { + this._eventHandler?.unsubscribeAll(); + this._bindEventService.unbindAll(); + this._buttonElms.forEach(elm => elm.remove()); + } + + // -- + // event handlers + // ------------------ + + /** + * Event handler when column title header are being rendered + * @param {Object} event - The event + * @param {Object} args - object arguments + */ + protected handleHeaderCellRendered(_e: Event, args: OnHeaderCellRenderedEventArgs) { + const column = args.column; + + if (column.header?.buttons && Array.isArray(column.header.buttons)) { + // Append buttons in reverse order since they are floated to the right. + let i = column.header.buttons.length; + while (i--) { + const button = column.header.buttons[i]; + // run each override functions to know if the item is visible and usable + const isItemVisible = this.runOverrideFunctionWhenExists(button.itemVisibilityOverride, args); + const isItemUsable = this.runOverrideFunctionWhenExists(button.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 'handleMenuItemCommandClick' has the correct flag and won't trigger a command clicked event + if (Object.prototype.hasOwnProperty.call(button, 'itemUsabilityOverride')) { + button.disabled = isItemUsable ? false : true; + } + + const buttonDivElm = document.createElement('div'); + buttonDivElm.className = this._options?.buttonCssClass ?? ''; + + if (button.disabled) { + buttonDivElm.classList.add('slick-header-button-disabled'); + } + + if (button.showOnHover) { + buttonDivElm.classList.add('slick-header-button-hidden'); + } + + if (button.image) { + console.warn('[Slickgrid-Universal] The "image" property of a Header Button is now deprecated and will be removed in future version, consider using "cssClass" instead.'); + buttonDivElm.style.backgroundImage = `url(${button.image})`; + } + + if (button.cssClass) { + buttonDivElm.classList.add(...button.cssClass.split(' ')); + } + + if (button.tooltip) { + buttonDivElm.title = button.tooltip; + } + + // add click event handler for user's optional command on button item clicked + if (button.handler && !button.disabled) { + this._bindEventService.bind(buttonDivElm, 'click', button.handler); + } + + // add click event handler for internal command on button item clicked + if (!button.disabled) { + this._bindEventService.bind(buttonDivElm, 'click', (e: Event) => this.handleButtonClick(e as DOMEvent, button, column)); + } + + this._buttonElms.push(buttonDivElm); + args.node.appendChild(buttonDivElm); + } + } + } + + /** + * Event handler before the header cell is being destroyed + * @param {Object} event - The event + * @param {Object} args.column - The column definition + */ + protected handleBeforeHeaderCellDestroy(_e: Event, args: { column: Column; node: HTMLElement; }) { + const column = args.column; + + if (column.header?.buttons && this._options?.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, '.'); + if (buttonCssClass) { + args.node.querySelectorAll(`.${buttonCssClass}`).forEach(elm => elm.remove()); + } + } + } + + protected handleButtonClick(event: DOMEvent, button: HeaderButtonItem, columnDef: Column) { + if (button && !button.disabled) { + const command = button.command || ''; + + const callbackArgs = { + grid: this.grid, + column: columnDef, + button, + } as HeaderButtonOnCommandArgs; + + if (command) { + callbackArgs.command = command; + } + + // execute action callback when defined + if (typeof button.action === 'function' && !button.disabled) { + button.action.call(this, event, callbackArgs); + } + + if (command !== null && !button.disabled && this._options?.onCommand) { + this.pubSubService.publish('headerButton:onCommand', callbackArgs); + this._options.onCommand(event as any, callbackArgs); + + // Update the header in case the user updated the button definition in the handler. + this.grid.updateColumnHeader(columnDef.id); + } + } + + // Stop propagation so that it doesn't register as a header click event. + event.preventDefault(); + event.stopPropagation(); + } + + // -- + // protected functions + // ------------------ + + /** Run the Override function when it exists, if it returns True then it is usable/visible */ + protected runOverrideFunctionWhenExists(overrideFn: any, args: T): boolean { + if (typeof overrideFn === 'function') { + return overrideFn.call(this, args); + } + return true; + } +} \ No newline at end of file diff --git a/packages/common/src/plugins/index.ts b/packages/common/src/plugins/index.ts index a4cb49e36..b13e1f69f 100644 --- a/packages/common/src/plugins/index.ts +++ b/packages/common/src/plugins/index.ts @@ -1 +1,2 @@ -export * from './autoTooltip.plugin'; \ No newline at end of file +export * from './autoTooltip.plugin'; +export * from './headerButton.plugin'; \ No newline at end of file diff --git a/packages/common/src/services/__tests__/extension.service.spec.ts b/packages/common/src/services/__tests__/extension.service.spec.ts index 1baa437c0..5e32fd272 100644 --- a/packages/common/src/services/__tests__/extension.service.spec.ts +++ b/packages/common/src/services/__tests__/extension.service.spec.ts @@ -10,7 +10,6 @@ import { DraggableGroupingExtension, ExtensionUtility, GroupItemMetaProviderExtension, - HeaderButtonExtension, HeaderMenuExtension, RowDetailViewExtension, RowMoveManagerExtension, @@ -18,7 +17,7 @@ import { } from '../../extensions'; import { BackendUtilityService, ExtensionService, FilterService, PubSubService, SharedService, SortService } from '..'; import { TranslateServiceStub } from '../../../../../test/translateServiceStub'; -import { AutoTooltipPlugin } from '../../plugins/index'; +import { AutoTooltipPlugin, HeaderButtonPlugin } from '../../plugins/index'; import { ColumnPickerControl, GridMenuControl } from '../../controls/index'; jest.mock('flatpickr', () => { }); @@ -44,6 +43,8 @@ const gridStub = { onColumnsResized: jest.fn(), registerPlugin: jest.fn(), onBeforeDestroy: new Slick.Event(), + onBeforeHeaderCellDestroy: new Slick.Event(), + onHeaderCellRendered: new Slick.Event(), onSetOptions: new Slick.Event(), onColumnsReordered: new Slick.Event(), onHeaderContextMenu: new Slick.Event(), @@ -148,7 +149,6 @@ describe('ExtensionService', () => { extensionContextMenuStub as unknown as ContextMenuExtension, extensionStub as unknown as DraggableGroupingExtension, extensionGroupItemMetaStub as unknown as GroupItemMetaProviderExtension, - extensionHeaderButtonStub as unknown as HeaderButtonExtension, extensionHeaderMenuStub as unknown as HeaderMenuExtension, extensionStub as unknown as RowDetailViewExtension, extensionRowMoveStub as unknown as RowMoveManagerExtension, @@ -228,7 +228,6 @@ describe('ExtensionService', () => { service.bindDifferentExtensions(); const gridMenuInstance = service.getSlickgridAddonInstance(ExtensionName.gridMenu); - const output = service.getExtensionByName(ExtensionName.gridMenu); const instance = service.getSlickgridAddonInstance(ExtensionName.gridMenu); @@ -454,16 +453,26 @@ describe('ExtensionService', () => { }); it('should register the HeaderButton addon when "enableHeaderButton" is set in the grid options', () => { - const gridOptionsMock = { enableHeaderButton: true } as GridOption; - const extSpy = jest.spyOn(extensionStub, 'register').mockReturnValue(instanceMock); + const onRegisteredMock = jest.fn(); + const gridOptionsMock = { + enableHeaderButton: true, + headerButton: { + onExtensionRegistered: onRegisteredMock + } + } as GridOption; const gridSpy = jest.spyOn(SharedService.prototype, 'gridOptions', 'get').mockReturnValue(gridOptionsMock); service.bindDifferentExtensions(); + const headerButtonInstance = service.getSlickgridAddonInstance(ExtensionName.headerButton); const output = service.getExtensionByName(ExtensionName.headerButton); + const instance = service.getSlickgridAddonInstance(ExtensionName.headerButton); + expect(onRegisteredMock).toHaveBeenCalledWith(expect.toBeObject()); + expect(output.instance instanceof HeaderButtonPlugin).toBeTrue(); expect(gridSpy).toHaveBeenCalled(); - expect(extSpy).toHaveBeenCalled(); - expect(output).toEqual({ name: ExtensionName.headerButton, instance: instanceMock as unknown, class: extensionHeaderButtonStub } as ExtensionModel); + expect(headerButtonInstance).toBeTruthy(); + expect(output!.instance).toEqual(instance); + expect(output).toEqual({ name: ExtensionName.headerButton, instance: headerButtonInstance as unknown, class: {} } as ExtensionModel); }); it('should register the HeaderMenu addon when "enableHeaderMenu" is set in the grid options', () => { @@ -648,7 +657,6 @@ describe('ExtensionService', () => { service.bindDifferentExtensions(); service.renderColumnHeaders(columnsMock); const gridMenuInstance = service.getSlickgridAddonInstance(ExtensionName.gridMenu); - const extSpy = jest.spyOn(gridMenuInstance, 'translateGridMenu'); service.translateGridMenu(); @@ -840,7 +848,6 @@ describe('ExtensionService', () => { extensionStub as unknown as ContextMenuExtension, extensionStub as unknown as DraggableGroupingExtension, extensionGroupItemMetaStub as unknown as GroupItemMetaProviderExtension, - extensionHeaderButtonStub as unknown as HeaderButtonExtension, extensionHeaderMenuStub as unknown as HeaderMenuExtension, extensionStub as unknown as RowDetailViewExtension, extensionStub as unknown as RowMoveManagerExtension, diff --git a/packages/common/src/services/extension.service.ts b/packages/common/src/services/extension.service.ts index 918d49b9f..d5dc874b2 100644 --- a/packages/common/src/services/extension.service.ts +++ b/packages/common/src/services/extension.service.ts @@ -13,7 +13,6 @@ import { DraggableGroupingExtension, ExtensionUtility, GroupItemMetaProviderExtension, - HeaderButtonExtension, HeaderMenuExtension, RowDetailViewExtension, RowMoveManagerExtension, @@ -21,7 +20,7 @@ import { } from '../extensions/index'; import { SharedService } from './shared.service'; import { TranslaterService } from './translater.service'; -import { AutoTooltipPlugin } from '../plugins/index'; +import { AutoTooltipPlugin, HeaderButtonPlugin } from '../plugins/index'; import { ColumnPickerControl, GridMenuControl } from '../controls/index'; import { FilterService } from './filter.service'; import { PubSubService } from './pubSub.service'; @@ -36,6 +35,7 @@ interface ExtensionWithColumnIndexPosition { export class ExtensionService { protected _columnPickerControl?: ColumnPickerControl; protected _gridMenuControl?: GridMenuControl; + protected _headerButtonPlugin?: HeaderButtonPlugin; protected _extensionCreatedList: ExtensionList = {} as ExtensionList; protected _extensionList: ExtensionList = {} as ExtensionList; @@ -59,7 +59,6 @@ export class ExtensionService { protected readonly contextMenuExtension: ContextMenuExtension, protected readonly draggableGroupingExtension: DraggableGroupingExtension, protected readonly groupItemMetaExtension: GroupItemMetaProviderExtension, - protected readonly headerButtonExtension: HeaderButtonExtension, protected readonly headerMenuExtension: HeaderMenuExtension, protected readonly rowDetailViewExtension: RowDetailViewExtension, protected readonly rowMoveManagerExtension: RowMoveManagerExtension, @@ -234,10 +233,13 @@ export class ExtensionService { } // Header Button Plugin - if (this.gridOptions.enableHeaderButton && this.headerButtonExtension && this.headerButtonExtension.register) { - const instance = this.headerButtonExtension.register(); - if (instance) { - this._extensionList[ExtensionName.headerButton] = { name: ExtensionName.headerButton, class: this.headerButtonExtension, instance }; + if (this.gridOptions.enableHeaderButton) { + this._headerButtonPlugin = new HeaderButtonPlugin(this.pubSubService, this.sharedService); + if (this._headerButtonPlugin) { + if (this.gridOptions.headerButton?.onExtensionRegistered) { + this.gridOptions.headerButton.onExtensionRegistered(this._headerButtonPlugin); + } + this._extensionList[ExtensionName.headerButton] = { name: ExtensionName.headerButton, class: {}, instance: this._headerButtonPlugin }; } } 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 db38c48cc..7ab9e1f4b 100644 --- a/packages/vanilla-bundle/src/components/slick-vanilla-grid-bundle.ts +++ b/packages/vanilla-bundle/src/components/slick-vanilla-grid-bundle.ts @@ -40,7 +40,6 @@ import { ExtensionUtility, GroupItemMetaProviderExtension, HeaderMenuExtension, - HeaderButtonExtension, RowDetailViewExtension, RowSelectionExtension, @@ -363,7 +362,6 @@ export class SlickVanillaGridBundle { const checkboxExtension = new CheckboxSelectorExtension(this.sharedService); const draggableGroupingExtension = new DraggableGroupingExtension(this.extensionUtility, this.sharedService); const groupItemMetaProviderExtension = new GroupItemMetaProviderExtension(this.sharedService); - const headerButtonExtension = new HeaderButtonExtension(this.extensionUtility, this.sharedService); const headerMenuExtension = new HeaderMenuExtension(this.extensionUtility, this.filterService, this._eventPubSubService, this.sharedService, this.sortService, this.translaterService); const rowDetailViewExtension = new RowDetailViewExtension(); const rowMoveManagerExtension = new RowMoveManagerExtension(this.sharedService); @@ -380,7 +378,6 @@ export class SlickVanillaGridBundle { contextMenuExtension, draggableGroupingExtension, groupItemMetaProviderExtension, - headerButtonExtension, headerMenuExtension, rowDetailViewExtension, rowMoveManagerExtension, diff --git a/test/cypress/integration/example13.spec.js b/test/cypress/integration/example13.spec.js index 0037a0aef..f36985ab6 100644 --- a/test/cypress/integration/example13.spec.js +++ b/test/cypress/integration/example13.spec.js @@ -71,25 +71,27 @@ describe('Example 13 - Header Button Plugin', { retries: 1 }, () => { .should('not.exist'); }); - it('should go over the last "Column J" and expect to find the red header button, however it should be usable and number should not display as red', () => { + it('should go over the last "Column J" and expect to find the button to have the disabled class and clicking it should not turn the negative numbers to red neither expect console log after clicking the disabled button', () => { cy.get('.slick-viewport-top.slick-viewport-left') .scrollTo('right') .wait(50); cy.get('.slick-header-columns') .children('.slick-header-column:nth(9)') - .should('contain', 'Column J'); + .should('contain', 'Column J') + .find('.slick-header-button-disabled') + .should('exist'); cy.get('.slick-header-columns') .children('.slick-header-column:nth(9)') - .find('.slick-header-button.mdi-lightbulb-outline.color-warning.faded') + .find('.slick-header-button.slick-header-button-disabled.mdi-lightbulb-outline.color-warning.faded') .should('exist') .click(); cy.get('.slick-header-columns') .children('.slick-header-column:nth(9)') - .find('.slick-header-button.mdi-lightbulb-outline.color-warning.faded') - .should('exist'); // should still be faded + .find('.slick-header-button.slick-header-button-disabled.mdi-lightbulb-outline.color-warning.faded') + .should('exist'); // should still be faded after previous click cy.get('.slick-row') .each(($row, index) => { @@ -99,6 +101,10 @@ describe('Example 13 - Header Button Plugin', { retries: 1 }, () => { cy.wrap($row).children('.slick-cell:nth(9)') .each($cell => expect($cell.html()).to.eq($cell.text())); }); + + cy.window().then((win) => { + expect(win.console.log).to.have.callCount(0); + }); }); it('should resize 1st column and make it wider', () => {