Skip to content

Commit 0440db3

Browse files
Merge pull request #121 from OpenDataEnsemble/feature/formplayer-html-rendering
fix(formplayer): Fix JSONForms cell rendering and add HTML label support
2 parents 0953dce + f1ef031 commit 0440db3

18 files changed

Lines changed: 154 additions & 213 deletions

formulus-formplayer/package.json

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -18,15 +18,13 @@
1818
"@testing-library/jest-dom": "^6.6.3",
1919
"@testing-library/react": "^16.2.0",
2020
"@testing-library/user-event": "^13.5.0",
21-
"@types/dompurify": "^3.0.5",
2221
"@types/jest": "^27.5.2",
2322
"@types/node": "^16.18.126",
2423
"@types/react": "^19.0.11",
2524
"@types/react-dom": "^19.0.4",
2625
"ajv": "^8.17.1",
2726
"ajv-errors": "^3.0.0",
2827
"ajv-formats": "^3.0.1",
29-
"dompurify": "^3.3.1",
3028
"react": "^19.0.0",
3129
"react-dom": "^19.0.0",
3230
"react-scripts": "5.0.1",

formulus-formplayer/src/App.tsx

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ import FileQuestionRenderer, { fileQuestionTester } from './FileQuestionRenderer
2525
import AudioQuestionRenderer, { audioQuestionTester } from './AudioQuestionRenderer';
2626
import GPSQuestionRenderer, { gpsQuestionTester } from './GPSQuestionRenderer';
2727
import VideoQuestionRenderer, { videoQuestionTester } from './VideoQuestionRenderer';
28+
import HtmlLabelRenderer, { htmlLabelTester } from './HtmlLabelRenderer';
2829
import { shellMaterialRenderers } from './material-wrappers';
2930

3031
import ErrorBoundary from './ErrorBoundary';
@@ -172,6 +173,7 @@ export const customRenderers = [
172173
{ tester: audioQuestionTester, renderer: AudioQuestionRenderer },
173174
{ tester: gpsQuestionTester, renderer: GPSQuestionRenderer },
174175
{ tester: videoQuestionTester, renderer: VideoQuestionRenderer },
176+
{ tester: htmlLabelTester, renderer: HtmlLabelRenderer },
175177
];
176178

177179
function App() {
@@ -536,7 +538,7 @@ function App() {
536538

537539
const ajv = new Ajv({
538540
allErrors: true,
539-
strictTypes: false, // Allow custom formats without strict type checking
541+
strict: false, // Allow custom keywords like x-formulus-validation
540542
});
541543
addErrors(ajv);
542544
addFormats(ajv);
Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,100 @@
1+
import React from 'react';
2+
import { RankedTester, rankWith, uiTypeIs, UISchemaElement } from '@jsonforms/core';
3+
import { withJsonFormsLabelProps } from '@jsonforms/react';
4+
import { Typography, Box } from '@mui/material';
5+
6+
/**
7+
* Simple HTML sanitizer that removes dangerous tags and attributes.
8+
*/
9+
const sanitizeHtml = (html: string): string => {
10+
// Remove script tags and their content
11+
let sanitized = html.replace(/<script\b[^<]*(?:(?!<\/script>)<[^<]*)*<\/script>/gi, '');
12+
// Remove style tags and their content
13+
sanitized = sanitized.replace(/<style\b[^<]*(?:(?!<\/style>)<[^<]*)*<\/style>/gi, '');
14+
// Remove event handlers (onclick, onerror, etc.)
15+
sanitized = sanitized.replace(/\s*on\w+\s*=\s*["'][^"']*["']/gi, '');
16+
sanitized = sanitized.replace(/\s*on\w+\s*=\s*[^\s>]+/gi, '');
17+
// Remove javascript: URLs
18+
sanitized = sanitized.replace(/javascript:/gi, '');
19+
// Remove data: URLs in href/src (potential XSS vector)
20+
sanitized = sanitized.replace(/\s*href\s*=\s*["']?\s*data:/gi, ' href="');
21+
sanitized = sanitized.replace(/\s*src\s*=\s*["']?\s*data:/gi, ' src="');
22+
23+
return sanitized;
24+
};
25+
26+
/**
27+
* Check if content contains HTML tags
28+
*/
29+
const hasHtmlTags = (content: string): boolean => {
30+
const htmlTagPattern = /<[a-z][a-z0-9]*(\s+[^>]*)?>/i;
31+
return htmlTagPattern.test(content);
32+
};
33+
34+
interface HtmlLabelProps {
35+
text?: string;
36+
visible?: boolean;
37+
uischema?: UISchemaElement;
38+
}
39+
40+
/**
41+
* Custom Label renderer that supports HTML content.
42+
* Detects HTML tags in the label text and renders them safely.
43+
*/
44+
const HtmlLabelRenderer: React.FC<HtmlLabelProps> = ({ text, visible, uischema }) => {
45+
if (visible === false) {
46+
return null;
47+
}
48+
49+
// Check if HTML rendering is enabled via options or if content has HTML tags
50+
const options = (uischema as any)?.options || {};
51+
const htmlEnabled = options.html === true || options.format === 'html';
52+
const contentHasHtml = hasHtmlTags(text || '');
53+
const shouldRenderHtml = htmlEnabled || contentHasHtml;
54+
55+
if (shouldRenderHtml && text) {
56+
const sanitized = sanitizeHtml(text);
57+
return (
58+
<Box sx={{ mb: 2 }}>
59+
<Typography
60+
variant="body1"
61+
component="div"
62+
sx={{
63+
'& ul, & ol': {
64+
pl: 3,
65+
my: 1,
66+
},
67+
'& li': {
68+
mb: 0.5,
69+
},
70+
'& b, & strong': {
71+
fontWeight: 700,
72+
},
73+
'& i, & em': {
74+
fontStyle: 'italic',
75+
},
76+
'& br': {
77+
display: 'block',
78+
content: '""',
79+
mt: 1,
80+
},
81+
}}
82+
dangerouslySetInnerHTML={{ __html: sanitized }}
83+
/>
84+
</Box>
85+
);
86+
}
87+
88+
// Plain text rendering
89+
return (
90+
<Box sx={{ mb: 2 }}>
91+
<Typography variant="body1">{text}</Typography>
92+
</Box>
93+
);
94+
};
95+
96+
// Tester with high priority to override default label renderer
97+
export const htmlLabelTester: RankedTester = rankWith(10, uiTypeIs('Label'));
98+
99+
// Use type assertion to satisfy withJsonFormsLabelProps
100+
export default withJsonFormsLabelProps(HtmlLabelRenderer as React.ComponentType<any>);

formulus-formplayer/src/QuestionShell.tsx

Lines changed: 34 additions & 41 deletions
Original file line numberDiff line numberDiff line change
@@ -1,58 +1,51 @@
11
import React, { ReactNode } from 'react';
22
import { Box, Typography, Alert, Stack, Divider } from '@mui/material';
3-
import DOMPurify from 'dompurify';
43

54
/**
6-
* Safely renders HTML content by detecting HTML tags and rendering them
7-
* Uses DOMPurify for robust HTML sanitization and dangerouslySetInnerHTML
8-
* only when HTML is detected for security.
9-
*
10-
* This function is backward compatible - if no HTML tags are detected,
11-
* content is returned as-is for normal text rendering.
5+
* Simple HTML sanitizer that removes dangerous tags and attributes.
6+
* This is a lightweight alternative that doesn't require external dependencies.
7+
*/
8+
const sanitizeHtml = (html: string): string => {
9+
// Remove script tags and their content
10+
let sanitized = html.replace(/<script\b[^<]*(?:(?!<\/script>)<[^<]*)*<\/script>/gi, '');
11+
// Remove style tags and their content
12+
sanitized = sanitized.replace(/<style\b[^<]*(?:(?!<\/style>)<[^<]*)*<\/style>/gi, '');
13+
// Remove event handlers (onclick, onerror, etc.)
14+
sanitized = sanitized.replace(/\s*on\w+\s*=\s*["'][^"']*["']/gi, '');
15+
sanitized = sanitized.replace(/\s*on\w+\s*=\s*[^\s>]+/gi, '');
16+
// Remove javascript: URLs
17+
sanitized = sanitized.replace(/javascript:/gi, '');
18+
// Remove data: URLs in href/src (potential XSS vector)
19+
sanitized = sanitized.replace(/\s*href\s*=\s*["']?\s*data:/gi, ' href="');
20+
sanitized = sanitized.replace(/\s*src\s*=\s*["']?\s*data:/gi, ' src="');
21+
22+
return sanitized;
23+
};
24+
25+
/**
26+
* Renders content with basic HTML support.
27+
* Detects HTML tags and renders them safely using dangerouslySetInnerHTML.
28+
* Falls back to plain text for non-HTML content.
1229
*/
1330
const renderHtmlContent = (content: string | undefined): React.ReactNode => {
1431
if (!content) return null;
1532

16-
// More precise HTML tag detection - looks for actual HTML tags, not just < followed by text
17-
// This avoids false positives like "Price < $100" or "x < 5"
18-
// Pattern: < followed by a letter (tag name), then optional attributes, then >
33+
// Check for HTML tags - looks for < followed by a letter (tag start)
1934
const htmlTagPattern = /<[a-z][a-z0-9]*(\s+[^>]*)?>/i;
2035
const hasHtmlTags = htmlTagPattern.test(content);
2136

2237
if (hasHtmlTags) {
23-
// Use DOMPurify for robust HTML sanitization
24-
// Allows safe HTML tags (strong, em, p, br, ul, ol, li, etc.) but removes dangerous content
25-
const sanitized = DOMPurify.sanitize(content, {
26-
ALLOWED_TAGS: [
27-
'strong',
28-
'b',
29-
'em',
30-
'i',
31-
'u',
32-
'p',
33-
'br',
34-
'div',
35-
'span',
36-
'ul',
37-
'ol',
38-
'li',
39-
'a',
40-
'h1',
41-
'h2',
42-
'h3',
43-
'h4',
44-
'h5',
45-
'h6',
46-
],
47-
ALLOWED_ATTR: ['href', 'target', 'rel'], // Allow href for links, target and rel for security
48-
ALLOW_DATA_ATTR: false, // Disable data attributes for security
49-
KEEP_CONTENT: true, // Keep text content even if tags are removed
50-
});
51-
52-
return <span dangerouslySetInnerHTML={{ __html: sanitized }} />;
38+
try {
39+
const sanitized = sanitizeHtml(content);
40+
return <span dangerouslySetInnerHTML={{ __html: sanitized }} />;
41+
} catch (error) {
42+
// If sanitization fails, strip all HTML tags
43+
console.error('Error rendering HTML content:', error);
44+
return content.replace(/<[^>]*>/g, '');
45+
}
5346
}
5447

55-
// No HTML tags detected, render as plain text (backward compatible)
48+
// No HTML tags detected, render as plain text
5649
return content;
5750
};
5851

formulus-formplayer/src/SwipeLayoutRenderer.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -123,7 +123,7 @@ const SwipeLayoutRenderer = ({
123123
>
124124
<div {...handlers} className="swipelayout_screen">
125125
{(uischema as any)?.label && <h1>{(uischema as any).label}</h1>}
126-
{layouts.length > 0 && (
126+
{layouts.length > 0 && layouts[currentPage] && (
127127
<JsonFormsDispatch
128128
schema={schema}
129129
uischema={layouts[currentPage]}
Lines changed: 5 additions & 74 deletions
Original file line numberDiff line numberDiff line change
@@ -1,48 +1,11 @@
11
import React from 'react';
22
import { isEnumControl, RankedTester, rankWith, ControlProps } from '@jsonforms/core';
33
import { withJsonFormsControlProps } from '@jsonforms/react';
4-
import {
5-
MaterialTextControl,
6-
materialTextControlTester,
7-
MaterialNumberControl,
8-
materialNumberControlTester,
9-
MaterialIntegerControl,
10-
materialIntegerControlTester,
11-
MaterialDateControl,
12-
materialDateControlTester,
13-
MaterialDateTimeControl,
14-
materialDateTimeControlTester,
15-
MaterialTimeControl,
16-
materialTimeControlTester,
17-
MaterialEnumControl,
18-
materialEnumControlTester,
19-
MaterialOneOfEnumControl,
20-
materialOneOfEnumControlTester,
21-
MaterialRadioGroupControl,
22-
materialRadioGroupControlTester,
23-
} from '@jsonforms/material-renderers';
244
import { Card, CardActionArea, CardContent, Typography, Box } from '@mui/material';
255
import QuestionShell from './QuestionShell';
266

277
type AnyControlProps = ControlProps & { errors?: string };
288

29-
const wrapWithShell =
30-
(Inner: React.ComponentType<any>) =>
31-
(props: ControlProps): React.ReactElement => {
32-
const { schema, uischema, errors } = props;
33-
const label = (uischema as any)?.label || schema.title;
34-
const description = schema.description;
35-
const required = Boolean(
36-
(uischema as any)?.options?.required ?? (schema as any)?.options?.required,
37-
);
38-
39-
return (
40-
<QuestionShell title={label} description={description} required={required} error={errors}>
41-
<Inner {...(props as any)} />
42-
</QuestionShell>
43-
);
44-
};
45-
469
const cardEnumControlTester: RankedTester = rankWith(6, isEnumControl);
4710

4811
const CardEnumControl = (props: AnyControlProps) => {
@@ -92,43 +55,11 @@ const CardEnumControl = (props: AnyControlProps) => {
9255
);
9356
};
9457

58+
// NOTE: We removed the shell wrappers for text/number/integer/date controls because
59+
// they interfere with JSONForms' internal cell rendering mechanism.
60+
// The default materialRenderers handle these controls properly.
61+
// Only export custom renderers that don't break cell rendering.
9562
export const shellMaterialRenderers = [
96-
{
97-
tester: materialTextControlTester,
98-
renderer: withJsonFormsControlProps(wrapWithShell(MaterialTextControl)),
99-
},
100-
{
101-
tester: materialNumberControlTester,
102-
renderer: withJsonFormsControlProps(wrapWithShell(MaterialNumberControl)),
103-
},
104-
{
105-
tester: materialIntegerControlTester,
106-
renderer: withJsonFormsControlProps(wrapWithShell(MaterialIntegerControl)),
107-
},
108-
{
109-
tester: materialDateControlTester,
110-
renderer: withJsonFormsControlProps(wrapWithShell(MaterialDateControl)),
111-
},
112-
{
113-
tester: materialDateTimeControlTester,
114-
renderer: withJsonFormsControlProps(wrapWithShell(MaterialDateTimeControl)),
115-
},
116-
{
117-
tester: materialTimeControlTester,
118-
renderer: withJsonFormsControlProps(wrapWithShell(MaterialTimeControl)),
119-
},
120-
// Card-style select/oneOf/radio
63+
// Card-style enum control - a custom renderer that uses QuestionShell
12164
{ tester: cardEnumControlTester, renderer: withJsonFormsControlProps(CardEnumControl) },
122-
{
123-
tester: materialEnumControlTester,
124-
renderer: withJsonFormsControlProps(wrapWithShell(MaterialEnumControl)),
125-
},
126-
{
127-
tester: materialOneOfEnumControlTester,
128-
renderer: withJsonFormsControlProps(wrapWithShell(MaterialOneOfEnumControl)),
129-
},
130-
{
131-
tester: materialRadioGroupControlTester,
132-
renderer: withJsonFormsControlProps(wrapWithShell(MaterialRadioGroupControl)),
133-
},
13465
];
Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,13 @@
11
{
22
"files": {
3-
"main.css": "./static/css/main.d72c52e9.css",
4-
"main.js": "./static/js/main.1ac29e77.js",
3+
"main.css": "./static/css/main.c4fc8de5.css",
4+
"main.js": "./static/js/main.2d2937a9.js",
55
"index.html": "./index.html",
6-
"main.d72c52e9.css.map": "./static/css/main.d72c52e9.css.map",
7-
"main.1ac29e77.js.map": "./static/js/main.1ac29e77.js.map"
6+
"main.c4fc8de5.css.map": "./static/css/main.c4fc8de5.css.map",
7+
"main.2d2937a9.js.map": "./static/js/main.2d2937a9.js.map"
88
},
99
"entrypoints": [
10-
"static/css/main.d72c52e9.css",
11-
"static/js/main.1ac29e77.js"
10+
"static/css/main.c4fc8de5.css",
11+
"static/js/main.2d2937a9.js"
1212
]
1313
}
Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
1-
<!doctype html><html lang="en"><head><meta charset="utf-8"/><link rel="icon" href="./favicon.ico"/><meta name="viewport" content="width=device-width,initial-scale=1"/><meta name="theme-color" content="#000000"/><meta name="description" content="Web site created using create-react-app"/><link rel="apple-touch-icon" href="./logo192.png"/><link rel="manifest" href="./manifest.json"/><title>React App</title><script src="./formulus-load.js"></script><script defer="defer" src="./static/js/main.1ac29e77.js"></script><link href="./static/css/main.d72c52e9.css" rel="stylesheet"></head><body><noscript>You need to enable JavaScript to run this app.</noscript><div id="root"></div></body></html>
1+
<!doctype html><html lang="en"><head><meta charset="utf-8"/><link rel="icon" href="./favicon.ico"/><meta name="viewport" content="width=device-width,initial-scale=1"/><meta name="theme-color" content="#000000"/><meta name="description" content="Web site created using create-react-app"/><link rel="apple-touch-icon" href="./logo192.png"/><link rel="manifest" href="./manifest.json"/><title>React App</title><script src="./formulus-load.js"></script><script defer="defer" src="./static/js/main.2d2937a9.js"></script><link href="./static/css/main.c4fc8de5.css" rel="stylesheet"></head><body><noscript>You need to enable JavaScript to run this app.</noscript><div id="root"></div></body></html>

formulus/android/app/src/main/assets/formplayer_dist/static/css/main.d72c52e9.css

Lines changed: 0 additions & 2 deletions
This file was deleted.

formulus/android/app/src/main/assets/formplayer_dist/static/css/main.d72c52e9.css.map

Lines changed: 0 additions & 1 deletion
This file was deleted.

0 commit comments

Comments
 (0)