The error/no-spinner/spinner cell renderers wrote the cell value straight into td.innerHTML. A value containing markup (which can arrive from a dataset served by the getdata stored program or from a typed edit) was therefore parsed and executed by the browser. Escape the value so it renders as inert text, keeping the hardcoded icon markup intact, and add a regression test that reproduces the injection via a real Handsontable instance.
142 lines
3.4 KiB
TypeScript
142 lines
3.4 KiB
TypeScript
import Handsontable from 'handsontable'
|
|
|
|
/**
|
|
* Returns string-safe text of any value so it can be assigned to innerHTML.
|
|
* The cell values painted by the status renderers are user/DB-controlled,
|
|
* so they must never be parsed as HTML by the browser — escaping turns any
|
|
* embedded markup into inert text.
|
|
*/
|
|
const escapeHtml = (value: any): string =>
|
|
String(value ?? '').replace(/[&<>"']/g, (char) => {
|
|
const entities: Record<string, string> = {
|
|
'&': '&',
|
|
'<': '<',
|
|
'>': '>',
|
|
'"': '"',
|
|
"'": '''
|
|
}
|
|
return entities[char]
|
|
})
|
|
|
|
/**
|
|
* Builds a display-only HOT renderer that formats numeric cell values using
|
|
* Intl.NumberFormat. The stored/submitted value is never changed — only the
|
|
* rendered text. `ruleValue` is a JSON string of Intl.NumberFormat options
|
|
* (e.g. '{"style":"currency","currency":"EUR","minimumFractionDigits":2}').
|
|
*
|
|
* Falls back to the plain text renderer when the JSON is invalid, the options
|
|
* are rejected by Intl.NumberFormat, or the value is not a finite number.
|
|
*/
|
|
export const makeNumberFormatRenderer = (ruleValue?: string) => {
|
|
let formatter: Intl.NumberFormat | null = null
|
|
|
|
try {
|
|
const options = ruleValue ? JSON.parse(ruleValue) : {}
|
|
formatter = new Intl.NumberFormat(window.navigator.language, options)
|
|
} catch (e) {
|
|
console.warn(
|
|
`NUMBER_FORMAT - invalid Intl.NumberFormat options: ${ruleValue}`
|
|
)
|
|
formatter = null
|
|
}
|
|
|
|
const baseRenderer = Handsontable.renderers.getRenderer('text')
|
|
|
|
return (
|
|
instance: any,
|
|
td: any,
|
|
row: number,
|
|
col: number,
|
|
prop: string | number,
|
|
value: any,
|
|
cellProperties: any
|
|
) => {
|
|
// Render via the base text renderer first to preserve cell styling/classes
|
|
// (readOnly, alignment, etc.), then override the displayed text.
|
|
baseRenderer(instance, td, row, col, prop, value, cellProperties)
|
|
|
|
const num = Number(value)
|
|
if (
|
|
formatter &&
|
|
value !== null &&
|
|
value !== undefined &&
|
|
value !== '' &&
|
|
!isNaN(num)
|
|
) {
|
|
td.textContent = formatter.format(num)
|
|
}
|
|
|
|
return td
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Custom renderer for HOT cell
|
|
* Used to show error icon
|
|
*/
|
|
export const errorRenderer = (
|
|
instance: any,
|
|
td: any,
|
|
row: number,
|
|
col: number,
|
|
prop: string,
|
|
value: any,
|
|
cellProperties: any
|
|
) => {
|
|
addDarkClass(td)
|
|
|
|
td.innerHTML = `${escapeHtml(value)} <cds-icon shape="exclamation-triangle" status="warning"></cds-icon>`
|
|
|
|
return td
|
|
}
|
|
|
|
/**
|
|
* Custom renderer for HOT cell
|
|
* Used to revert cell back to original state (no spinner, no error)
|
|
*/
|
|
export const noSpinnerRenderer = (
|
|
instance: any,
|
|
td: any,
|
|
row: number,
|
|
col: number,
|
|
prop: string,
|
|
value: any,
|
|
cellProperties: any
|
|
) => {
|
|
addDarkClass(td)
|
|
|
|
td.innerHTML = escapeHtml(value)
|
|
|
|
return td
|
|
}
|
|
|
|
/**
|
|
* Custom renderer for HOT cell
|
|
* Used to show loading spinner in the cell
|
|
* (Spinner shown whilst waiting for SAS to respond)
|
|
*/
|
|
export const spinnerRenderer = (
|
|
instance: any,
|
|
td: any,
|
|
row: number,
|
|
col: number,
|
|
prop: string,
|
|
value: any,
|
|
cellProperties: any
|
|
) => {
|
|
addDarkClass(td)
|
|
|
|
td.innerHTML = `${escapeHtml(value)} <span class="spinner spinner-sm vertical-align-middle"></span>`
|
|
|
|
return td
|
|
}
|
|
|
|
/**
|
|
* Adds a htDark class to a TD element if not existing
|
|
*/
|
|
const addDarkClass = (td: any) => {
|
|
if (!td.classList.contains('htDark')) {
|
|
td.classList.add('htDark')
|
|
}
|
|
}
|