Skip to content

Commit

Permalink
fix(DHIS2-17668): sanitise HTML in table instead of showing it as text
Browse files Browse the repository at this point in the history
  • Loading branch information
kabaros committed Aug 20, 2024
1 parent 714d490 commit 4205fdc
Show file tree
Hide file tree
Showing 6 changed files with 294 additions and 65 deletions.
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@
"history": "^5.0.1",
"prop-types": "^15.7.2",
"query-string": "^7.0.1",
"react-html-parser": "^2.0.2",
"use-debounce": "^7.0.0"
}
}
2 changes: 1 addition & 1 deletion src/app-context/app-provider.js
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ const query = {
'displayName',
'dataApprovalLevels',
'periodType',
'dataSets[id,displayName,periodType]',
'dataSets[id,displayName,periodType,formType]',
],
},
},
Expand Down
15 changes: 15 additions & 0 deletions src/data-workspace/display/display.js
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import {
RetryButton,
} from '../../shared/index.js'
import styles from './display.module.css'
import { TableCustomDataSet } from './table-custom-data-set.js'
import { Table } from './table.js'

const query = {
Expand Down Expand Up @@ -123,6 +124,20 @@ const Display = ({ dataSetId }) => {
)
}

if (selectedDataSet.formType === 'CUSTOM') {
return (
<div className={styles.display}>
{tables.map((table) => (
<TableCustomDataSet
key={table.title}
title={table.title}
columns={table.headers.map((h) => h.name)}
rows={table.rows}
/>
))}
</div>
)
}
return (
<div className={styles.display}>
{tables.map((table) => (
Expand Down
217 changes: 155 additions & 62 deletions src/data-workspace/display/display.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -23,12 +23,6 @@ describe('<Display>', () => {
periodType: 'Monthly',
}

const dataSetThree = {
displayName: 'Another',
id: 'custom',
periodType: 'Monthly',
}

it('asks the user to select a data set if none is selected', () => {
render(
<CustomDataProvider options={{ loadForever: true }}>
Expand Down Expand Up @@ -223,64 +217,163 @@ describe('<Display>', () => {
).toBeInTheDocument()
})

it('renders one table per data set in the report', async () => {
const data = {
dataSetReport: [
{
title: 'Data set 1',
headers: [{ name: 'Header 1' }, { name: 'Header 2' }],
rows: [],
},
{
title: 'Data set 2',
headers: [{ name: 'Header 1' }, { name: 'Header 2' }],
rows: [],
},
{
title: 'Data set 3',
headers: [{ name: 'Header 1' }, { name: 'Header 2' }],
rows: [],
},
],
}
render(
<CustomDataProvider data={data}>
<SelectionContext.Provider
value={{
orgUnit: {
id: 'ou-2',
path: '/ou-2',
displayName: 'Org unit 2',
},
period: {
displayName: 'January 2021',
startDate: '2021-01-01',
endDate: '2021-01-31',
year: 2021,
iso: '202101',
id: '202101',
},
workflow: {
dataSets: [dataSetOne, dataSetTwo],
dataApprovalLevels: [],
displayName: 'Workflow 1',
periodType: 'Monthly',
id: 'foo',
},
}}
>
<Display dataSetId="pBOMPrpg1QX" />
</SelectionContext.Provider>
</CustomDataProvider>
)
describe('display for custom datasets', () => {
it('renders a table for a custom dataset with safely sanitised HTML and CSS', async () => {
const data = {
dataSetReport: [
{
title: 'Custom Data set',
headers: [
{
name: '<b><span style="color:#00b050">2024/25</span></b>',
column: '<b><span style="color:#00b050">2024/25</span></b>',
type: 'java.lang.String',
hidden: false,
meta: false,
},
{
name: '<span style="color:black">NATIONAL DEPARTMENT OF HEALTH</span>',
column: '<span style="color:black">NATIONAL DEPARTMENT OF HEALTH</span>',
type: 'java.lang.String',
hidden: false,
meta: false,
},
],
rows: [
[
'<span style="color:black">Programme 6: Performance Indicator</span>',
],
],
},
],
}
render(
<CustomDataProvider data={data}>
<SelectionContext.Provider
value={{
orgUnit: {
id: 'ou-2',
path: '/ou-2',
displayName: 'Org unit 2',
},
period: {
displayName: 'January 2021',
startDate: '2021-01-01',
endDate: '2021-01-31',
year: 2021,
iso: '202101',
id: '202101',
},
workflow: {
dataSets: [
{
displayName: 'Another',
id: 'custom',
periodType: 'Monthly',
formType: 'CUSTOM',
},
],
dataApprovalLevels: [],
displayName: 'Workflow 1',
periodType: 'Monthly',
id: 'foo',
},
}}
>
<Display dataSetId="custom" />
</SelectionContext.Provider>
</CustomDataProvider>
)

await waitForElementToBeRemoved(() => screen.getByRole('progressbar'))
await waitForElementToBeRemoved(() =>
screen.getByRole('progressbar')
)

expect(await screen.findAllByRole('table')).toHaveLength(3)
expect(
await screen.queryByText(
/This data set does not use a default form. The data is displayed as a simple grid below, but this might not be a suitable representation..*/
expect(screen.getByText('2024/25')).toHaveStyle({
color: 'rgb(0, 176, 80)',
})
expect(screen.getByText('2024/25').parentElement.tagName).toBe('B')

expect(
screen.getByText('NATIONAL DEPARTMENT OF HEALTH')
).toHaveStyle({
color: 'black',
})

expect(
screen.getByText('Programme 6: Performance Indicator')
).toHaveStyle({
color: 'black',
})
})

it('renders HTML and CSS encoded for non-custom dataset', async () => {
const data = {
dataSetReport: [
{
title: 'Custom Data set',
headers: [
{
name: '<span style="color:black">NATIONAL DEPARTMENT OF HEALTH</span>',
column: '<span style="color:black">NATIONAL DEPARTMENT OF HEALTH</span>',
type: 'java.lang.String',
hidden: false,
meta: false,
},
],
rows: [
[
'<span style="color:black">Programme 6: Performance Indicator</span>',
],
],
},
],
}
render(
<CustomDataProvider data={data}>
<SelectionContext.Provider
value={{
orgUnit: {
id: 'ou-2',
path: '/ou-2',
displayName: 'Org unit 2',
},
period: {
displayName: 'January 2021',
startDate: '2021-01-01',
endDate: '2021-01-31',
year: 2021,
iso: '202101',
id: '202101',
},
workflow: {
dataSets: [
{
displayName: 'Another',
id: 'custom',
periodType: 'Monthly',
formType: 'Default',
},
],
dataApprovalLevels: [],
displayName: 'Workflow 1',
periodType: 'Monthly',
id: 'foo',
},
}}
>
<Display dataSetId="custom" />
</SelectionContext.Provider>
</CustomDataProvider>
)

await waitForElementToBeRemoved(() =>
screen.getByRole('progressbar')
)
).not.toBeInTheDocument()

expect(screen.getByRole('table')).toContainHTML(
'&lt;span style="color:black"&gt;Programme 6: Performance Indicator&lt;/span&gt;'
)
})
})
})
89 changes: 89 additions & 0 deletions src/data-workspace/display/table-custom-data-set.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
import {
DataTable,
TableHead,
DataTableRow,
DataTableColumnHeader,
TableBody,
DataTableCell,
} from '@dhis2/ui'
import PropTypes from 'prop-types'
import React from 'react'
import ReactHtmlParser from 'react-html-parser'
import styles from './table.module.css'

// Needs to have the same width as the table, so can't use the one from
// @dhis2/ui
const DataTableToolbar = ({ children, columns }) => (
<tr>
<th className={styles.titleCell} colSpan={columns.toString()}>
{children}
</th>
</tr>
)

DataTableToolbar.propTypes = {
children: PropTypes.any.isRequired,
columns: PropTypes.number.isRequired,
}

const TableCustomDataSet = ({ title, columns, rows }) => (
<>
<DataTable className={styles.dataTable} width="auto">
<TableHead>
<DataTableToolbar columns={columns.length}>
{ReactHtmlParser(title)}
</DataTableToolbar>
<DataTableRow>
<DataTableColumnHeader className={styles.cell}>
<span className={styles.labelCellContent}>
{ReactHtmlParser(columns[0])}
</span>
</DataTableColumnHeader>

{columns.slice(1).map((column) => {
return (
<DataTableColumnHeader
key={column}
className={styles.cell}
>
{ReactHtmlParser(column)}
</DataTableColumnHeader>
)
})}
</DataTableRow>
</TableHead>
<TableBody>
{rows.map((row, index) => {
const [firstCell, ...cells] = row

return (
<DataTableRow key={index}>
<DataTableCell className={styles.cell}>
<span className={styles.labelCellContent}>
{ReactHtmlParser(firstCell)}
</span>
</DataTableCell>

{cells.map((value, index) => (
<DataTableCell
key={index}
className={styles.cell}
>
{ReactHtmlParser(value)}
</DataTableCell>
))}
</DataTableRow>
)
})}
</TableBody>
</DataTable>
</>
)

TableCustomDataSet.propTypes = {
columns: PropTypes.array.isRequired,
rows: PropTypes.array.isRequired,
title: PropTypes.string.isRequired,
}

export { TableCustomDataSet }
Loading

0 comments on commit 4205fdc

Please sign in to comment.