Skip to content

Commit

Permalink
feat: convert GroupItemMetadataProvider Formatter to native HTML for …
Browse files Browse the repository at this point in the history
…CSP (#1215)

* feat: convert GroupItemMetadataProvider Formatter to native HTML for CSP
- in order to depend less on the use of innerHTML, we should convert internal code that have Formatter to use native HTML element to be more CSP compliant. However please note that the user will have to convert themselve the GroupTotals Formatter to native element as well for full CSP compliance on that section of the code
  • Loading branch information
ghiscoding authored Nov 24, 2023
1 parent 0c233ba commit d723856
Show file tree
Hide file tree
Showing 11 changed files with 58 additions and 48 deletions.
21 changes: 14 additions & 7 deletions packages/common/src/core/slickGrid.ts
Original file line number Diff line number Diff line change
Expand Up @@ -469,22 +469,29 @@ export class SlickGrid<TData = any, C extends Column<TData> = Column<TData>, 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) {
Expand Down Expand Up @@ -727,7 +734,7 @@ export class SlickGrid<TData = any, C extends Column<TData> = Column<TData>, 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;
}
Expand Down Expand Up @@ -3364,7 +3371,7 @@ export class SlickGrid<TData = any, C extends Column<TData> = Column<TData>, 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);
Expand Down Expand Up @@ -3539,7 +3546,7 @@ export class SlickGrid<TData = any, C extends Column<TData> = Column<TData>, 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);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
Expand Down Expand Up @@ -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('<span class="slick-group-toggle expanded" aria-expanded="true" style="margin-left: 0px"></span><span class="slick-group-title" level="0">Some Title</span>');
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('<span class="slick-group-toggle expanded" style="margin-left: 0px;"></span><span class="slick-group-title" level="0">Some Title</span>');
});

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('<span class="groupy-toggle groupy-expanded" aria-expanded="true" style="margin-left: 30px"></span><span class="slick-group-title" level="2">Some Title</span>');
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('<span class="groupy-toggle groupy-expanded" style="margin-left: 30px;"></span><span class="slick-group-title" level="2">Some Title</span>');
});

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('<span class="groupy-toggle groupy-collapsed" aria-expanded="false" style="margin-left: 45px"></span><span class="slick-group-title" level="3">Some Title</span>');
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('<span class="groupy-toggle groupy-collapsed" style="margin-left: 45px;"></span><span class="slick-group-title" level="3">Some Title</span>');
});
});

Expand Down
25 changes: 22 additions & 3 deletions packages/common/src/extensions/slickGroupItemMetadataProvider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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;
Expand Down Expand Up @@ -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;
}
Expand All @@ -116,8 +118,25 @@ export class SlickGroupItemMetadataProvider {
const marginLeft = `${groupLevel * indentation}px`;
const toggleClass = item.collapsed ? this._options.toggleCollapsedCssClass : this._options.toggleExpandedCssClass;

return `<span class="${this._options.toggleCssClass} ${toggleClass}" aria-expanded="${!item.collapsed}" style="margin-left: ${marginLeft}"></span>` +
`<span class="${this._options.groupTitleCssClass}" level="${groupLevel}">${item.title || ''}</span>`;
// 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) {
Expand Down
1 change: 1 addition & 0 deletions packages/common/src/global-grid-options.ts
Original file line number Diff line number Diff line change
Expand Up @@ -260,6 +260,7 @@ export const GlobalGridOptions: Partial<GridOption> = {
maxItemToInspectSingleColumnWidthByContent: 5000,
widthToRemoveFromExceededWidthReadjustment: 50,
},
sanitizeHtmlOptions: { ADD_ATTR: ['level'], RETURN_TRUSTED_TYPE: true }, // our default DOMPurify options
treeDataOptions: {
exportIndentMarginLeft: 5,
exportIndentationLeadingChar: '͏͏͏͏͏͏͏͏͏·',
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ export interface ExcelCopyBufferOption<T = any> {
copiedCellStyleLayerKey?: string;

/** option to specify a custom column value extractor function */
dataItemColumnValueExtractor?: (item: any, columnDef: Column<T>) => string | HTMLElement | FormatterResultWithHtml | FormatterResultWithText | null;
dataItemColumnValueExtractor?: (item: any, columnDef: Column<T>) => string | HTMLElement | DocumentFragment | FormatterResultWithHtml | FormatterResultWithText | null;

/** option to specify a custom column value setter function */
dataItemColumnValueSetter?: (item: any, columnDef: Column<T>, value: any) => string | FormatterResultWithHtml | FormatterResultWithText | null;
Expand Down
2 changes: 1 addition & 1 deletion packages/common/src/interfaces/formatter.interface.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import type { SlickGrid } from '../core/index';
import type { Column, FormatterResultWithHtml, FormatterResultWithText } from './index';

export declare type Formatter<T = any> = (row: number, cell: number, value: any, columnDef: Column<T>, dataContext: T, grid: SlickGrid) => string | HTMLElement | FormatterResultWithHtml | FormatterResultWithText;
export declare type Formatter<T = any> = (row: number, cell: number, value: any, columnDef: Column<T>, dataContext: T, grid: SlickGrid) => string | HTMLElement | DocumentFragment | FormatterResultWithHtml | FormatterResultWithText;
19 changes: 0 additions & 19 deletions packages/common/src/services/__tests__/extension.service.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,6 @@ import {
SlickContextMenu,
SlickDraggableGrouping,
SlickGridMenu,
SlickGroupItemMetadataProvider,
SlickHeaderButtons,
SlickHeaderMenu,
SlickRowMoveManager,
Expand Down Expand Up @@ -354,16 +353,13 @@ 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();
expect(gridSpy).toHaveBeenCalled();
expect(pluginInstance).toBeTruthy();
expect(output!.instance).toEqual(pluginInstance);
expect(output).toEqual({ name: ExtensionName.draggableGrouping, instance: pluginInstance } as ExtensionModel<any>);
expect(output2).toEqual({ name: ExtensionName.groupItemMetaProvider, instance: groupMetaInstance } as ExtensionModel<any>);
});

it('should register the GridMenu addon when "enableGridMenu" is set in the grid options', () => {
Expand All @@ -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<any>);
});

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;
Expand Down
8 changes: 0 additions & 8 deletions packages/common/src/services/extension.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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();
});
Expand All @@ -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();
});
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -533,6 +533,9 @@ export class SlickVanillaGridBundle<TData = any> {
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);
Expand Down

0 comments on commit d723856

Please sign in to comment.