From 448ec4f380fd6f25a641d4902f7205d935c41eed Mon Sep 17 00:00:00 2001 From: Ghislain B Date: Mon, 13 Nov 2023 21:05:26 -0500 Subject: [PATCH] feat: add grid option `enableHtmlRendering` to use pure HTML not string (#894) * feat: add grid option `enableHtmlRendering` to use pure HTML not string - prior to this PR, SlickGrid only used html string that are then passed to `innerHTML` but that is not CSP (Content Security Policy) friendly, what could be nice is to provide an HTMLElement directly as Formatter and other areas of the code. The `enableHtmlRendering` option is basically to disable the use of `innerHTML` within SlickGrid - this PR is NOT complete, at this point it only adds HTMLElement to Formatter but there are still some usage of `innerHTML` in the code --- cypress/e2e/example4-model-esm.cy.ts | 19 + .../e2e/example4-model-html-formatters.cy.ts | 138 ++++++ cypress/e2e/example4-model.cy.ts | 19 + examples/example-plugin-custom-tooltip.html | 10 +- examples/example4-model-html-formatters.html | 459 ++++++++++++++++++ examples/index.html | 1 + src/controls/slick.columnmenu.ts | 19 +- src/controls/slick.columnpicker.ts | 19 +- src/controls/slick.gridmenu.ts | 27 +- src/models/column.interface.ts | 7 +- src/models/excelCopyBufferOption.interface.ts | 6 +- src/models/formatter.interface.ts | 4 +- src/models/formatterResultObject.interface.ts | 11 +- src/models/gridOption.interface.ts | 7 + src/plugins/slick.autotooltips.ts | 3 +- src/plugins/slick.cellexternalcopymanager.ts | 10 +- src/plugins/slick.crossgridrowmovemanager.ts | 4 +- src/plugins/slick.customtooltip.ts | 15 +- src/plugins/slick.rowdetailview.ts | 4 +- src/plugins/slick.rowmovemanager.ts | 8 +- src/slick.grid.ts | 106 ++-- 21 files changed, 798 insertions(+), 98 deletions(-) create mode 100644 cypress/e2e/example4-model-html-formatters.cy.ts create mode 100644 examples/example4-model-html-formatters.html diff --git a/cypress/e2e/example4-model-esm.cy.ts b/cypress/e2e/example4-model-esm.cy.ts index f70a307fb..67146a489 100644 --- a/cypress/e2e/example4-model-esm.cy.ts +++ b/cypress/e2e/example4-model-esm.cy.ts @@ -1,4 +1,5 @@ describe('Example 4 - Model (ESM)', () => { + const GRID_ROW_HEIGHT = 25; const titles = ['#', 'Title', 'Duration', '% Complete', 'Start', 'Finish', 'Effort Driven']; beforeEach(() => { @@ -21,6 +22,15 @@ describe('Example 4 - Model (ESM)', () => { .each(($child, index) => expect($child.text()).to.eq(titles[index])); }); + it('should expect first row to include "Task 0" and other specific properties', () => { + cy.get(`[style="top: ${GRID_ROW_HEIGHT * 0}px;"] > .slick-cell:nth(1)`).should('contain', 'Task 0'); + cy.get(`[style="top: ${GRID_ROW_HEIGHT * 0}px;"] > .slick-cell:nth(2)`).should('contain', '5 days'); + cy.get(`[style="top: ${GRID_ROW_HEIGHT * 0}px;"] > .slick-cell:nth(3) .percent-complete-bar`).should('exist'); + cy.get(`[style="top: ${GRID_ROW_HEIGHT * 0}px;"] > .slick-cell:nth(4)`).should('contain', '01/01/2009'); + cy.get(`[style="top: ${GRID_ROW_HEIGHT * 0}px;"] > .slick-cell:nth(5)`).should('contain', '01/05/2009'); + cy.get(`[style="top: ${GRID_ROW_HEIGHT * 0}px;"] > .slick-cell:nth(6)`).find('.sgi.sgi-checkbox-intermediate').should('have.length', 1); + }); + it('should display the text "Showing all 50000 rows" without Pagination', () => { const expectedRows = ['Task 0', 'Task 1', 'Task 2', 'Task 3', 'Task 4']; @@ -116,4 +126,13 @@ describe('Example 4 - Model (ESM)', () => { expect(win.console.log).to.be.calledWith('on After Paging Info Changed - New Paging:: ', { pageSize: 50, pageNum: 999, totalRows: 50000, totalPages: 1000 }); }); }); + + it('should expect first row to include "Task 49950" and other specific properties', () => { + cy.get(`[style="top: ${GRID_ROW_HEIGHT * 0}px;"] > .slick-cell:nth(1)`).should('contain', 'Task 49950'); + cy.get(`[style="top: ${GRID_ROW_HEIGHT * 0}px;"] > .slick-cell:nth(2)`).should('contain', '5 days'); + cy.get(`[style="top: ${GRID_ROW_HEIGHT * 0}px;"] > .slick-cell:nth(3) .percent-complete-bar`).should('exist'); + cy.get(`[style="top: ${GRID_ROW_HEIGHT * 0}px;"] > .slick-cell:nth(4)`).should('contain', '01/01/2009'); + cy.get(`[style="top: ${GRID_ROW_HEIGHT * 0}px;"] > .slick-cell:nth(5)`).should('contain', '01/05/2009'); + cy.get(`[style="top: ${GRID_ROW_HEIGHT * 0}px;"] > .slick-cell:nth(6)`).find('.sgi.sgi-checkbox-intermediate').should('have.length', 1); + }); }); diff --git a/cypress/e2e/example4-model-html-formatters.cy.ts b/cypress/e2e/example4-model-html-formatters.cy.ts new file mode 100644 index 000000000..7788ca912 --- /dev/null +++ b/cypress/e2e/example4-model-html-formatters.cy.ts @@ -0,0 +1,138 @@ +describe('Example 4 - HTML Formatters', () => { + const GRID_ROW_HEIGHT = 25; + const titles = ['#', 'Title', 'Duration', '% Complete', 'Start', 'Finish', 'Effort Driven']; + + beforeEach(() => { + // create a console.log spy for later use + cy.window().then((win) => { + cy.spy(win.console, "log"); + }); + }); + + it('should display Example title', () => { + cy.visit(`${Cypress.config('baseUrl')}/examples/example4-model-html-formatters.html`); + cy.get('h2').contains('Demonstrates'); + cy.get('h2 + ul > li').first().contains('a filtered Model (DataView) as a data source instead of a simple array'); + }); + + it('should have exact Column Titles in the grid', () => { + cy.get('#myGrid') + .find('.slick-header-columns') + .children() + .each(($child, index) => expect($child.text()).to.eq(titles[index])); + }); + + it('should expect first row to include "Task 0" and other specific properties', () => { + cy.get(`[style="top: ${GRID_ROW_HEIGHT * 0}px;"] > .slick-cell:nth(1)`).should('contain', 'Task 0'); + cy.get(`[style="top: ${GRID_ROW_HEIGHT * 0}px;"] > .slick-cell:nth(2)`).should('contain', '5 days'); + cy.get(`[style="top: ${GRID_ROW_HEIGHT * 0}px;"] > .slick-cell:nth(3) .percent-complete-bar`).should('exist'); + cy.get(`[style="top: ${GRID_ROW_HEIGHT * 0}px;"] > .slick-cell:nth(4)`).should('contain', '01/01/2009'); + cy.get(`[style="top: ${GRID_ROW_HEIGHT * 0}px;"] > .slick-cell:nth(5)`).should('contain', '01/05/2009'); + cy.get(`[style="top: ${GRID_ROW_HEIGHT * 0}px;"] > .slick-cell:nth(6)`).find('.sgi.sgi-checkbox-intermediate').should('have.length', 1); + }); + + it('should display the text "Showing all 50000 rows" without Pagination', () => { + const expectedRows = ['Task 0', 'Task 1', 'Task 2', 'Task 3', 'Task 4']; + + cy.get('.slick-pager-status') + .contains('Showing all 50000 rows'); + + cy.get('#myGrid') + .find('.slick-row') + .each(($row, index) => { + if (index > expectedRows.length - 1) { + return; + } + cy.wrap($row).children('.slick-cell:nth(1)') + .first() + .should('contain', expectedRows[index]); + }); + }); + + it('Should display "Showing page 1 of 1000" text after changing Pagination to 50 items per page', () => { + cy.get('.sgi-lightbulb') + .click(); + + cy.get('.slick-pager-settings-expanded') + .should('be.visible'); + + cy.get('.slick-pager-settings-expanded') + .contains('50') + .click(); + + cy.get('.slick-pager-status') + .contains('Showing page 1 of 1000'); + + cy.window().then((win) => { + expect(win.console.log).to.have.callCount(2); + expect(win.console.log).to.be.calledWith('on Before Paging Info Changed - Previous Paging:: ', { pageSize: 0, pageNum: 0, totalRows: 50000, totalPages: 1 }); + expect(win.console.log).to.be.calledWith('on After Paging Info Changed - New Paging:: ', { pageSize: 50, pageNum: 0, totalRows: 50000, totalPages: 1000 }); + }); + }); + + it('Should display "Showing page 2 of 1000" text after clicking on next page', () => { + const expectedRows = ['Task 50', 'Task 51', 'Task 52', 'Task 53', 'Task 54']; + + cy.get('.sgi-chevron-start.sgi-state-disabled'); + cy.get('.sgi-chevron-left.sgi-state-disabled'); + + cy.get('.sgi-chevron-right') + .click(); + + cy.get('.slick-pager-status') + .contains('Showing page 2 of 1000'); + + cy.get('#myGrid') + .find('.slick-row') + .each(($row, index) => { + if (index > expectedRows.length - 1) { + return; + } + cy.wrap($row).children('.slick-cell:nth(1)') + .first() + .should('contain', expectedRows[index]); + }); + + cy.window().then((win) => { + expect(win.console.log).to.have.callCount(2); + expect(win.console.log).to.be.calledWith('on Before Paging Info Changed - Previous Paging:: ', { pageSize: 50, pageNum: 0, totalRows: 50000, totalPages: 1000 }); + expect(win.console.log).to.be.calledWith('on After Paging Info Changed - New Paging:: ', { pageSize: 50, pageNum: 1, totalRows: 50000, totalPages: 1000 }); + }); + }); + + it('Should display "Showing page 1000 of 1000" text after clicking on last page', () => { + const expectedRows = ['Task 49950', 'Task 49951', 'Task 49952', 'Task 49953', 'Task 49954']; + + cy.get('.sgi-chevron-end') + .click(); + + cy.get('.slick-pager-status') + .contains('Showing page 1000 of 1000'); + + cy.get('#myGrid') + .find('.slick-row') + .each(($row, index) => { + if (index > expectedRows.length - 1) { + return; + } + cy.wrap($row).children('.slick-cell:nth(1)') + .first() + .should('contain', expectedRows[index]); + }); + + cy.window().then((win) => { + expect(win.console.log).to.have.callCount(2); + expect(win.console.log).to.be.calledWith('on Before Paging Info Changed - Previous Paging:: ', { pageSize: 50, pageNum: 1, totalRows: 50000, totalPages: 1000 }); + expect(win.console.log).to.be.calledWith('on After Paging Info Changed - New Paging:: ', { pageSize: 50, pageNum: 999, totalRows: 50000, totalPages: 1000 }); + }); + }); + + it('should expect first row to include "Task 49950" and other specific properties', () => { + cy.get(`[style="top: ${GRID_ROW_HEIGHT * 0}px;"] > .slick-cell:nth(1)`).should('contain', 'Task 49950'); + cy.get(`[style="top: ${GRID_ROW_HEIGHT * 0}px;"] > .slick-cell:nth(2)`).should('contain', '5 days'); + cy.get(`[style="top: ${GRID_ROW_HEIGHT * 0}px;"] > .slick-cell:nth(3) .percent-complete-bar`).should('exist'); + cy.get(`[style="top: ${GRID_ROW_HEIGHT * 0}px;"] > .slick-cell:nth(4)`).should('contain', '01/01/2009'); + cy.get(`[style="top: ${GRID_ROW_HEIGHT * 0}px;"] > .slick-cell:nth(5)`).should('contain', '01/05/2009'); + cy.get(`[style="top: ${GRID_ROW_HEIGHT * 0}px;"] > .slick-cell:nth(6)`).find('.sgi.sgi-checkbox-intermediate').should('have.length', 1); + }); +}); diff --git a/cypress/e2e/example4-model.cy.ts b/cypress/e2e/example4-model.cy.ts index 3bf3b65aa..ebec1e4d3 100644 --- a/cypress/e2e/example4-model.cy.ts +++ b/cypress/e2e/example4-model.cy.ts @@ -1,4 +1,5 @@ describe('Example 4 - Model', () => { + const GRID_ROW_HEIGHT = 25; const titles = ['#', 'Title', 'Duration', '% Complete', 'Start', 'Finish', 'Effort Driven']; beforeEach(() => { @@ -21,6 +22,15 @@ describe('Example 4 - Model', () => { .each(($child, index) => expect($child.text()).to.eq(titles[index])); }); + it('should expect first row to include "Task 0" and other specific properties', () => { + cy.get(`[style="top: ${GRID_ROW_HEIGHT * 0}px;"] > .slick-cell:nth(1)`).should('contain', 'Task 0'); + cy.get(`[style="top: ${GRID_ROW_HEIGHT * 0}px;"] > .slick-cell:nth(2)`).should('contain', '5 days'); + cy.get(`[style="top: ${GRID_ROW_HEIGHT * 0}px;"] > .slick-cell:nth(3) .percent-complete-bar`).should('exist'); + cy.get(`[style="top: ${GRID_ROW_HEIGHT * 0}px;"] > .slick-cell:nth(4)`).should('contain', '01/01/2009'); + cy.get(`[style="top: ${GRID_ROW_HEIGHT * 0}px;"] > .slick-cell:nth(5)`).should('contain', '01/05/2009'); + cy.get(`[style="top: ${GRID_ROW_HEIGHT * 0}px;"] > .slick-cell:nth(6)`).find('.sgi.sgi-checkbox-intermediate').should('have.length', 1); + }); + it('should display the text "Showing all 50000 rows" without Pagination', () => { const expectedRows = ['Task 0', 'Task 1', 'Task 2', 'Task 3', 'Task 4']; @@ -116,4 +126,13 @@ describe('Example 4 - Model', () => { expect(win.console.log).to.be.calledWith('on After Paging Info Changed - New Paging:: ', { pageSize: 50, pageNum: 999, totalRows: 50000, totalPages: 1000 }); }); }); + + it('should expect first row to include "Task 49950" and other specific properties', () => { + cy.get(`[style="top: ${GRID_ROW_HEIGHT * 0}px;"] > .slick-cell:nth(1)`).should('contain', 'Task 49950'); + cy.get(`[style="top: ${GRID_ROW_HEIGHT * 0}px;"] > .slick-cell:nth(2)`).should('contain', '5 days'); + cy.get(`[style="top: ${GRID_ROW_HEIGHT * 0}px;"] > .slick-cell:nth(3) .percent-complete-bar`).should('exist'); + cy.get(`[style="top: ${GRID_ROW_HEIGHT * 0}px;"] > .slick-cell:nth(4)`).should('contain', '01/01/2009'); + cy.get(`[style="top: ${GRID_ROW_HEIGHT * 0}px;"] > .slick-cell:nth(5)`).should('contain', '01/05/2009'); + cy.get(`[style="top: ${GRID_ROW_HEIGHT * 0}px;"] > .slick-cell:nth(6)`).find('.sgi.sgi-checkbox-intermediate').should('have.length', 1); + }); }); diff --git a/examples/example-plugin-custom-tooltip.html b/examples/example-plugin-custom-tooltip.html index e235718fc..4cde4243b 100644 --- a/examples/example-plugin-custom-tooltip.html +++ b/examples/example-plugin-custom-tooltip.html @@ -300,7 +300,10 @@

View Source:

function tooltipFormatter(row, cell, value, column, dataContext) { const tooltipTitle = 'Custom Tooltip'; const effortDrivenHtml = Slick.Formatters.Checkmark(row, cell, dataContext.effortDriven, column, dataContext); - const completionBarHtml = Slick.Formatters.PercentCompleteBar(row, cell, dataContext.percentComplete, column, dataContext); + let completionBarHtml = Slick.Formatters.PercentCompleteBar(row, cell, dataContext.percentComplete, column, dataContext); + if (completionBarHtml instanceof HTMLElement) { + completionBarHtml = completionBarHtml.outerHTML; + } return '
' + tooltipTitle + '
' + '
Id:
' + dataContext.id + '
' + '
Title:
' + dataContext.title + '
' @@ -313,7 +316,10 @@

View Source:

// use a 2nd Formatter to get the percent completion // any properies provided from the `asyncPost` will end up in the `__params` property (unless a different prop name is provided via `asyncParamsPropName`) - const completionBar = Slick.Formatters.PercentCompleteBar(row, cell, dataContext.percentComplete, column, dataContext); + let completionBar = Slick.Formatters.PercentCompleteBar(row, cell, dataContext.percentComplete, column, dataContext); + if (completionBar instanceof HTMLElement) { + completionBar = completionBar.outerHTML; + } const out = '
' + tooltipTitle + '
' + '
Completion:
' + completionBar + '
' + '
Lifespan:
' + dataContext.__params.lifespan.toFixed(2) + '
' diff --git a/examples/example4-model-html-formatters.html b/examples/example4-model-html-formatters.html new file mode 100644 index 000000000..3b2d6af28 --- /dev/null +++ b/examples/example4-model-html-formatters.html @@ -0,0 +1,459 @@ + + + + + + SlickGrid example 4: Model + + + + + + + + + + + +
+
+
+ + +
+
+
+
+ +
+

+ + Search: +

+
+ + +
+ +
+
+ + +

+ + +
+ +

Demonstrates:

+
    +
  • a filtered Model (DataView) as a data source instead of a simple array
  • +
  • grid reacting to model events (onRowCountChanged, onRowsChanged)
  • +
  • + FAST DataView recalculation and real-time grid updating in response to data changes.
    + The grid holds 50'000 rows, yet you are able to sort, filter, scroll, navigate and edit as if it had 50 + rows. +
  • +
  • adding new rows, bidirectional sorting
  • +
  • column options: cannotTriggerInsert
  • +
  • events: onCellChange, onAddNewRow, onKeyDown, onSelectedRowsChanged, onSort
  • +
  • NOTE: all filters are immediately applied to new/edited rows
  • +
  • Handling row selection against model changes.
  • +
  • Paging.
  • +
  • inline filter panel
  • +
+

View Source:

+ +
+
+
+ + + + + + + + + + diff --git a/examples/index.html b/examples/index.html index 393a1a7b5..633f1c967 100644 --- a/examples/index.html +++ b/examples/index.html @@ -128,6 +128,7 @@

Other Features

  • Grid with Hidden Columns
  • Frozen Grid with Hidden Columns
  • CSP Header (Content Security Policy)
  • +
  • Filtered DataView with HTML Formatter - CSP Header (Content Security Policy)
  • diff --git a/src/controls/slick.columnmenu.ts b/src/controls/slick.columnmenu.ts index b8994595e..a478f7c15 100644 --- a/src/controls/slick.columnmenu.ts +++ b/src/controls/slick.columnmenu.ts @@ -54,7 +54,7 @@ export class SlickColumnMenu { hideSyncResizeButton: false, forceFitTitle: 'Force fit columns', syncResizeTitle: 'Synchronous resize', - headerColumnValueExtractor: (columnDef: Column) => columnDef.name || '' + headerColumnValueExtractor: (columnDef: Column) => columnDef.name instanceof HTMLElement ? columnDef.name.innerHTML : columnDef.name || '' }; constructor(protected columns: Column[], protected readonly grid: SlickGrid, options: GridOption) { @@ -130,11 +130,14 @@ export class SlickColumnMenu { let columnId, columnLabel, excludeCssClass; for (let i = 0; i < this.columns.length; i++) { columnId = this.columns[i].id; + const colName: string = this.columns[i].name instanceof HTMLElement + ? (this.columns[i].name as HTMLElement).innerHTML + : (this.columns[i].name || '') as string; excludeCssClass = this.columns[i].excludeFromColumnPicker ? "hidden" : ""; const liElm = document.createElement('li'); liElm.className = excludeCssClass; - liElm.ariaLabel = this.columns[i]?.name || ''; + liElm.ariaLabel = colName; const checkboxElm = document.createElement('input'); checkboxElm.type = 'checkbox'; @@ -148,15 +151,13 @@ export class SlickColumnMenu { checkboxElm.checked = true; } - if (this._options?.columnPicker?.headerColumnValueExtractor) { - columnLabel = this._options.columnPicker.headerColumnValueExtractor(this.columns[i], this._options); - } else { - columnLabel = this._defaults.headerColumnValueExtractor!(this.columns[i], this._options); - } + columnLabel = (this._options?.columnPicker?.headerColumnValueExtractor) + ? this._options.columnPicker.headerColumnValueExtractor(this.columns[i], this._options) + : this._defaults.headerColumnValueExtractor!(this.columns[i], this._options); const labelElm = document.createElement('label'); labelElm.htmlFor = `${this._gridUid}colpicker-${columnId}`; - labelElm.innerHTML = columnLabel; + labelElm.innerHTML = this.grid.sanitizeHtmlString(columnLabel); liElm.appendChild(labelElm); this._listElm.appendChild(liElm); } @@ -249,7 +250,7 @@ export class SlickColumnMenu { /** Update the Titles of each sections (command, customTitle, ...) */ updateAllTitles(pickerOptions: { columnTitle: string; }) { if (this._columnTitleElm?.innerHTML) { - this._columnTitleElm.innerHTML = pickerOptions.columnTitle; + this._columnTitleElm.innerHTML = this.grid.sanitizeHtmlString(pickerOptions.columnTitle); } } diff --git a/src/controls/slick.columnpicker.ts b/src/controls/slick.columnpicker.ts index fc9627dfb..1322e336a 100644 --- a/src/controls/slick.columnpicker.ts +++ b/src/controls/slick.columnpicker.ts @@ -55,7 +55,7 @@ export class SlickColumnPicker { hideSyncResizeButton: false, forceFitTitle: 'Force fit columns', syncResizeTitle: 'Synchronous resize', - headerColumnValueExtractor: (columnDef: Column) => columnDef.name || '' + headerColumnValueExtractor: (columnDef: Column) => columnDef.name instanceof HTMLElement ? columnDef.name.innerHTML : columnDef.name || '' }; constructor(protected columns: Column[], protected readonly grid: SlickGrid, gridOptions: GridOption) { @@ -131,11 +131,14 @@ export class SlickColumnPicker { let columnId, columnLabel, excludeCssClass; for (let i = 0; i < this.columns.length; i++) { columnId = this.columns[i].id; + const colName: string = this.columns[i].name instanceof HTMLElement + ? (this.columns[i].name as HTMLElement).innerHTML + : (this.columns[i].name || '') as string; excludeCssClass = this.columns[i].excludeFromColumnPicker ? 'hidden' : ''; const liElm = document.createElement('li'); liElm.className = excludeCssClass; - liElm.ariaLabel = this.columns[i]?.name || ''; + liElm.ariaLabel = colName; const checkboxElm = document.createElement('input'); checkboxElm.type = 'checkbox'; @@ -149,15 +152,13 @@ export class SlickColumnPicker { checkboxElm.checked = true; } - if (this._gridOptions?.columnPicker?.headerColumnValueExtractor) { - columnLabel = this._gridOptions.columnPicker.headerColumnValueExtractor(this.columns[i], this._gridOptions); - } else { - columnLabel = this._defaults.headerColumnValueExtractor!(this.columns[i], this._gridOptions); - } + columnLabel = (this._gridOptions?.columnPicker?.headerColumnValueExtractor) + ? this._gridOptions.columnPicker.headerColumnValueExtractor(this.columns[i], this._gridOptions) + : this._defaults.headerColumnValueExtractor!(this.columns[i], this._gridOptions); const labelElm = document.createElement('label'); labelElm.htmlFor = `${this._gridUid}colpicker-${columnId}`; - labelElm.innerHTML = columnLabel; + labelElm.innerHTML = this.grid.sanitizeHtmlString(columnLabel); liElm.appendChild(labelElm); this._listElm.appendChild(liElm); } @@ -250,7 +251,7 @@ export class SlickColumnPicker { /** Update the Titles of each sections (command, customTitle, ...) */ updateAllTitles(pickerOptions: { columnTitle: string; }) { if (this._columnTitleElm?.innerHTML) { - this._columnTitleElm.innerHTML = pickerOptions.columnTitle; + this._columnTitleElm.innerHTML = this.grid.sanitizeHtmlString(pickerOptions.columnTitle); } } diff --git a/src/controls/slick.gridmenu.ts b/src/controls/slick.gridmenu.ts index 741f42601..3521fde4a 100644 --- a/src/controls/slick.gridmenu.ts +++ b/src/controls/slick.gridmenu.ts @@ -167,7 +167,7 @@ export class SlickGridMenu { subMenuOpenByEvent: 'mouseover', syncResizeTitle: 'Synchronous resize', useClickToRepositionMenu: true, - headerColumnValueExtractor: (columnDef: Column) => columnDef.name as string, + headerColumnValueExtractor: (columnDef: Column) => columnDef.name instanceof HTMLElement ? columnDef.name.innerHTML : columnDef.name || '', }; constructor(protected columns: Column[], protected readonly grid: SlickGrid, gridOptions: GridOption) { @@ -393,7 +393,7 @@ export class SlickGridMenu { if (!isSubMenu && (this._gridMenuOptions?.commandTitle || this._gridMenuOptions?.customTitle)) { this._commandTitleElm = document.createElement('div'); this._commandTitleElm.className = 'title'; - this._commandTitleElm.innerHTML = (this._gridMenuOptions.commandTitle || this._gridMenuOptions.customTitle) as string; + this._commandTitleElm.innerHTML = this.grid.sanitizeHtmlString((this._gridMenuOptions.commandTitle || this._gridMenuOptions.customTitle) as string); commandListElm.appendChild(this._commandTitleElm); } @@ -461,7 +461,7 @@ export class SlickGridMenu { const textElm = document.createElement('span'); textElm.className = 'slick-gridmenu-content'; - textElm.innerHTML = (item as GridMenuItem).title || ''; + textElm.innerHTML = this.grid.sanitizeHtmlString((item as GridMenuItem).title || ''); liElm.appendChild(textElm); @@ -512,7 +512,7 @@ export class SlickGridMenu { if (this._gridMenuOptions?.columnTitle) { this._columnTitleElm = document.createElement('div'); this._columnTitleElm.className = 'title'; - this._columnTitleElm.innerHTML = this._gridMenuOptions.columnTitle; + this._columnTitleElm.innerHTML = this.grid.sanitizeHtmlString(this._gridMenuOptions.columnTitle); this._menuElm.appendChild(this._columnTitleElm); } @@ -565,10 +565,13 @@ export class SlickGridMenu { for (let i = 0; i < this.columns.length; i++) { columnId = this.columns[i].id; excludeCssClass = this.columns[i].excludeFromGridMenu ? 'hidden' : ''; + const colName: string = this.columns[i].name instanceof HTMLElement + ? (this.columns[i].name as HTMLElement).innerHTML + : (this.columns[i].name || '') as string; const liElm = document.createElement('li'); liElm.className = excludeCssClass; - liElm.ariaLabel = this.columns[i]?.name || ''; + liElm.ariaLabel = colName; const checkboxElm = document.createElement('input'); checkboxElm.type = 'checkbox'; @@ -583,15 +586,13 @@ export class SlickGridMenu { this._columnCheckboxes.push(checkboxElm); // get the column label from the picker value extractor (user can optionally provide a custom extractor) - if (this._gridMenuOptions?.headerColumnValueExtractor) { - columnLabel = this._gridMenuOptions.headerColumnValueExtractor(this.columns[i], this._gridOptions); - } else { - columnLabel = this._defaults.headerColumnValueExtractor!(this.columns[i]); - } + columnLabel = (this._gridMenuOptions?.headerColumnValueExtractor) + ? this._gridMenuOptions.headerColumnValueExtractor(this.columns[i], this._gridOptions) + : this._defaults.headerColumnValueExtractor!(this.columns[i]); const labelElm = document.createElement('label'); labelElm.htmlFor = `${this._gridUid}-gridmenu-colpicker-${columnId}`; - labelElm.innerHTML = columnLabel || ''; + labelElm.innerHTML = this.grid.sanitizeHtmlString(columnLabel || ''); liElm.appendChild(labelElm); this._listElm.appendChild(liElm); } @@ -760,10 +761,10 @@ export class SlickGridMenu { /** Update the Titles of each sections (command, commandTitle, ...) */ updateAllTitles(gridMenuOptions: GridMenuOption) { if (this._commandTitleElm?.innerHTML) { - this._commandTitleElm.innerHTML = gridMenuOptions.commandTitle || gridMenuOptions.customTitle || ''; + this._commandTitleElm.innerHTML = this.grid.sanitizeHtmlString(gridMenuOptions.commandTitle || gridMenuOptions.customTitle || ''); } if (this._columnTitleElm?.innerHTML) { - this._columnTitleElm.innerHTML = gridMenuOptions.columnTitle || ''; + this._columnTitleElm.innerHTML = this.grid.sanitizeHtmlString(gridMenuOptions.columnTitle || ''); } } diff --git a/src/models/column.interface.ts b/src/models/column.interface.ts index c1c2f025f..ac5da6f05 100644 --- a/src/models/column.interface.ts +++ b/src/models/column.interface.ts @@ -5,7 +5,8 @@ import type { Editor, EditorValidator, Formatter, - FormatterResultObject, + FormatterResultWithHtml, + FormatterResultWithText, GroupTotalsFormatter, Grouping, HeaderButtonsOrMenu @@ -25,7 +26,7 @@ type Join = F extends string ? string extends F ? string : `${F}${D}${Join}` : never : string; /* eslint-enable @typescript-eslint/indent */ -export type FormatterOverrideCallback = (row: number, cell: number, val: any, columnDef: Column, item: any, grid: SlickGrid) => string | FormatterResultObject; +export type FormatterOverrideCallback = (row: number, cell: number, val: any, columnDef: Column, item: any, grid: SlickGrid) => string | FormatterResultWithHtml | FormatterResultWithText; export interface Column { /** Defaults to false, should we always render the column? */ @@ -143,7 +144,7 @@ export interface Column { minWidth?: number; /** Column Title Name to be displayed in the Grid (UI) */ - name?: string; + name?: string | HTMLElement; /** column offset width */ offsetWidth?: number; diff --git a/src/models/excelCopyBufferOption.interface.ts b/src/models/excelCopyBufferOption.interface.ts index 3fedf1e43..28078bd41 100644 --- a/src/models/excelCopyBufferOption.interface.ts +++ b/src/models/excelCopyBufferOption.interface.ts @@ -1,4 +1,4 @@ -import type { CellRange, Column, FormatterResultObject, } from './index'; +import type { CellRange, Column, FormatterResultWithHtml, FormatterResultWithText } from './index'; import type { SlickEventData } from '../slick.core'; export interface ExcelCopyBufferOption { @@ -15,10 +15,10 @@ export interface ExcelCopyBufferOption { copiedCellStyleLayerKey?: string; /** option to specify a custom column value extractor function */ - dataItemColumnValueExtractor?: (item: any, columnDef: Column) => string | FormatterResultObject | null; + dataItemColumnValueExtractor?: (item: any, columnDef: Column) => string | FormatterResultWithHtml | FormatterResultWithText | null; /** option to specify a custom column value setter function */ - dataItemColumnValueSetter?: (item: any, columnDef: Column, value: any) => string | FormatterResultObject | null; + dataItemColumnValueSetter?: (item: any, columnDef: Column, value: any) => string | FormatterResultWithHtml | FormatterResultWithText | null; /** option to specify a custom handler for paste actions */ clipboardCommandHandler?: (editCommand: any) => void; diff --git a/src/models/formatter.interface.ts b/src/models/formatter.interface.ts index 29c5ff6e7..ed39cd410 100644 --- a/src/models/formatter.interface.ts +++ b/src/models/formatter.interface.ts @@ -1,4 +1,4 @@ -import type { Column, FormatterResultObject } from './index'; +import type { Column, FormatterResultWithHtml, FormatterResultWithText } from './index'; import type { SlickGrid } from '../slick.grid'; -export declare type Formatter = (row: number, cell: number, value: any, columnDef: Column, dataContext: T, grid: SlickGrid) => string | FormatterResultObject; +export declare type Formatter = (row: number, cell: number, value: any, columnDef: Column, dataContext: T, grid: SlickGrid) => string | HTMLElement | FormatterResultWithHtml | FormatterResultWithText; diff --git a/src/models/formatterResultObject.interface.ts b/src/models/formatterResultObject.interface.ts index a1ae2e763..d463a9b43 100644 --- a/src/models/formatterResultObject.interface.ts +++ b/src/models/formatterResultObject.interface.ts @@ -5,9 +5,16 @@ export interface FormatterResultObject { /** Optional CSS classes to remove from the cell div container. */ removeClasses?: string; + /** Optional tooltip text when hovering the cell div container. */ + toolTip?: string; +} + +export interface FormatterResultWithText extends FormatterResultObject { /** Text to be displayed in the cell, basically the formatter output. */ text: string; +} - /** Optional tooltip text when hovering the cell div container. */ - toolTip?: string; +export interface FormatterResultWithHtml extends FormatterResultObject { + /** Text to be displayed in the cell, basically the formatter output. */ + html: HTMLElement; } diff --git a/src/models/gridOption.interface.ts b/src/models/gridOption.interface.ts index 7c2ff6416..998c9c6ab 100644 --- a/src/models/gridOption.interface.ts +++ b/src/models/gridOption.interface.ts @@ -154,6 +154,13 @@ export interface GridOption { */ enableColumnReorder?: boolean | ColumnReorderFunction; + /** + * Defaults to true, do we want to allow passing HTML string to cell/row rendering by using `innerHTML`. + * When this is enabled and input is a string, it will use `innerHTML = 'some html'` to render the input, however when disable it will use `textContent = 'some html'`. + * Note: for strict CSP, you would want to disable this option and convert all your custom Formatters to return an HTMLElement instead of a string + */ + enableHtmlRendering?: boolean; + /** * Do we want to always enable the mousewheel scroll handler? * In other words, do we want the mouse scrolling would work from anywhere. diff --git a/src/plugins/slick.autotooltips.ts b/src/plugins/slick.autotooltips.ts index 7b16946ad..632e38192 100644 --- a/src/plugins/slick.autotooltips.ts +++ b/src/plugins/slick.autotooltips.ts @@ -97,7 +97,8 @@ export class SlickAutoTooltips implements SlickPlugin { if (targetElm) { node = targetElm.closest('.slick-header-column'); if (node && !(column?.toolTip)) { - node.title = (targetElm.clientWidth < node.clientWidth) ? column?.name ?? '' : ''; + const titleVal = (targetElm.clientWidth < node.clientWidth) ? column?.name ?? '' : ''; + node.title = titleVal instanceof HTMLElement ? titleVal.innerHTML : titleVal; } } node = null; diff --git a/src/plugins/slick.cellexternalcopymanager.ts b/src/plugins/slick.cellexternalcopymanager.ts index 83cf81c09..67d985940 100644 --- a/src/plugins/slick.cellexternalcopymanager.ts +++ b/src/plugins/slick.cellexternalcopymanager.ts @@ -388,7 +388,10 @@ export class SlickCellExternalCopyManager implements SlickPlugin { if (clipTextRows.length === 0 && this._options.includeHeaderWhenCopying) { const clipTextHeaders: string[] = []; for (let j = range.fromCell; j < range.toCell + 1; j++) { - if (columns[j].name!.length > 0 && !columns[j].hidden) { + const colName: string = columns[j].name instanceof HTMLElement + ? (columns[j].name as HTMLElement).innerHTML + : columns[j].name as string; + if (colName.length > 0 && !columns[j].hidden) { clipTextHeaders.push(this.getHeaderValueForColumn(columns[j])); } } @@ -396,7 +399,10 @@ export class SlickCellExternalCopyManager implements SlickPlugin { } for (let j = range.fromCell; j < range.toCell + 1; j++) { - if (columns[j].name!.length > 0 && !columns[j].hidden) { + const colName: string = columns[j].name instanceof HTMLElement + ? (columns[j].name as HTMLElement).innerHTML + : columns[j].name as string; + if (colName.length > 0 && !columns[j].hidden) { clipTextCells.push(this.getDataItemValueForColumn(dt, columns[j], e)); } } diff --git a/src/plugins/slick.crossgridrowmovemanager.ts b/src/plugins/slick.crossgridrowmovemanager.ts index c9ec11266..1ab91b905 100644 --- a/src/plugins/slick.crossgridrowmovemanager.ts +++ b/src/plugins/slick.crossgridrowmovemanager.ts @@ -1,5 +1,5 @@ import { SlickEvent as SlickEvent_, SlickEventData as SlickEventData_, SlickEventHandler as SlickEventHandler_, Utils as Utils_ } from '../slick.core'; -import type { Column, DOMEvent, DragRowMove, FormatterResultObject, CrossGridRowMoveManagerOption, UsabilityOverrideFn } from '../models/index'; +import type { Column, DOMEvent, DragRowMove, CrossGridRowMoveManagerOption, FormatterResultWithText, UsabilityOverrideFn } from '../models/index'; import type { SlickGrid } from '../slick.grid'; // for (iife) load Slick methods from global Slick object, or use imports for (esm) @@ -256,7 +256,7 @@ export class SlickCrossGridRowMoveManager { }; } - protected moveIconFormatter(row: number, _cell: number, _val: any, _column: Column, dataContext: any, grid: SlickGrid): FormatterResultObject | string { + protected moveIconFormatter(row: number, _cell: number, _val: any, _column: Column, dataContext: any, grid: SlickGrid): FormatterResultWithText | string { if (!this.checkUsabilityOverride(row, dataContext, grid)) { return ''; } else { diff --git a/src/plugins/slick.customtooltip.ts b/src/plugins/slick.customtooltip.ts index 3e83646c5..b5cd4a332 100644 --- a/src/plugins/slick.customtooltip.ts +++ b/src/plugins/slick.customtooltip.ts @@ -1,4 +1,4 @@ -import type { CancellablePromiseWrapper, Column, CustomTooltipOption, DOMEvent, Formatter, GridOption } from '../models/index'; +import type { CancellablePromiseWrapper, Column, CustomTooltipOption, DOMEvent, Formatter, FormatterResultWithHtml, FormatterResultWithText, GridOption } from '../models/index'; import { SlickEventHandler as SlickEventHandler_, Utils as Utils_ } from '../slick.core'; import type { SlickDataView } from '../slick.dataview'; import type { SlickGrid } from '../slick.grid'; @@ -78,7 +78,7 @@ type CellType = 'slick-cell' | 'slick-header-column' | 'slick-headerrow-column'; * @param {boolean} [options.className="slick-custom-tooltip"] - custom tooltip class name * @param {boolean} [options.offsetTop=5] - tooltip offset from the top */ -export class CustomTooltip { +export class SlickCustomTooltip { // -- // public API pluginName = 'CustomTooltip' as const; @@ -429,9 +429,12 @@ export class CustomTooltip { */ protected parseFormatterAndSanitize(formatterOrText: Formatter | string | undefined, cell: { row: number; cell: number; }, value: any, columnDef: Column, item: unknown): string { if (typeof formatterOrText === 'function') { - const tooltipText = formatterOrText(cell.row, cell.cell, value, columnDef, item, this._grid); - const formatterText = (typeof tooltipText === 'object' && tooltipText?.text) ? tooltipText.text : (typeof tooltipText === 'string' ? tooltipText : ''); - return this._grid.sanitizeHtmlString(formatterText); + const tooltipResult = formatterOrText(cell.row, cell.cell, value, columnDef, item, this._grid); + let formatterText = (Object.prototype.toString.call(tooltipResult) !== '[object Object]' ? tooltipResult : (tooltipResult as FormatterResultWithHtml).html || (tooltipResult as FormatterResultWithText).text); + if (formatterText instanceof HTMLElement) { + formatterText = formatterText.outerHTML; + } + return this._grid.sanitizeHtmlString(formatterText as string); } else if (typeof formatterOrText === 'string') { return this._grid.sanitizeHtmlString(formatterOrText); } @@ -508,7 +511,7 @@ if (IIFE_ONLY && window.Slick) { Utils.extend(true, window, { Slick: { Plugins: { - CustomTooltip + CustomTooltip: SlickCustomTooltip } } }); diff --git a/src/plugins/slick.rowdetailview.ts b/src/plugins/slick.rowdetailview.ts index ced0f4d6a..e867f2551 100644 --- a/src/plugins/slick.rowdetailview.ts +++ b/src/plugins/slick.rowdetailview.ts @@ -1,5 +1,5 @@ import { SlickEvent as SlickEvent_, SlickEventHandler as SlickEventHandler_, Utils as Utils_ } from '../slick.core'; -import type { Column, DOMEvent, FormatterResultObject, GridOption, OnAfterRowDetailToggleArgs, OnBeforeRowDetailToggleArgs, OnRowBackToViewportRangeArgs, OnRowDetailAsyncEndUpdateArgs, OnRowDetailAsyncResponseArgs, OnRowOutOfViewportRangeArgs, RowDetailViewOption, UsabilityOverrideFn } from '../models/index'; +import type { Column, DOMEvent, FormatterResultWithHtml, FormatterResultWithText, GridOption, OnAfterRowDetailToggleArgs, OnBeforeRowDetailToggleArgs, OnRowBackToViewportRangeArgs, OnRowDetailAsyncEndUpdateArgs, OnRowDetailAsyncResponseArgs, OnRowOutOfViewportRangeArgs, RowDetailViewOption, UsabilityOverrideFn } from '../models/index'; import type { SlickDataView } from '../slick.dataview'; import type { SlickGrid } from '../slick.grid'; @@ -630,7 +630,7 @@ export class SlickRowDetailView { } /** The cell Formatter that shows the icon that will be used to toggle the Row Detail */ - protected detailSelectionFormatter(row: number, _cell: number, _val: any, _column: Column, dataContext: any, grid: SlickGrid): FormatterResultObject | string { + protected detailSelectionFormatter(row: number, _cell: number, _val: any, _column: Column, dataContext: any, grid: SlickGrid): FormatterResultWithHtml | FormatterResultWithText | string { if (!this.checkExpandableOverride(row, dataContext, grid)) { return ''; } else { diff --git a/src/plugins/slick.rowmovemanager.ts b/src/plugins/slick.rowmovemanager.ts index dc08769eb..5365a3983 100644 --- a/src/plugins/slick.rowmovemanager.ts +++ b/src/plugins/slick.rowmovemanager.ts @@ -1,5 +1,5 @@ import { SlickEvent as SlickEvent_, SlickEventData as SlickEventData_, SlickEventHandler as SlickEventHandler_, Utils as Utils_ } from '../slick.core'; -import type { Column, DOMEvent, DragRowMove, FormatterResultObject, RowMoveManagerOption, UsabilityOverrideFn } from '../models/index'; +import type { Column, DOMEvent, DragRowMove, FormatterResultWithHtml, RowMoveManagerOption, UsabilityOverrideFn } from '../models/index'; import type { SlickGrid } from '../slick.grid'; // for (iife) load Slick methods from global Slick object, or use imports for (esm) @@ -247,13 +247,15 @@ export class SlickRowMoveManager { }; } - protected moveIconFormatter(row: number, _cell: number, _val: any, _column: Column, dataContext: any, grid: SlickGrid): FormatterResultObject | string { + protected moveIconFormatter(row: number, _cell: number, _val: any, _column: Column, dataContext: any, grid: SlickGrid): FormatterResultWithHtml | string { if (!this.checkUsabilityOverride(row, dataContext, grid)) { return ''; } else { + const iconElm = document.createElement('div'); + iconElm.className = this._options.cssClass || ''; return { addClasses: `cell-reorder dnd ${this._options.containerCssClass || ''}`, - text: `
    ` + html: iconElm }; } } diff --git a/src/slick.grid.ts b/src/slick.grid.ts index 1cd91f6e7..5e886374a 100644 --- a/src/slick.grid.ts +++ b/src/slick.grid.ts @@ -18,6 +18,8 @@ import type { Formatter, FormatterOverrideCallback, FormatterResultObject, + FormatterResultWithHtml, + FormatterResultWithText, GridOption as BaseGridOption, InteractionBase, ItemMetadata, @@ -209,6 +211,7 @@ export class SlickGrid = Column, O e explicitInitialization: false, rowHeight: 25, defaultColumnWidth: 80, + enableHtmlRendering: true, enableAddRow: false, leaveSpaceForNewRows: false, editable: false, @@ -514,6 +517,26 @@ export class SlickGrid = Column, O e this.finishInitialization(); } + /** + * 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. + * 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 + */ + applyHtmlCode(target: HTMLElement, val: string | HTMLElement) { + if (val instanceof HTMLElement) { + target.appendChild(val); + } else { + if (this._options.enableHtmlRendering) { + target.innerHTML = this.sanitizeHtmlString(val as string); + } else { + target.textContent = this.sanitizeHtmlString(val as string); + } + } + } + protected initialize() { if (typeof this.container === 'string') { this._container = document.querySelector(this.container) as HTMLDivElement; @@ -1288,7 +1311,7 @@ export class SlickGrid = Column, O e * @param {String} [title] New column name. * @param {String} [toolTip] New column tooltip. */ - updateColumnHeader(columnId: number | string, title?: string, toolTip?: string) { + updateColumnHeader(columnId: number | string, title?: string | HTMLElement, toolTip?: string) { if (!this.initialized) { return; } const idx = this.getColumnIndex(columnId); if (!Utils.isDefined(idx)) { @@ -1313,7 +1336,7 @@ export class SlickGrid = Column, O e header.setAttribute('title', toolTip || ''); if (title !== undefined) { - header.children[0].innerHTML = this.sanitizeHtmlString(title); + this.applyHtmlCode(header.children[0], title); } this.trigger(this.onHeaderCellRendered, { @@ -1541,7 +1564,7 @@ export class SlickGrid = Column, O e const header = Utils.createDomElement('div', { id: `${this.uid + m.id}`, dataset: { id: String(m.id) }, className: 'ui-state-default slick-state-default slick-header-column', title: m.toolTip || '' }, headerTarget); const colNameElm = Utils.createDomElement('span', { className: 'slick-column-name' }, header); - colNameElm.innerHTML = this.sanitizeHtmlString(m.name as string); + this.applyHtmlCode(colNameElm, m.name as string); Utils.width(header, m.width! - this.headerColumnWidthDiff); @@ -2969,7 +2992,7 @@ export class SlickGrid = Column, O e let len: number; let max = 0; let maxText = ''; - let formatterResult: string | FormatterResultObject; + let formatterResult: string | FormatterResultWithHtml | FormatterResultWithText | HTMLElement; let val: any; // get mode - if text only display, use canvas otherwise html element @@ -3053,7 +3076,7 @@ export class SlickGrid = Column, O e const header = this.getHeader(columnDef) as HTMLElement; headerColEl = Utils.createDomElement('div', { id: dummyHeaderColElId, className: 'ui-state-default slick-state-default slick-header-column' }, header); const colNameElm = Utils.createDomElement('span', { className: 'slick-column-name' }, headerColEl); - colNameElm.innerHTML = this.sanitizeHtmlString(String(columnDef.name)); + this.applyHtmlCode(colNameElm, columnDef.name!); clone.style.cssText = 'position: absolute; visibility: hidden;right: auto;text-overflow: initial;white-space: nowrap;'; if (columnDef.headerCssClass) { headerColEl.classList.add(...(columnDef.headerCssClass || '').split(' ')); @@ -3808,17 +3831,16 @@ export class SlickGrid = Column, O e const rowDiv = document.createElement('div'); let rowDivR: HTMLElement | undefined; - - rowDiv.className = 'ui-widget-content ' + rowCss; - rowDiv.style.top = `${(this.getRowTop(row) - frozenRowOffset)}px`; - divArrayL.push(rowDiv); - if (this.hasFrozenColumns()) { - //it has to be a deep copy otherwise we will have issues with pass by reference in js since - //attempting to add the same element to 2 different arrays will just move 1 item to the other array - rowDivR = rowDiv.cloneNode(true) as HTMLElement; - divArrayR.push(rowDivR); - } + rowDiv.className = 'ui-widget-content ' + rowCss; + rowDiv.style.top = `${(this.getRowTop(row) - frozenRowOffset)}px`; + divArrayL.push(rowDiv); + if (this.hasFrozenColumns()) { + //it has to be a deep copy otherwise we will have issues with pass by reference in js since + //attempting to add the same element to 2 different arrays will just move 1 item to the other array + rowDivR = rowDiv.cloneNode(true) as HTMLElement; + divArrayR.push(rowDivR); + } let colspan: number | string; let m: C; @@ -3883,7 +3905,7 @@ export class SlickGrid = Column, O e } let value: any = null; - let formatterResult: FormatterResultObject | string = ''; + let formatterResult: FormatterResultWithHtml | FormatterResultWithText | HTMLElement | string = ''; if (item) { value = this.getDataItemValueForColumn(item, m); formatterResult = this.getFormatter(row, m)(row, cell, value, m, item, this as unknown as SlickGridModel); @@ -3901,27 +3923,29 @@ export class SlickGrid = Column, O e addlCssClasses += (addlCssClasses ? ' ' : '') + (formatterResult as FormatterResultObject).addClasses; } - const toolTipText = (formatterResult as FormatterResultObject)?.toolTip ? `${(formatterResult as FormatterResultObject).toolTip}` : ''; - const cellDiv = document.createElement('div'); - cellDiv.className = cellCss + (addlCssClasses ? ' ' + addlCssClasses : ''); - cellDiv.setAttribute('title', toolTipText); - if (m.hasOwnProperty('cellAttrs') && m.cellAttrs instanceof Object) { - for (const key in m.cellAttrs) { - if (m.cellAttrs.hasOwnProperty(key)) { - cellDiv.setAttribute(key, m.cellAttrs[key]); - } + const toolTipText = (formatterResult as FormatterResultObject)?.toolTip ? `${(formatterResult as FormatterResultObject).toolTip}` : ''; + const cellDiv = document.createElement('div'); + cellDiv.className = cellCss + (addlCssClasses ? ' ' + addlCssClasses : ''); + cellDiv.setAttribute('title', toolTipText); + if (m.hasOwnProperty('cellAttrs') && m.cellAttrs instanceof Object) { + for (const key in m.cellAttrs) { + if (m.cellAttrs.hasOwnProperty(key)) { + cellDiv.setAttribute(key, m.cellAttrs[key]); } } + } - // if there is a corresponding row (if not, this is the Add New row or this data hasn't been loaded yet) - if (item) { - const obj = (Object.prototype.toString.call(formatterResult) !== '[object Object]' ? formatterResult : (formatterResult as FormatterResultObject).text) as string; - cellDiv.innerHTML = this.sanitizeHtmlString(obj); + // if there is a corresponding row (if not, this is the Add New row or this data hasn't been loaded yet) + if (item) { + const cellResult = (Object.prototype.toString.call(formatterResult) !== '[object Object]' ? formatterResult : (formatterResult as FormatterResultWithHtml).html || (formatterResult as FormatterResultWithText).text); + if (cellResult instanceof HTMLElement) { + cellDiv.appendChild(cellResult); + } else { + cellDiv.innerHTML = this.sanitizeHtmlString(cellResult as string); } + } - divRow.appendChild(cellDiv); - - + divRow.appendChild(cellDiv); this.rowsCache[row].cellRenderQueue.push(cell); this.rowsCache[row].cellColSpans[cell] = colspan; @@ -4059,13 +4083,16 @@ export class SlickGrid = Column, O e } /** Apply a Formatter Result to a Cell DOM Node */ - applyFormatResultToCellNode(formatterResult: FormatterResultObject | string, cellNode: HTMLDivElement, suppressRemove?: boolean) { + applyFormatResultToCellNode(formatterResult: FormatterResultWithHtml | FormatterResultWithText | string | HTMLElement, cellNode: HTMLDivElement, suppressRemove?: boolean) { if (formatterResult === null || formatterResult === undefined) { formatterResult = ''; } if (Object.prototype.toString.call(formatterResult) !== '[object Object]') { - cellNode.innerHTML = this.sanitizeHtmlString(formatterResult as string); + this.applyHtmlCode(cellNode, formatterResult as string | HTMLElement); return; } - cellNode.innerHTML = this.sanitizeHtmlString((formatterResult as FormatterResultObject).text); + + const formatterVal: HTMLElement | string = (formatterResult as FormatterResultWithHtml).html || (formatterResult as FormatterResultWithText).text; + this.applyHtmlCode(cellNode, formatterVal); + if ((formatterResult as FormatterResultObject).removeClasses && !suppressRemove) { const classes = (formatterResult as FormatterResultObject).removeClasses!.split(' '); classes.forEach((c) => cellNode.classList.remove(c)); @@ -4534,7 +4561,7 @@ export class SlickGrid = Column, O e const processedRows: number[] = []; let cellsAdded: number; let totalCellsAdded = 0; - let colspan; + let colspan: number | string; for (let row = range.top as number, btm = range.bottom as number; row <= btm; row++) { cacheEntry = this.rowsCache[row]; @@ -4579,12 +4606,13 @@ export class SlickGrid = Column, O e } } - if (this.columnPosRight[Math.min(ii - 1, i + colspan - 1)] > range.leftPx) { - this.appendCellHtml(divRow, row, i, colspan, d); + const colspanNb = colspan as number; // at this point colspan is for sure a number + if (this.columnPosRight[Math.min(ii - 1, i + colspanNb - 1)] > range.leftPx) { + this.appendCellHtml(divRow, row, i, colspanNb, d); cellsAdded++; } - i += (colspan > 1 ? colspan - 1 : 0); + i += (colspanNb > 1 ? colspanNb - 1 : 0); } if (cellsAdded) {