diff --git a/package.json b/package.json index 40dc629d..0331e630 100644 --- a/package.json +++ b/package.json @@ -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" } } diff --git a/src/app-context/app-provider.js b/src/app-context/app-provider.js index e4b201e4..f0868dd3 100644 --- a/src/app-context/app-provider.js +++ b/src/app-context/app-provider.js @@ -23,7 +23,7 @@ const query = { 'displayName', 'dataApprovalLevels', 'periodType', - 'dataSets[id,displayName,periodType]', + 'dataSets[id,displayName,periodType,formType]', ], }, }, diff --git a/src/data-workspace/display/display.js b/src/data-workspace/display/display.js index bc1d22c2..1d892dcb 100644 --- a/src/data-workspace/display/display.js +++ b/src/data-workspace/display/display.js @@ -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 = { @@ -123,6 +124,20 @@ const Display = ({ dataSetId }) => { ) } + if (selectedDataSet.formType === 'CUSTOM') { + return ( +
+ {tables.map((table) => ( + h.name)} + rows={table.rows} + /> + ))} +
+ ) + } return (
{tables.map((table) => ( diff --git a/src/data-workspace/display/display.test.js b/src/data-workspace/display/display.test.js index 916b917c..1c8e60d4 100644 --- a/src/data-workspace/display/display.test.js +++ b/src/data-workspace/display/display.test.js @@ -23,12 +23,6 @@ describe('', () => { periodType: 'Monthly', } - const dataSetThree = { - displayName: 'Another', - id: 'custom', - periodType: 'Monthly', - } - it('asks the user to select a data set if none is selected', () => { render( @@ -223,64 +217,163 @@ describe('', () => { ).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( - - - - - - ) + 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: '2024/25', + column: '2024/25', + type: 'java.lang.String', + hidden: false, + meta: false, + }, + { + name: 'NATIONAL DEPARTMENT OF HEALTH', + column: 'NATIONAL DEPARTMENT OF HEALTH', + type: 'java.lang.String', + hidden: false, + meta: false, + }, + ], + rows: [ + [ + 'Programme 6: Performance Indicator', + ], + ], + }, + ], + } + render( + + + + + + ) - 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: 'NATIONAL DEPARTMENT OF HEALTH', + column: 'NATIONAL DEPARTMENT OF HEALTH', + type: 'java.lang.String', + hidden: false, + meta: false, + }, + ], + rows: [ + [ + 'Programme 6: Performance Indicator', + ], + ], + }, + ], + } + render( + + + + + + ) + + await waitForElementToBeRemoved(() => + screen.getByRole('progressbar') ) - ).not.toBeInTheDocument() + + expect(screen.getByRole('table')).toContainHTML( + '<span style="color:black">Programme 6: Performance Indicator</span>' + ) + }) }) }) diff --git a/src/data-workspace/display/table-custom-data-set.js b/src/data-workspace/display/table-custom-data-set.js new file mode 100644 index 00000000..c364a73f --- /dev/null +++ b/src/data-workspace/display/table-custom-data-set.js @@ -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 }) => ( + + + {children} + + +) + +DataTableToolbar.propTypes = { + children: PropTypes.any.isRequired, + columns: PropTypes.number.isRequired, +} + +const TableCustomDataSet = ({ title, columns, rows }) => ( + <> + + + + {ReactHtmlParser(title)} + + + + + {ReactHtmlParser(columns[0])} + + + + {columns.slice(1).map((column) => { + return ( + + {ReactHtmlParser(column)} + + ) + })} + + + + {rows.map((row, index) => { + const [firstCell, ...cells] = row + + return ( + + + + {ReactHtmlParser(firstCell)} + + + + {cells.map((value, index) => ( + + {ReactHtmlParser(value)} + + ))} + + ) + })} + + + +) + +TableCustomDataSet.propTypes = { + columns: PropTypes.array.isRequired, + rows: PropTypes.array.isRequired, + title: PropTypes.string.isRequired, +} + +export { TableCustomDataSet } diff --git a/yarn.lock b/yarn.lock index d8f745c3..64e1f624 100644 --- a/yarn.lock +++ b/yarn.lock @@ -7795,7 +7795,7 @@ domain-browser@^1.1.1, domain-browser@^1.2.0: resolved "https://registry.yarnpkg.com/domain-browser/-/domain-browser-1.2.0.tgz#3d31f50191a6749dd1375a7f522e823d42e54eda" integrity sha512-jnjyiM6eRyZl2H+W8Q/zLMA481hzi0eszAaBUzIVnmYVDBbnLxVNnfu1HgEBvCbL+71FrxMl3E6lpKH7Ge3OXA== -domelementtype@1: +domelementtype@1, domelementtype@^1.3.1: version "1.3.1" resolved "https://registry.yarnpkg.com/domelementtype/-/domelementtype-1.3.1.tgz#d048c44b37b0d10a7f2a3d5fee3f4333d790481f" integrity sha512-BSKB+TSpMpFI/HOxCNr1O8aMOTZ8hT3pM3GQ0w/mWRmkhEDSFJkkyzz4XQsBV44BChwGkrDfMyjVD0eA2aFV3w== @@ -7812,6 +7812,13 @@ domexception@^2.0.1: dependencies: webidl-conversions "^5.0.0" +domhandler@^2.3.0: + version "2.4.2" + resolved "https://registry.yarnpkg.com/domhandler/-/domhandler-2.4.2.tgz#8805097e933d65e85546f726d60f5eb88b44f803" + integrity sha512-JiK04h0Ht5u/80fdLMCEmV4zkNh2BcoMFBmZ/91WtYZ8qVXSKjiw7fXMgFPnHcSZgOo3XdinHvmnDUeMf5R4wA== + dependencies: + domelementtype "1" + domhandler@^4.0.0, domhandler@^4.2.0: version "4.2.2" resolved "https://registry.yarnpkg.com/domhandler/-/domhandler-4.2.2.tgz#e825d721d19a86b8c201a35264e226c678ee755f" @@ -7819,7 +7826,7 @@ domhandler@^4.0.0, domhandler@^4.2.0: dependencies: domelementtype "^2.2.0" -domutils@^1.7.0: +domutils@^1.5.1, domutils@^1.7.0: version "1.7.0" resolved "https://registry.yarnpkg.com/domutils/-/domutils-1.7.0.tgz#56ea341e834e06e6748af7a1cb25da67ea9f8c2a" integrity sha512-Lgd2XcJ/NjEw+7tFvfKxOzCYKZsdct5lczQ2ZaQY8Djz7pfAD3Gbp8ySJWtreII/vDlMVmxwa6pHmdxIYgttDg== @@ -8047,6 +8054,11 @@ ensure-array@^1.0.0: resolved "https://registry.yarnpkg.com/ensure-array/-/ensure-array-1.0.0.tgz#317e9fc632c656bb849eb649133528e205b23abc" integrity sha512-A+3Ntl5WS+GjDnHtC67dKIjw+IoGoeFdNvjn3ZfKEmZgWUz0nxBPE4W52QMCbGZsat0VwWskD5T6AEpe3T2d1g== +entities@^1.1.1: + version "1.1.2" + resolved "https://registry.yarnpkg.com/entities/-/entities-1.1.2.tgz#bdfa735299664dfafd34529ed4f8522a275fea56" + integrity sha512-f2LZMYl1Fzu7YSBKg+RoROelpOaNrcGmE9AZubeDfrCEia483oW4MI4VyFd5VNHIgQ/7qm1I0wUHK1eJnn2y2w== + entities@^2.0.0: version "2.2.0" resolved "https://registry.yarnpkg.com/entities/-/entities-2.2.0.tgz#098dc90ebb83d8dffa089d55256b351d34c4da55" @@ -9898,6 +9910,18 @@ htmlescape@^1.1.0: resolved "https://registry.yarnpkg.com/htmlescape/-/htmlescape-1.1.1.tgz#3a03edc2214bca3b66424a3e7959349509cb0351" integrity sha1-OgPtwiFLyjtmQko+eVk0lQnLA1E= +htmlparser2@^3.9.0: + version "3.10.1" + resolved "https://registry.yarnpkg.com/htmlparser2/-/htmlparser2-3.10.1.tgz#bd679dc3f59897b6a34bb10749c855bb53a9392f" + integrity sha512-IgieNijUMbkDovyoKObU1DUhm1iwNYE/fuifEoEHfd1oZKZDaONBSkal7Y01shxsM49R4XaMdGez3WnF9UfiCQ== + dependencies: + domelementtype "^1.3.1" + domhandler "^2.3.0" + domutils "^1.5.1" + entities "^1.1.1" + inherits "^2.0.1" + readable-stream "^3.1.1" + htmlparser2@^6.1.0: version "6.1.0" resolved "https://registry.yarnpkg.com/htmlparser2/-/htmlparser2-6.1.0.tgz#c4d762b6c3371a05dbe65e94ae43a9f845fb8fb7" @@ -14870,6 +14894,13 @@ react-final-form@^6.5.3: dependencies: "@babel/runtime" "^7.12.1" +react-html-parser@^2.0.2: + version "2.0.2" + resolved "https://registry.yarnpkg.com/react-html-parser/-/react-html-parser-2.0.2.tgz#6dbe1ddd2cebc1b34ca15215158021db5fc5685e" + integrity sha512-XeerLwCVjTs3njZcgCOeDUqLgNIt/t+6Jgi5/qPsO/krUWl76kWKXMeVs2LhY2gwM6X378DkhLjur0zUQdpz0g== + dependencies: + htmlparser2 "^3.9.0" + react-is@^16.13.1, react-is@^16.8.1, react-is@^16.8.6: version "16.13.1" resolved "https://registry.yarnpkg.com/react-is/-/react-is-16.13.1.tgz#789729a4dc36de2999dc156dd6c1d9c18cea56a4"