Skip to content

Commit

Permalink
feat: change CheckSelectColumn plugin to native HTML for CSP safe code (
Browse files Browse the repository at this point in the history
#973)

* feat: change CheckSelectColumn plugin to native HTML for CSP safe code
- change Column interface `name` to also accept `DocumentFragment`
- change `headerColumnValueExtractor` to also accept `DocumentFragment`
- add `getHtmlStringOutput()` util to get HTML from any type of input (string, number, HTMLElement or DocumentFragment), which is especially useful with `DocumentFragment` which doesn't return HTML by default
- increase grid height in demo to fully test the checkbox count shown in current page (when using Pagination) in Cypress E2E tests
  • Loading branch information
ghiscoding authored Jan 16, 2024
1 parent 039f4ae commit bef663c
Show file tree
Hide file tree
Showing 14 changed files with 103 additions and 77 deletions.
11 changes: 5 additions & 6 deletions cypress/e2e/example-checkbox-header-row.cy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -156,15 +156,14 @@ describe('Example - Checkbox Header Row', () => {
.click();

cy.get('.slick-cell-checkboxsel input:checked')
.should('have.length', 11);
.should('have.length', 13);
});

it('should go to last page and still expect all rows selected in current page', () => {
cy.get('.sgi-chevron-end')
.click();

cy.get('.slick-cell-checkboxsel input:checked')
.should('have.length', 11);
cy.get('.slick-cell-checkboxsel input:checked').should('have.length', 13);

cy.get('.slick-pager-status')
.contains('Showing page 6 of 6');
Expand All @@ -187,7 +186,7 @@ describe('Example - Checkbox Header Row', () => {
.should('not.be.checked');

cy.get('.slick-cell-checkboxsel input:checked')
.should('have.length', 10);
.should('have.length', 12);

cy.get('#selectedRows')
.should('contain', '2,4,6,8,10,12,14,16,18,20,22,24');
Expand All @@ -204,7 +203,7 @@ describe('Example - Checkbox Header Row', () => {
.should('not.be.checked');

cy.get('.slick-cell-checkboxsel input:checked')
.should('have.length', 11);
.should('have.length', 12);

cy.get('.slick-pager-status')
.contains('Showing page 1 of 6');
Expand All @@ -227,7 +226,7 @@ describe('Example - Checkbox Header Row', () => {
.should('be.checked');

cy.get('.slick-cell-checkboxsel input:checked')
.should('have.length', 11);
.should('have.length', 13);

cy.get('.slick-pager-status')
.contains('Showing page 6 of 6');
Expand Down
2 changes: 1 addition & 1 deletion examples/example-checkbox-header-row.html
Original file line number Diff line number Diff line change
Expand Up @@ -80,7 +80,7 @@
<body>
<div style="position:relative">
<div style="width:600px;">
<div id="myGrid" style="width:100%;height:500px;"></div>
<div id="myGrid" style="width:100%;height:700px;"></div>
<div id="pager" style="width:100%;height:20px;"></div>
</div>

Expand Down
2 changes: 1 addition & 1 deletion src/controls/slick.columnmenu.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,7 @@ export class SlickColumnMenu {
hideSyncResizeButton: false,
forceFitTitle: 'Force fit columns',
syncResizeTitle: 'Synchronous resize',
headerColumnValueExtractor: (columnDef: Column) => columnDef.name instanceof HTMLElement ? columnDef.name.innerHTML : columnDef.name || ''
headerColumnValueExtractor: (columnDef: Column) => Utils.getHtmlStringOutput(columnDef.name || '', 'innerHTML'),
};

constructor(protected columns: Column[], protected readonly grid: SlickGrid, options: GridOption) {
Expand Down
2 changes: 1 addition & 1 deletion src/controls/slick.columnpicker.ts
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,7 @@ export class SlickColumnPicker {
hideSyncResizeButton: false,
forceFitTitle: 'Force fit columns',
syncResizeTitle: 'Synchronous resize',
headerColumnValueExtractor: (columnDef: Column) => columnDef.name instanceof HTMLElement ? columnDef.name.innerHTML : columnDef.name || ''
headerColumnValueExtractor: (columnDef: Column) => Utils.getHtmlStringOutput(columnDef.name || '', 'innerHTML'),
};

constructor(protected columns: Column[], protected readonly grid: SlickGrid, gridOptions: GridOption) {
Expand Down
4 changes: 2 additions & 2 deletions src/controls/slick.gridmenu.ts
Original file line number Diff line number Diff line change
Expand Up @@ -167,7 +167,7 @@ export class SlickGridMenu {
subMenuOpenByEvent: 'mouseover',
syncResizeTitle: 'Synchronous resize',
useClickToRepositionMenu: true,
headerColumnValueExtractor: (columnDef: Column) => columnDef.name instanceof HTMLElement ? columnDef.name.innerHTML : columnDef.name || '',
headerColumnValueExtractor: (columnDef: Column) => Utils.getHtmlStringOutput(columnDef.name || '', 'innerHTML'),
};

constructor(protected columns: Column[], protected readonly grid: SlickGrid, gridOptions: GridOption) {
Expand Down Expand Up @@ -593,7 +593,7 @@ export class SlickGridMenu {

const labelElm = document.createElement('label');
labelElm.htmlFor = `${this._gridUid}-gridmenu-colpicker-${columnId}`;
this.grid.applyHtmlCode(labelElm, this.grid.sanitizeHtmlString((columnLabel instanceof HTMLElement ? columnLabel.innerHTML : columnLabel) || ''));
this.grid.applyHtmlCode(labelElm, this.grid.sanitizeHtmlString(Utils.getHtmlStringOutput(columnLabel || '')));
liElm.appendChild(labelElm);
this._listElm.appendChild(liElm);
}
Expand Down
2 changes: 1 addition & 1 deletion src/models/column.interface.ts
Original file line number Diff line number Diff line change
Expand Up @@ -144,7 +144,7 @@ export interface Column<TData = any> {
minWidth?: number;

/** Column Title Name to be displayed in the Grid (UI) */
name?: string | HTMLElement;
name?: string | HTMLElement | DocumentFragment;

/** column offset width */
offsetWidth?: number;
Expand Down
2 changes: 1 addition & 1 deletion src/models/columnPicker.interface.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@ export interface ColumnPickerOption {
syncResizeTitle?: string;

/** Callback method to override the column name output used by the ColumnPicker/GridMenu. */
headerColumnValueExtractor?: (column: Column, gridOptions?: GridOption) => string | HTMLElement;
headerColumnValueExtractor?: (column: Column, gridOptions?: GridOption) => string | HTMLElement | DocumentFragment;
}

export interface OnColumnsChangedArgs {
Expand Down
2 changes: 1 addition & 1 deletion src/models/excelCopyBufferOption.interface.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,7 @@ export interface ExcelCopyBufferOption<T = any> {
readOnlyMode?: boolean;

/** option to specify a custom column header value extractor function */
headerColumnValueExtractor?: (columnDef: Column<T>) => any;
headerColumnValueExtractor?: (columnDef: Column<T>) => string | HTMLElement | DocumentFragment;

// --
// Events
Expand Down
2 changes: 1 addition & 1 deletion src/models/gridMenuOption.interface.ts
Original file line number Diff line number Diff line change
Expand Up @@ -86,7 +86,7 @@ export interface GridMenuOption {
// action/override callbacks

/** Callback method to override the column name output used by the ColumnPicker/GridMenu. */
headerColumnValueExtractor?: (column: Column, gridOptions?: GridOption) => string | HTMLElement;
headerColumnValueExtractor?: (column: Column, gridOptions?: GridOption) => string | HTMLElement | DocumentFragment;

/** Callback method that user can override the default behavior of enabling/disabling an item from the list. */
menuUsabilityOverride?: (args: MenuCallbackArgs<any>) => boolean;
Expand Down
2 changes: 1 addition & 1 deletion src/plugins/slick.autotooltips.ts
Original file line number Diff line number Diff line change
Expand Up @@ -98,7 +98,7 @@ export class SlickAutoTooltips implements SlickPlugin {
node = targetElm.closest<HTMLDivElement>('.slick-header-column');
if (node && !(column?.toolTip)) {
const titleVal = (targetElm.clientWidth < node.clientWidth) ? column?.name ?? '' : '';
node.title = titleVal instanceof HTMLElement ? titleVal.innerHTML : titleVal;
node.title = Utils.getHtmlStringOutput(titleVal, 'innerHTML');
}
}
node = null;
Expand Down
8 changes: 4 additions & 4 deletions src/plugins/slick.cellexternalcopymanager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -91,15 +91,15 @@ export class SlickCellExternalCopyManager implements SlickPlugin {
this._grid.onKeyDown.unsubscribe(this.handleKeyDown.bind(this));
}

protected getHeaderValueForColumn(columnDef: Column) {
protected getHeaderValueForColumn(columnDef: Column): string {
if (this._options.headerColumnValueExtractor) {
const val = this._options.headerColumnValueExtractor(columnDef);
const val = Utils.getHtmlStringOutput(this._options.headerColumnValueExtractor(columnDef));
if (val) {
return val;
}
}

return columnDef.name;
return Utils.getHtmlStringOutput(columnDef.name || '');
}

protected getDataItemValueForColumn(item: any, columnDef: Column, event: SlickEventData): string {
Expand Down Expand Up @@ -393,7 +393,7 @@ export class SlickCellExternalCopyManager implements SlickPlugin {
? (columns[j].name as HTMLElement).innerHTML
: columns[j].name as string;
if (colName.length > 0 && !columns[j].hidden) {
clipTextHeaders.push(this.getHeaderValueForColumn(columns[j]));
clipTextHeaders.push(this.getHeaderValueForColumn(columns[j]) || '');
}
}
clipTextRows.push(clipTextHeaders.join('\t'));
Expand Down
62 changes: 35 additions & 27 deletions src/plugins/slick.checkboxselectcolumn.ts
Original file line number Diff line number Diff line change
Expand Up @@ -343,12 +343,31 @@ export class SlickCheckboxSelectColumn<T = any> implements SlickPlugin {
return this._checkboxColumnCellIndex;
}

/**
* use a DocumentFragment to return a fragment including an <input> then a <label> as siblings,
* the label is using `for` to link it to the input `id`
* @param {String} inputId - id to link the label
* @param {Boolean} [checked] - is the input checkbox checked? (defaults to false)
* @returns
*/
createCheckboxElement(inputId: string, checked = false) {
const fragmentElm = new DocumentFragment();
fragmentElm.appendChild(
Utils.createDomElement('input', { id: inputId, type: 'checkbox', checked, ariaChecked: String(checked) })
);
fragmentElm.appendChild(
Utils.createDomElement('label', { htmlFor: inputId })
);

return fragmentElm;
}

getColumnDefinition() {
return {
id: this._options.columnId,
name: (this._options.hideSelectAllCheckbox || this._options.hideInColumnTitleRow)
? this._options.name || ''
: `<input id="header-selector${this._selectAll_UID}" type="checkbox"><label for="header-selector${this._selectAll_UID}"></label>`,
: this.createCheckboxElement(`header-selector${this._selectAll_UID}`),
toolTip: (this._options.hideSelectAllCheckbox || this._options.hideInColumnTitleRow) ? '' : this._options.toolTip,
field: 'sel',
width: this._options.width,
Expand All @@ -368,18 +387,14 @@ export class SlickCheckboxSelectColumn<T = any> implements SlickPlugin {
this._handler.subscribe(grid.onHeaderRowCellRendered, (_e: any, args: any) => {
if (args.column.field === 'sel') {
Utils.emptyElement(args.node);
const spanElm = document.createElement('span');
spanElm.id = 'filter-checkbox-selectall-container';

const inputElm = document.createElement('input');
inputElm.type = 'checkbox';
inputElm.id = `header-filter-selector${this._selectAll_UID}`;

const labelElm = document.createElement('label');
labelElm.htmlFor = `header-filter-selector${this._selectAll_UID}`;
const spanElm = Utils.createDomElement('span', { id: 'filter-checkbox-selectall-container', ariaChecked: 'false' });
spanElm.appendChild(
Utils.createDomElement('input', { type: 'checkbox', id: `header-filter-selector${this._selectAll_UID}` })
);
spanElm.appendChild(
Utils.createDomElement('label', { htmlFor: `header-filter-selector${this._selectAll_UID}` })
);

spanElm.appendChild(inputElm);
spanElm.appendChild(labelElm);
args.node.appendChild(spanElm);
this._headerRowNode = args.node;

Expand All @@ -393,16 +408,9 @@ export class SlickCheckboxSelectColumn<T = any> implements SlickPlugin {
}

protected checkboxSelectionFormatter(row: number, _cell: number, _val: any, _columnDef: Column, dataContext: any, grid: SlickGrid) {
const UID = this.createUID() + row;

if (dataContext) {
if (!this.checkSelectableOverride(row, dataContext, grid)) {
return null;
} else {
return this._selectedRowsLookup[row]
? `<input id="selector${UID}" type="checkbox" checked="checked"><label for="selector${UID}"></label>`
: `<input id="selector${UID}" type="checkbox"><label for="selector${UID}"></label>`;
}
if (dataContext && this.checkSelectableOverride(row, dataContext, grid)) {
const UID = this.createUID() + row;
return this.createCheckboxElement(`selector${UID}`, !!this._selectedRowsLookup[row]);
}
return null;
}
Expand All @@ -415,11 +423,11 @@ export class SlickCheckboxSelectColumn<T = any> implements SlickPlugin {
}

protected renderSelectAllCheckbox(isSelectAllChecked?: boolean) {
if (isSelectAllChecked) {
this._grid.updateColumnHeader(this._options.columnId || '', `<input id="header-selector${this._selectAll_UID}" type="checkbox" checked="checked"><label for="header-selector${this._selectAll_UID}"></label>`, this._options.toolTip);
} else {
this._grid.updateColumnHeader(this._options.columnId || '', `<input id="header-selector${this._selectAll_UID}" type="checkbox"><label for="header-selector${this._selectAll_UID}"></label>`, this._options.toolTip);
}
this._grid.updateColumnHeader(
this._options.columnId || '',
this.createCheckboxElement(`header-selector${this._selectAll_UID}`, !!isSelectAllChecked),
this._options.toolTip
);
}

/**
Expand Down
18 changes: 18 additions & 0 deletions src/slick.core.ts
Original file line number Diff line number Diff line change
Expand Up @@ -810,6 +810,24 @@ export class Utils {
return elm;
}

/**
* From any input provided, return the HTML string (when a string is provided, it will be returned "as is" but when it's a number it will be converted to string)
* When detecting HTMLElement/DocumentFragment, we can also specify which HTML type to retrieve innerHTML or outerHTML.
* We can get the HTML by looping through all fragment `childNodes`
* @param {DocumentFragment | HTMLElement | string | number} input
* @param {'innerHTML' | 'outerHTML'} [type] - when the input is a DocumentFragment or HTMLElement, which type of HTML do you want to return? 'innerHTML' or 'outerHTML'
* @returns {String}
*/
public static getHtmlStringOutput(input: DocumentFragment | HTMLElement | string | number, type: 'innerHTML' | 'outerHTML' = 'innerHTML'): string {
if (input instanceof DocumentFragment) {
// a DocumentFragment doesn't have innerHTML/outerHTML, but we can loop through all children and concatenate them all to an HTML string
return [].map.call(input.childNodes, (x: HTMLElement) => x[type]).join('') || input.textContent || '';
} else if (input instanceof HTMLElement) {
return input[type];
}
return String(input) ?? ''; // reaching this line means it's already a string (or number) so just return it as string
}

public static emptyElement<T extends Element = Element>(element?: T | null): T | undefined | null {
while (element?.firstChild) {
element.removeChild(element.firstChild);
Expand Down
61 changes: 31 additions & 30 deletions src/slick.grid.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1360,42 +1360,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 (!Utils.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 (!Utils.isDefined(idx)) {
return;
}

this.trigger(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.trigger(this.onBeforeHeaderCellDestroy, {
node: header,
column: columnDef,
grid: this
});

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

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

Expand Down

0 comments on commit bef663c

Please sign in to comment.