diff --git a/packages/common/src/core/slickGrid.ts b/packages/common/src/core/slickGrid.ts index 90f70d090..ea06e8346 100644 --- a/packages/common/src/core/slickGrid.ts +++ b/packages/common/src/core/slickGrid.ts @@ -469,22 +469,29 @@ export class SlickGrid = Column, O e /** * Apply HTML code by 3 different ways depending on what is provided as input and what options are enabled. - * 1. value is an HTMLElement, then simply append the HTML to the target element. + * 1. value is an HTMLElement or DocumentFragment, then first empty the target and simply append the HTML to the target element. * 2. value is string and `enableHtmlRendering` is enabled, then use `target.innerHTML = value;` * 3. value is string and `enableHtmlRendering` is disabled, then use `target.textContent = value;` * @param target - target element to apply to * @param val - input value can be either a string or an HTMLElement + * @param options - `emptyTarget`, defaults to true, will empty the target. `sanitizerOptions` is to provide extra options when using `innerHTML` and the sanitizer */ - applyHtmlCode(target: HTMLElement, val: string | HTMLElement = '', sanitizerOptions?: DOMPurify_.Config) { + applyHtmlCode(target: HTMLElement, val: string | HTMLElement | DocumentFragment = '', options?: { emptyTarget?: boolean; sanitizerOptions?: any; }) { if (target) { - if (val instanceof HTMLElement) { + if (val instanceof HTMLElement || val instanceof DocumentFragment) { + // first empty target and then append new HTML element + const emptyTarget = options?.emptyTarget !== false; + if (emptyTarget) { + emptyElement(target); + } target.appendChild(val); } else { let sanitizedText = val; if (typeof this._options?.sanitizer === 'function') { sanitizedText = this._options.sanitizer(val || ''); } else if (typeof DOMPurify?.sanitize === 'function') { - sanitizedText = DOMPurify.sanitize(val || '', sanitizerOptions || { ADD_ATTR: ['level'], RETURN_TRUSTED_TYPE: true }); + const purifyOptions = (options?.sanitizerOptions ?? this._options.sanitizeHtmlOptions ?? { ADD_ATTR: ['level'], RETURN_TRUSTED_TYPE: true }) as DOMPurify_.Config; + sanitizedText = DOMPurify.sanitize(val || '', purifyOptions); } if (this._options.enableHtmlRendering) { @@ -727,7 +734,7 @@ export class SlickGrid = Column, O e // disable text selection in grid cells except in input and textarea elements // (this is IE-specific, because selectstart event will only fire in IE) this._viewport.forEach((view) => { - this._bindingEventService.bind(view, 'selectstart', (event) => { + this._bindingEventService.bind(view, 'selectstart', (event: Event) => { if (event.target instanceof HTMLInputElement || event.target instanceof HTMLTextAreaElement) { return; } @@ -3364,7 +3371,7 @@ export class SlickGrid = Column, O e } let value: any = null; - let formatterResult: FormatterResultWithHtml | FormatterResultWithText | HTMLElement | string = ''; + let formatterResult: FormatterResultWithHtml | FormatterResultWithText | HTMLElement | DocumentFragment | string = ''; if (item) { value = this.getDataItemValueForColumn(item, m); formatterResult = this.getFormatter(row, m)(row, cell, value, m, item, this as unknown as SlickGrid); @@ -3539,7 +3546,7 @@ export class SlickGrid = Column, O e } /** Apply a Formatter Result to a Cell DOM Node */ - applyFormatResultToCellNode(formatterResult: FormatterResultWithHtml | FormatterResultWithText | string | HTMLElement, cellNode: HTMLDivElement, suppressRemove?: boolean) { + applyFormatResultToCellNode(formatterResult: FormatterResultWithHtml | FormatterResultWithText | string | HTMLElement | DocumentFragment, cellNode: HTMLDivElement, suppressRemove?: boolean) { if (formatterResult === null || formatterResult === undefined) { formatterResult = ''; } if (Object.prototype.toString.call(formatterResult) !== '[object Object]') { this.applyHtmlCode(cellNode, formatterResult as string | HTMLElement); diff --git a/packages/common/src/extensions/__tests__/slickGroupItemMetadataProvider.spec.ts b/packages/common/src/extensions/__tests__/slickGroupItemMetadataProvider.spec.ts index f8835029d..812a91aa4 100644 --- a/packages/common/src/extensions/__tests__/slickGroupItemMetadataProvider.spec.ts +++ b/packages/common/src/extensions/__tests__/slickGroupItemMetadataProvider.spec.ts @@ -28,6 +28,7 @@ const dataViewStub = { } as unknown as SlickDataView; const gridStub = { + applyHtmlCode: (elm, val) => elm.innerHTML = val || '', autosizeColumns: jest.fn(), getActiveCell: jest.fn(), getColumnIndex: jest.fn(), @@ -115,21 +116,27 @@ describe('GroupItemMetadataProvider Service', () => { }); it('should return Grouping info formatted with a group level 0 without indentation when calling "defaultGroupCellFormatter" with option "enableExpandCollapse" set to True', () => { + service.init(gridStub); service.setOptions({ enableExpandCollapse: true }); - const output = service.getOptions().groupFormatter!(0, 0, 'test', mockColumns[0], { title: 'Some Title' }, gridStub); - expect(output).toBe('Some Title'); + const output = service.getOptions().groupFormatter!(0, 0, 'test', mockColumns[0], { title: 'Some Title' }, gridStub) as DocumentFragment; + const htmlContent = [].map.call(output.childNodes, x => x.outerHTML).join('') + expect(htmlContent).toBe('Some Title'); }); it('should return Grouping info formatted with a group level 2 with indentation of 30px when calling "defaultGroupCellFormatter" with option "enableExpandCollapse" set to True and level 2', () => { + service.init(gridStub); service.setOptions({ enableExpandCollapse: true, toggleCssClass: 'groupy-toggle', toggleExpandedCssClass: 'groupy-expanded' }); - const output = service.getOptions().groupFormatter!(0, 0, 'test', mockColumns[0], { level: 2, title: 'Some Title' }, gridStub); - expect(output).toBe('Some Title'); + const output = service.getOptions().groupFormatter!(0, 0, 'test', mockColumns[0], { level: 2, title: 'Some Title' }, gridStub) as DocumentFragment; + const htmlContent = [].map.call(output.childNodes, x => x.outerHTML).join('') + expect(htmlContent).toBe('Some Title'); }); it('should return Grouping info formatted with a group level 2 with indentation of 30px when calling "defaultGroupCellFormatter" with option "enableExpandCollapse" set to True and level 2', () => { + service.init(gridStub); service.setOptions({ enableExpandCollapse: true, toggleCssClass: 'groupy-toggle', toggleCollapsedCssClass: 'groupy-collapsed' }); - const output = service.getOptions().groupFormatter!(0, 0, 'test', mockColumns[0], { collapsed: true, level: 3, title: 'Some Title' }, gridStub); - expect(output).toBe('Some Title'); + const output = service.getOptions().groupFormatter!(0, 0, 'test', mockColumns[0], { collapsed: true, level: 3, title: 'Some Title' }, gridStub) as DocumentFragment; + const htmlContent = [].map.call(output.childNodes, x => x.outerHTML).join('') + expect(htmlContent).toBe('Some Title'); }); }); diff --git a/packages/common/src/extensions/slickGroupItemMetadataProvider.ts b/packages/common/src/extensions/slickGroupItemMetadataProvider.ts index d02e66fa2..7b8975891 100644 --- a/packages/common/src/extensions/slickGroupItemMetadataProvider.ts +++ b/packages/common/src/extensions/slickGroupItemMetadataProvider.ts @@ -7,6 +7,7 @@ import type { ItemMetadata, OnClickEventArgs, } from '../interfaces/index'; +import { createDomElement } from '../services/domUtilities'; /** * Provides item metadata for group (SlickGroup) and totals (SlickTotals) rows produced by the DataView. @@ -17,6 +18,7 @@ import type { * If "grid.registerPlugin(...)" is not called, expand & collapse will not work. */ export class SlickGroupItemMetadataProvider { + pluginName = 'SlickGroupItemMetadataProvider' as const; protected _eventHandler: SlickEventHandler; protected _grid!: SlickGrid; protected _options: GroupItemMetadataProviderOption; @@ -106,7 +108,7 @@ export class SlickGroupItemMetadataProvider { // protected functions // ------------------- - protected defaultGroupCellFormatter(_row: number, _cell: number, _value: any, _columnDef: Column, item: any): string { + protected defaultGroupCellFormatter(_row: number, _cell: number, _value: any, _columnDef: Column, item: any) { if (!this._options.enableExpandCollapse) { return item.title; } @@ -116,8 +118,25 @@ export class SlickGroupItemMetadataProvider { const marginLeft = `${groupLevel * indentation}px`; const toggleClass = item.collapsed ? this._options.toggleCollapsedCssClass : this._options.toggleExpandedCssClass; - return `` + - `${item.title || ''}`; + // use a DocumentFragment to avoid creating an extra div container + const containerElm = document.createDocumentFragment(); + + // 1. group toggle span + containerElm.appendChild(createDomElement('span', { + className: `${this._options.toggleCssClass} ${toggleClass}`, + ariaExpanded: String(!item.collapsed), + style: { marginLeft } + })); + + // 2. group title span + const groupTitleElm = createDomElement('span', { className: this._options.groupTitleCssClass || '' }); + groupTitleElm.setAttribute('level', groupLevel); + (item.title instanceof HTMLElement) + ? groupTitleElm.appendChild(item.title) + : this._grid.applyHtmlCode(groupTitleElm, item.title ?? ''); + containerElm.appendChild(groupTitleElm); + + return containerElm; } protected defaultTotalsCellFormatter(_row: number, _cell: number, _value: any, columnDef: Column, item: any, grid: SlickGrid) { diff --git a/packages/common/src/global-grid-options.ts b/packages/common/src/global-grid-options.ts index 3c9f7fcba..758e1937f 100644 --- a/packages/common/src/global-grid-options.ts +++ b/packages/common/src/global-grid-options.ts @@ -260,6 +260,7 @@ export const GlobalGridOptions: Partial = { maxItemToInspectSingleColumnWidthByContent: 5000, widthToRemoveFromExceededWidthReadjustment: 50, }, + sanitizeHtmlOptions: { ADD_ATTR: ['level'], RETURN_TRUSTED_TYPE: true }, // our default DOMPurify options treeDataOptions: { exportIndentMarginLeft: 5, exportIndentationLeadingChar: '͏͏͏͏͏͏͏͏͏·', diff --git a/packages/common/src/interfaces/excelCopyBufferOption.interface.ts b/packages/common/src/interfaces/excelCopyBufferOption.interface.ts index 6654d8d7f..0d2a499a4 100644 --- a/packages/common/src/interfaces/excelCopyBufferOption.interface.ts +++ b/packages/common/src/interfaces/excelCopyBufferOption.interface.ts @@ -17,7 +17,7 @@ export interface ExcelCopyBufferOption { copiedCellStyleLayerKey?: string; /** option to specify a custom column value extractor function */ - dataItemColumnValueExtractor?: (item: any, columnDef: Column) => string | HTMLElement | FormatterResultWithHtml | FormatterResultWithText | null; + dataItemColumnValueExtractor?: (item: any, columnDef: Column) => string | HTMLElement | DocumentFragment | FormatterResultWithHtml | FormatterResultWithText | null; /** option to specify a custom column value setter function */ dataItemColumnValueSetter?: (item: any, columnDef: Column, value: any) => string | FormatterResultWithHtml | FormatterResultWithText | null; diff --git a/packages/common/src/interfaces/formatter.interface.ts b/packages/common/src/interfaces/formatter.interface.ts index 7ab9f6ad2..da62c637f 100644 --- a/packages/common/src/interfaces/formatter.interface.ts +++ b/packages/common/src/interfaces/formatter.interface.ts @@ -1,4 +1,4 @@ import type { SlickGrid } from '../core/index'; import type { Column, FormatterResultWithHtml, FormatterResultWithText } from './index'; -export declare type Formatter = (row: number, cell: number, value: any, columnDef: Column, dataContext: T, grid: SlickGrid) => string | HTMLElement | FormatterResultWithHtml | FormatterResultWithText; +export declare type Formatter = (row: number, cell: number, value: any, columnDef: Column, dataContext: T, grid: SlickGrid) => string | HTMLElement | DocumentFragment | FormatterResultWithHtml | FormatterResultWithText; diff --git a/packages/common/src/services/__tests__/extension.service.spec.ts b/packages/common/src/services/__tests__/extension.service.spec.ts index 94690816c..4fa3fe0df 100644 --- a/packages/common/src/services/__tests__/extension.service.spec.ts +++ b/packages/common/src/services/__tests__/extension.service.spec.ts @@ -19,7 +19,6 @@ import { SlickContextMenu, SlickDraggableGrouping, SlickGridMenu, - SlickGroupItemMetadataProvider, SlickHeaderButtons, SlickHeaderMenu, SlickRowMoveManager, @@ -354,8 +353,6 @@ describe('ExtensionService', () => { const output = service.getExtensionByName(ExtensionName.draggableGrouping); const pluginInstance = service.getExtensionInstanceByName(ExtensionName.draggableGrouping); - const groupMetaInstance = service.getExtensionInstanceByName(ExtensionName.groupItemMetaProvider); - const output2 = service.getExtensionByName(ExtensionName.groupItemMetaProvider); expect(onRegisteredMock).toHaveBeenCalledWith(expect.toBeObject()); expect(output!.instance instanceof SlickDraggableGrouping).toBeTrue(); @@ -363,7 +360,6 @@ describe('ExtensionService', () => { expect(pluginInstance).toBeTruthy(); expect(output!.instance).toEqual(pluginInstance); expect(output).toEqual({ name: ExtensionName.draggableGrouping, instance: pluginInstance } as ExtensionModel); - expect(output2).toEqual({ name: ExtensionName.groupItemMetaProvider, instance: groupMetaInstance } as ExtensionModel); }); it('should register the GridMenu addon when "enableGridMenu" is set in the grid options', () => { @@ -383,21 +379,6 @@ describe('ExtensionService', () => { expect(output!.instance instanceof SlickGridMenu).toBeTrue(); }); - it('should register the GroupItemMetaProvider addon when "enableGrouping" is set in the grid options', () => { - const gridOptionsMock = { enableGrouping: true } as GridOption; - const gridSpy = jest.spyOn(SharedService.prototype, 'gridOptions', 'get').mockReturnValue(gridOptionsMock); - - service.bindDifferentExtensions(); - const output = service.getExtensionByName(ExtensionName.groupItemMetaProvider); - const pluginInstance = service.getExtensionInstanceByName(ExtensionName.groupItemMetaProvider); - - expect(gridSpy).toHaveBeenCalled(); - expect(output!.instance instanceof SlickGroupItemMetadataProvider).toBeTrue(); - expect(pluginInstance).toBeTruthy(); - expect(output!.instance).toEqual(pluginInstance); - expect(output).toEqual({ name: ExtensionName.groupItemMetaProvider, instance: pluginInstance } as ExtensionModel); - }); - it('should register the CheckboxSelector addon when "enableCheckboxSelector" is set in the grid options', () => { const columnsMock = [{ id: 'field1', field: 'field1', width: 100, cssClass: 'red' }] as Column[]; const gridOptionsMock = { enableCheckboxSelector: true } as GridOption; diff --git a/packages/common/src/services/extension.service.ts b/packages/common/src/services/extension.service.ts index 383319d68..f2acda5c1 100644 --- a/packages/common/src/services/extension.service.ts +++ b/packages/common/src/services/extension.service.ts @@ -246,14 +246,6 @@ export class ExtensionService { this._extensionList[ExtensionName.gridMenu] = { name: ExtensionName.gridMenu, instance: this._gridMenuControl }; } - // Grouping Plugin - // register the group item metadata provider to add expand/collapse group handlers - if (this.gridOptions.enableDraggableGrouping || this.gridOptions.enableGrouping) { - this._groupItemMetadataProviderService = this._groupItemMetadataProviderService ? this._groupItemMetadataProviderService : new SlickGroupItemMetadataProvider(); - this._groupItemMetadataProviderService.init(this.sharedService.slickGrid); - this._extensionList[ExtensionName.groupItemMetaProvider] = { name: ExtensionName.groupItemMetaProvider, instance: this._groupItemMetadataProviderService }; - } - // Header Button Plugin if (this.gridOptions.enableHeaderButton) { const headerButtonPlugin = new SlickHeaderButtons(this.extensionUtility, this.pubSubService, this.sharedService); diff --git a/packages/empty-warning-component/src/slick-empty-warning.component.ts b/packages/empty-warning-component/src/slick-empty-warning.component.ts index 72c9a64a2..8080c6bc1 100644 --- a/packages/empty-warning-component/src/slick-empty-warning.component.ts +++ b/packages/empty-warning-component/src/slick-empty-warning.component.ts @@ -94,12 +94,10 @@ export class SlickEmptyWarningComponent implements ExternalResource { } if (!this._warningLeftElement && gridCanvasLeftElm && gridCanvasRightElm) { - const sanitizedOptions = this.gridOptions?.sanitizeHtmlOptions ?? {}; - this._warningLeftElement = document.createElement('div'); this._warningLeftElement.classList.add(emptyDataClassName); this._warningLeftElement.classList.add('left'); - this.grid.applyHtmlCode(this._warningLeftElement, warningMessage, sanitizedOptions); + this.grid.applyHtmlCode(this._warningLeftElement, warningMessage); // clone the warning element and add the "right" class to it so we can distinguish this._warningRightElement = this._warningLeftElement.cloneNode(true) as HTMLDivElement; diff --git a/packages/vanilla-bundle/src/components/__tests__/slick-vanilla-grid.spec.ts b/packages/vanilla-bundle/src/components/__tests__/slick-vanilla-grid.spec.ts index 7a8e993bb..df7931b27 100644 --- a/packages/vanilla-bundle/src/components/__tests__/slick-vanilla-grid.spec.ts +++ b/packages/vanilla-bundle/src/components/__tests__/slick-vanilla-grid.spec.ts @@ -801,6 +801,7 @@ describe('Slick-Vanilla-Grid-Bundle Component instantiated via Constructor', () expect(dataviewSpy).toHaveBeenCalledWith({ inlineFilters: false, groupItemMetadataProvider: expect.anything() }); expect(sharedService.groupItemMetadataProvider instanceof SlickGroupItemMetadataProvider).toBeTruthy(); expect(sharedMetaSpy).toHaveBeenCalledWith(expect.toBeObject()); + expect(mockGrid.registerPlugin).toHaveBeenCalled(); component.dispose(); }); @@ -815,6 +816,7 @@ describe('Slick-Vanilla-Grid-Bundle Component instantiated via Constructor', () expect(dataviewSpy).toHaveBeenCalledWith({ inlineFilters: false, groupItemMetadataProvider: expect.anything() }); expect(sharedMetaSpy).toHaveBeenCalledWith(expect.toBeObject()); expect(sharedService.groupItemMetadataProvider instanceof SlickGroupItemMetadataProvider).toBeTruthy(); + expect(mockGrid.registerPlugin).toHaveBeenCalled(); component.dispose(); }); 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 071423aab..906be29df 100644 --- a/packages/vanilla-bundle/src/components/slick-vanilla-grid-bundle.ts +++ b/packages/vanilla-bundle/src/components/slick-vanilla-grid-bundle.ts @@ -533,6 +533,9 @@ export class SlickVanillaGridBundle { this.sharedService.dataView = this.dataView as SlickDataView; this.sharedService.slickGrid = this.slickGrid as SlickGrid; this.sharedService.gridContainerElement = this._gridContainerElm; + if (this.groupItemMetadataProvider) { + this.slickGrid.registerPlugin(this.groupItemMetadataProvider); // register GroupItemMetadataProvider when Grouping is enabled + } this.extensionService.bindDifferentExtensions(); this.bindDifferentHooks(this.slickGrid, this._gridOptions, this.dataView as SlickDataView);