Skip to content

Commit

Permalink
feat: convert CheckSelectColumn plugin to native HTML for CSP safe co…
Browse files Browse the repository at this point in the history
…de (#1332)

* feat: convert CheckSelectColumn plugin to native HTML for CSP safe code
  • Loading branch information
ghiscoding authored Jan 16, 2024
1 parent abe344b commit 2b9216d
Show file tree
Hide file tree
Showing 23 changed files with 166 additions and 119 deletions.
2 changes: 1 addition & 1 deletion docs/migrations/migration-to-4.x.md
Original file line number Diff line number Diff line change
Expand Up @@ -174,7 +174,7 @@ The previous Formatters implementation were all returning HTML strings (or `Form
**Since all Formatters were rewritten as HTML, you might get unexpected behavior in your UI, you will have to inspect your UI and make changes accordingly**. For example, I had to adjust [Example 12](https://ghiscoding.github.io/slickgrid-universal/#/example12) `customEditableInputFormatter` because it was expecting all Formatters to return an HTML string and I was concatenating them to an HTML string but that code was now resulting in `[object HTMLElement]`, so I had to update the code and detect if Formatter output is a native element then do something or else do something else... Below is the adjustment I had to do to fix my own demo (your use case may vary)

> **Note** some Formatters now return `HTMLElement` or `DocumentFragment`, you can add a condition check with `instanceof HTMLElement` or `instanceof DocumentFragment`, however please also note that a `DocumentFragment` does not have `innerHTML`/`outerHTML` (you can write a simple function for loop to get them, see this [SO](https://stackoverflow.com/a/51017093/1212166) or use `getHTMLFromFragment(elm)` from Slickgrid-Universal)
> **Note** some Formatters now return `HTMLElement` or `DocumentFragment`, you can add a condition check with `instanceof HTMLElement` or `instanceof DocumentFragment`, however please also note that a `DocumentFragment` does not have `innerHTML`/`outerHTML` (you can write a simple function for loop to get them, see this [SO](https://stackoverflow.com/a/51017093/1212166) or use `getHtmlStringOutput(elm)` from Slickgrid-Universal)
```diff
const customEditableInputFormatter: Formatter = (_row, _cell, value, columnDef, dataContext, grid) => {
Expand Down
61 changes: 31 additions & 30 deletions packages/common/src/core/slickGrid.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1327,42 +1327,43 @@ export class SlickGrid<TData = any, C extends Column<TData> = Column<TData>, O e
/**
* Updates an existing column definition and a corresponding header DOM element with the new title and tooltip.
* @param {Number|String} columnId Column id.
* @param {String} [title] New column name.
* @param {string | HTMLElement | DocumentFragment} [title] New column name.
* @param {String} [toolTip] New column tooltip.
*/
updateColumnHeader(columnId: number | string, title?: string | HTMLElement, toolTip?: string) {
if (!this.initialized) { return; }
const idx = this.getColumnIndex(columnId);
if (!isDefined(idx)) {
return;
}

const columnDef = this.columns[idx];
const header: any = this.getColumnByIndex(idx);
if (header) {
if (title !== undefined) {
this.columns[idx].name = title;
}
if (toolTip !== undefined) {
this.columns[idx].toolTip = toolTip;
updateColumnHeader(columnId: number | string, title?: string | HTMLElement | DocumentFragment, toolTip?: string) {
if (this.initialized) {
const idx = this.getColumnIndex(columnId);
if (!isDefined(idx)) {
return;
}

this.triggerEvent(this.onBeforeHeaderCellDestroy, {
node: header,
column: columnDef,
grid: this
});
const columnDef = this.columns[idx];
const header: HTMLElement | undefined = this.getColumnByIndex(idx);
if (header) {
if (title !== undefined) {
this.columns[idx].name = title;
}
if (toolTip !== undefined) {
this.columns[idx].toolTip = toolTip;
}

header.setAttribute('title', toolTip || '');
if (title !== undefined) {
this.applyHtmlCode(header.children[0], title);
}
this.triggerEvent(this.onBeforeHeaderCellDestroy, {
node: header,
column: columnDef,
grid: this
});

this.triggerEvent(this.onHeaderCellRendered, {
node: header,
column: columnDef,
grid: this
});
header.setAttribute('title', toolTip || '');
if (title !== undefined) {
this.applyHtmlCode(header.children[0] as HTMLElement, title);
}

this.triggerEvent(this.onHeaderCellRendered, {
node: header,
column: columnDef,
grid: this
});
}
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { SlickCheckboxSelectColumn } from '../slickCheckboxSelectColumn';
import type { Column, OnSelectedRowsChangedEventArgs } from '../../interfaces/index';
import { SlickRowSelectionModel } from '../../extensions/slickRowSelectionModel';
import { SlickEvent, SlickGrid } from '../../core/index';
import { getHtmlStringOutput } from '@slickgrid-universal/utils';

const addVanillaEventPropagation = function (event, commandKey = '', keyName = '', target?: HTMLElement, which: string | number = '') {
Object.defineProperty(event, 'isPropagationStopped', { writable: true, configurable: true, value: jest.fn() });
Expand Down Expand Up @@ -186,7 +187,7 @@ describe('SlickCheckboxSelectColumn Plugin', () => {
expect(plugin).toBeTruthy();
expect(updateColHeaderSpy).toHaveBeenCalledWith(
'_checkbox_selector',
`<input id="header-selector${plugin.selectAllUid}" type="checkbox" aria-checked="false"><label for="header-selector${plugin.selectAllUid}"></label>`,
plugin.createCheckboxElement(`header-selector${plugin.selectAllUid}`),
'Select/Deselect All'
);
expect(preventDefaultSpy).toHaveBeenCalled();
Expand Down Expand Up @@ -382,10 +383,10 @@ describe('SlickCheckboxSelectColumn Plugin', () => {
it('should create a new row selection column definition', () => {
plugin = new SlickCheckboxSelectColumn(pubSubServiceStub);
plugin.init(gridStub);
const nameHtmlOutput = getHtmlStringOutput(plugin.getColumnDefinition()?.name || '', 'outerHTML');

expect(plugin.getColumnDefinition()).toEqual({
id: '_checkbox_selector',
name: `<input id="header-selector${plugin.selectAllUid}" type="checkbox"><label for="header-selector${plugin.selectAllUid}"></label>`,
toolTip: 'Select/Deselect All',
field: '_checkbox_selector',
cssClass: null,
Expand All @@ -398,8 +399,10 @@ describe('SlickCheckboxSelectColumn Plugin', () => {
resizable: false,
sortable: false,
width: 30,
name: expect.any(DocumentFragment),
formatter: expect.toBeFunction(),
});
expect(nameHtmlOutput).toBe(`<input id="header-selector${plugin.selectAllUid}" type="checkbox" aria-checked="false"><label for="header-selector${plugin.selectAllUid}"></label>`);
});

it('should create the plugin and add the Toggle All checkbox in the filter header row and expect toggle all to work when clicked', () => {
Expand Down Expand Up @@ -431,22 +434,28 @@ describe('SlickCheckboxSelectColumn Plugin', () => {
field: 'chk-id',
hideSelectAllCheckbox: false,
id: 'chk-id',
name: `<input id="header-selector${plugin.selectAllUid}" type="checkbox"><label for="header-selector${plugin.selectAllUid}"></label>`,
resizable: false,
sortable: false,
toolTip: 'Select/Deselect All',
width: 30,
};

plugin.create(mockColumns, { checkboxSelector: { columnId: 'chk-id' } });
const nameHtmlOutput = getHtmlStringOutput(mockColumns[0]?.name || '', 'outerHTML');

expect(pubSubSpy).toHaveBeenCalledWith('onPluginColumnsChanged', { columns: expect.arrayContaining([{ ...checkboxColumnMock, formatter: expect.toBeFunction() }]), pluginName: 'CheckboxSelectColumn' });
expect(pubSubSpy).toHaveBeenCalledWith('onPluginColumnsChanged', {
columns: expect.arrayContaining([{ ...checkboxColumnMock, name: expect.any(DocumentFragment), formatter: expect.toBeFunction() }]),
pluginName: 'CheckboxSelectColumn'
});
expect(plugin).toBeTruthy();
expect(mockColumns[0]).toEqual(expect.objectContaining({ ...checkboxColumnMock, formatter: expect.toBeFunction() }));
expect(nameHtmlOutput).toBe(`<input id="header-selector${plugin.selectAllUid}" type="checkbox" aria-checked="false"><label for="header-selector${plugin.selectAllUid}"></label>`);
});


it('should call the "create" method and expect plugin to be created at position 1 when defined', () => {
plugin.create(mockColumns, { checkboxSelector: { columnIndexPosition: 1 } });
const nameHtmlOutput = getHtmlStringOutput(mockColumns[1]?.name || '', 'outerHTML');

expect(plugin).toBeTruthy();
expect(mockColumns[1]).toEqual({
Expand All @@ -460,12 +469,13 @@ describe('SlickCheckboxSelectColumn Plugin', () => {
formatter: expect.toBeFunction(),
hideSelectAllCheckbox: false,
id: '_checkbox_selector',
name: `<input id="header-selector${plugin.selectAllUid}" type="checkbox"><label for="header-selector${plugin.selectAllUid}"></label>`,
name: expect.any(DocumentFragment),
resizable: false,
sortable: false,
toolTip: 'Select/Deselect All',
width: 30,
});
expect(nameHtmlOutput).toBe(`<input id="header-selector${plugin.selectAllUid}" type="checkbox" aria-checked="false"><label for="header-selector${plugin.selectAllUid}"></label>`);
});

it('should add a "name" and "hideSelectAllCheckbox: true" and call the "create" method and expect plugin to be created with a column name and without a checkbox', () => {
Expand Down Expand Up @@ -504,11 +514,11 @@ describe('SlickCheckboxSelectColumn Plugin', () => {
it('should process the "checkboxSelectionFormatter" and expect necessary Formatter to return null when selectableOverride is returning False', () => {
plugin.init(gridStub);
plugin.selectableOverride(() => true);
const output = plugin.getColumnDefinition().formatter!(0, 0, null, { id: 'checkbox_selector', field: '' } as Column, { firstName: 'John', lastName: 'Doe', age: 33 }, gridStub);
const output = plugin.getColumnDefinition().formatter!(0, 0, null, { id: 'checkbox_selector', field: '' } as Column, { firstName: 'John', lastName: 'Doe', age: 33 }, gridStub) as DocumentFragment;

expect(plugin).toBeTruthy();
expect(output).toContain(`<input id="selector`);
expect(output).toContain(`<label for="selector`);
expect(output.querySelector('input')?.id).toMatch(/^selector.*/);
expect(output.querySelector('label')?.htmlFor).toMatch(/^selector.*/);
});

it('should trigger "onClick" event and expect toggleRowSelection to be called', () => {
Expand Down Expand Up @@ -654,7 +664,7 @@ describe('SlickCheckboxSelectColumn Plugin', () => {
expect(setSelectedRowSpy).not.toHaveBeenCalled();
expect(updateColumnHeaderSpy).toHaveBeenCalledWith(
'_checkbox_selector',
`<input id="header-selector${plugin.selectAllUid}" type="checkbox" aria-checked="false"><label for="header-selector${plugin.selectAllUid}"></label>`,
plugin.createCheckboxElement(`header-selector${plugin.selectAllUid}`),
'Select/Deselect All'
);
});
Expand All @@ -680,7 +690,7 @@ describe('SlickCheckboxSelectColumn Plugin', () => {
expect(setSelectedRowSpy).not.toHaveBeenCalled();
expect(updateColumnHeaderSpy).toHaveBeenCalledWith(
'_checkbox_selector',
`<input id="header-selector${plugin.selectAllUid}" type="checkbox" aria-checked="false"><label for="header-selector${plugin.selectAllUid}"></label>`,
plugin.createCheckboxElement(`header-selector${plugin.selectAllUid}`),
'Select/Deselect All'
);
});
Expand Down Expand Up @@ -711,18 +721,15 @@ describe('SlickCheckboxSelectColumn Plugin', () => {
expect(setSelectedRowSpy).toHaveBeenCalled();
expect(updateColumnHeaderSpy).toHaveBeenCalledWith(
'_checkbox_selector',
`<input id="header-selector${plugin.selectAllUid}" type="checkbox" checked="checked" aria-checked="true"><label for="header-selector${plugin.selectAllUid}"></label>`,
plugin.createCheckboxElement(`header-selector${plugin.selectAllUid}`, true),
'Select/Deselect All'
);
});

it('should trigger "onSelectedRowIdsChanged" event and invalidate row and render to be called also with "setSelectedRows" when checkSelectableOverride returns False and input select checkbox is all checked', () => {
const nodeElm = document.createElement('div');
nodeElm.className = 'slick-headerrow-column';
const invalidateRowSpy = jest.spyOn(gridStub, 'invalidateRow');
const renderSpy = jest.spyOn(gridStub, 'render');
const updateColumnHeaderSpy = jest.spyOn(gridStub, 'updateColumnHeader');
const setSelectedRowSpy = jest.spyOn(gridStub, 'setSelectedRows');
jest.spyOn(dataViewStub, 'getAllSelectedFilteredIds').mockReturnValueOnce([1, 2]);
jest.spyOn(gridStub.getEditorLock(), 'commitCurrentEdit').mockReturnValue(true);
jest.spyOn(dataViewStub, 'getFilteredItems').mockReturnValueOnce([{ id: 22, firstName: 'John', lastName: 'Doe', age: 30 }]);
Expand All @@ -743,7 +750,7 @@ describe('SlickCheckboxSelectColumn Plugin', () => {
expect(plugin).toBeTruthy();
expect(updateColumnHeaderSpy).toHaveBeenCalledWith(
'_checkbox_selector',
`<input id="header-selector${plugin.selectAllUid}" type="checkbox" checked="checked" aria-checked="true"><label for="header-selector${plugin.selectAllUid}"></label>`,
plugin.createCheckboxElement(`header-selector${plugin.selectAllUid}`, true),
'Select/Deselect All'
);
});
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import 'jest-extended';
import type { Column, GridOption, GroupItemMetadataProviderOption } from '../../interfaces';
import { SlickGroupItemMetadataProvider } from '../slickGroupItemMetadataProvider';
import { type SlickDataView, SlickEvent, SlickGrid, SlickGroup } from '../../core/index';
import { getHTMLFromFragment } from '@slickgrid-universal/utils';
import { getHtmlStringOutput } from '@slickgrid-universal/utils';

const gridOptionMock = {
enablePagination: true,
Expand Down Expand Up @@ -138,7 +138,7 @@ describe('GroupItemMetadataProvider Service', () => {
const spanElm = document.createElement('span');
spanElm.textContent = 'Another Title';
const output = service.getOptions().groupFormatter!(0, 0, 'test', mockColumns[0], { title: spanElm }, gridStub) as DocumentFragment;
const htmlContent = getHTMLFromFragment(output, 'outerHTML');
const htmlContent = getHtmlStringOutput(output, 'outerHTML');
expect(htmlContent).toBe('<span class="slick-group-toggle expanded" aria-expanded="true" style="margin-left: 0px;"></span><span class="slick-group-title" level="0"><span>Another Title</span></span>');
});

Expand All @@ -148,23 +148,23 @@ describe('GroupItemMetadataProvider Service', () => {
const fragment = document.createDocumentFragment();
fragment.textContent = 'Fragment Title';
const output = service.getOptions().groupFormatter!(0, 0, 'test', mockColumns[0], { title: fragment }, gridStub) as DocumentFragment;
const htmlContent = getHTMLFromFragment(output, 'outerHTML');
const htmlContent = getHtmlStringOutput(output, 'outerHTML');
expect(htmlContent).toBe('<span class="slick-group-toggle expanded" aria-expanded="true" style="margin-left: 0px;"></span><span class="slick-group-title" level="0">Fragment 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) as DocumentFragment;
const htmlContent = getHTMLFromFragment(output, 'outerHTML');
const htmlContent = getHtmlStringOutput(output, 'outerHTML');
expect(htmlContent).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>');
});

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) as DocumentFragment;
const htmlContent = [].map.call(output.childNodes, x => x.outerHTML).join('')
const htmlContent = [].map.call(output.childNodes, x => x.outerHTML).join('');
expect(htmlContent).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>');
});
});
Expand Down
4 changes: 2 additions & 2 deletions packages/common/src/extensions/slickAutoTooltip.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@

import { stripTags } from '@slickgrid-universal/utils';
import { getHtmlStringOutput, stripTags } from '@slickgrid-universal/utils';

import type { AutoTooltipOption, Column } from '../interfaces/index';
import { SlickEventHandler, type SlickEventData, type SlickGrid } from '../core/index';
Expand Down Expand Up @@ -101,7 +101,7 @@ export class SlickAutoTooltip {
node = targetElm.closest<HTMLDivElement>('.slick-header-column');
if (node && !(column?.toolTip)) {
const titleVal = (targetElm.clientWidth < node.clientWidth) ? column?.name ?? '' : '';
node.title = titleVal instanceof HTMLElement ? stripTags(titleVal.innerHTML) : titleVal;
node.title = stripTags(getHtmlStringOutput(titleVal, 'innerHTML'));
}
}
node = null;
Expand Down
10 changes: 5 additions & 5 deletions packages/common/src/extensions/slickCellExternalCopyManager.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { createDomElement, stripTags } from '@slickgrid-universal/utils';
import { createDomElement, getHtmlStringOutput, stripTags } from '@slickgrid-universal/utils';

import type { Column, ExcelCopyBufferOption, ExternalCopyClipCommand, OnEventArgs } from '../interfaces/index';
import { SlickEvent, SlickEventData, SlickEventHandler, type SlickGrid, SlickRange, SlickDataView, Utils as SlickUtils } from '../core/index';
Expand Down Expand Up @@ -105,14 +105,14 @@ export class SlickCellExternalCopyManager {
this._grid.removeCellCssStyles(this._copiedCellStyleLayerKey);
}

getHeaderValueForColumn(columnDef: Column) {
getHeaderValueForColumn(columnDef: Column): string {
if (typeof this._addonOptions.headerColumnValueExtractor === 'function') {
const val = this._addonOptions.headerColumnValueExtractor(columnDef);
const val = getHtmlStringOutput(this._addonOptions.headerColumnValueExtractor(columnDef), 'innerHTML');
if (val) {
return (val instanceof HTMLElement) ? stripTags(val.innerHTML) : val;
return stripTags(val);
}
}
return columnDef.name instanceof HTMLElement ? stripTags(columnDef.name.innerHTML) : columnDef.name;
return getHtmlStringOutput(columnDef.name || '', 'innerHTML');
}

getDataItemValueForColumn(item: any, columnDef: Column, event: SlickEventData) {
Expand Down
Loading

0 comments on commit 2b9216d

Please sign in to comment.