Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: convert GroupItemMetadataProvider Formatter to native HTML for CSP #1215

Merged
merged 5 commits into from
Nov 24, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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