diff --git a/package.json b/package.json index 17b5d827..efa9b9a9 100644 --- a/package.json +++ b/package.json @@ -54,6 +54,10 @@ "fuzzy": "^0.1.3", "lodash-es": "^4.17.21", "react-ace": "^11.0.1", + "react-markdown": "^9.0.1", + "react-mde": "^11.5.0", + "rehype-raw": "^7.0.0", + "remark-gfm": "^4.0.0", "sass": "^1.67.0" }, "peerDependencies": { diff --git a/src/components/interactive-builder/add-question.modal.tsx b/src/components/interactive-builder/add-question.modal.tsx index 2aa36f4a..698b32c9 100644 --- a/src/components/interactive-builder/add-question.modal.tsx +++ b/src/components/interactive-builder/add-question.modal.tsx @@ -47,6 +47,7 @@ import { useConceptLookup } from '../../hooks/useConceptLookup'; import { usePatientIdentifierTypes } from '../../hooks/usePatientIdentifierTypes'; import { usePersonAttributeTypes } from '../../hooks/usePersonAttributeTypes'; import { useProgramWorkStates, usePrograms } from '../../hooks/useProgramStates'; +import MarkdownQuestion from './markdown-question.component'; import styles from './question-modal.scss'; interface AddQuestionModalProps { @@ -108,6 +109,7 @@ const AddQuestionModal: React.FC = ({ const [min, setMin] = useState(''); const [questionId, setQuestionId] = useState(''); const [questionLabel, setQuestionLabel] = useState(''); + const [questionValue, setQuestionValue] = useState(''); const [questionType, setQuestionType] = useState(null); const [rows, setRows] = useState(''); const [selectedAnswers, setSelectedAnswers] = useState< @@ -211,8 +213,9 @@ const AddQuestionModal: React.FC = ({ const computedQuestionId = `question${questionIndex + 1}Section${sectionIndex + 1}Page-${pageIndex + 1}`; const newQuestion = { - label: questionLabel, - type: questionType, + ...(questionLabel && {label: questionLabel}), + ...((renderingType === 'markdown') && {value: questionValue}), + ...((renderingType !== 'markdown') && {type: questionType}), required: isQuestionRequired, id: questionId ?? computedQuestionId, ...((renderingType === 'date' || renderingType === 'datetime') && @@ -320,7 +323,27 @@ const AddQuestionModal: React.FC = ({ - ) => + setRenderingType(event.target.value as RenderType) + } + id="renderingType" + invalidText={t('validRenderingTypeRequired', 'A valid rendering type value is required')} + labelText={t('renderingType', 'Rendering type')} + required + > + {!renderingType && } + + {questionTypes.filter((questionType) => questionType !== 'obs').includes(questionType as Exclude) + ? renderTypeOptions[questionType].map((type, key) => ( + + )) + : fieldTypes.map((type, key) => )} + + + {renderingType === 'markdown' ? : ( + } placeholder={t('labelPlaceholder', 'e.g. Type of Anaesthesia')} @@ -328,6 +351,7 @@ const AddQuestionModal: React.FC = ({ onChange={(event: React.ChangeEvent) => setQuestionLabel(event.target.value)} required /> + )} = ({ required /> - - setIsQuestionRequired(false)} - value="optional" - /> - setIsQuestionRequired(true)} - value="required" - /> - - - - - + {renderingType !== 'markdown' && ( + <> + + setIsQuestionRequired(false)} + value="optional" + /> + setIsQuestionRequired(true)} + value="required" + /> + + + + )} {questionType === 'personAttribute' && (
@@ -846,11 +854,12 @@ const AddQuestionModal: React.FC = ({
= ({ const [min, setMin] = useState(''); const [questionId, setQuestionId] = useState(''); const [questionLabel, setQuestionLabel] = useState(''); + const [questionValue, setQuestionValue] = useState(questionToEdit.value); const [questionType, setQuestionType] = useState(null); const [datePickerType, setDatePickerType] = useState( questionToEdit.datePickerFormat ?? 'both', @@ -268,7 +270,8 @@ const EditQuestionModal: React.FC = ({ try { const data = { - label: questionLabel ? questionLabel : questionToEdit.label, + ...(questionLabel && {label: questionLabel}), + ...(questionValue && {value: questionValue}), type: questionType ? questionType : questionToEdit.type, required: isQuestionRequired ? isQuestionRequired : /true/.test(questionToEdit?.required?.toString()), id: questionId ? questionId : questionToEdit.id, @@ -372,13 +375,28 @@ const EditQuestionModal: React.FC = ({
event.preventDefault()}> - ) => setQuestionLabel(event.target.value)} - required - /> + + {questionToEdit.questionOptions.rendering === 'markdown' ? : ( + ) => setQuestionLabel(event.target.value)} + required + /> + )} = ({ )} required /> - - setIsQuestionRequired(false)} - value="optional" - /> - setIsQuestionRequired(true)} - value="required" - /> - - - + {questionToEdit.questionOptions.rendering !== 'markdown' && ( + <> + + setIsQuestionRequired(false)} + value="optional" + /> + setIsQuestionRequired(true)} + value="required" + /> + + + + )} {fieldType === 'number' ? ( <> = ({ {section.questions?.length ? ( section.questions.map((question, questionIndex) => { return ( - - - {getValidationError(question) && ( -
- {getValidationError(question)} -
- )} - {getAnswerErrors(question.questionOptions.answers)?.length ? ( -
-
Answer Errors
- {getAnswerErrors(question.questionOptions.answers)?.map((error, index) => ( -
{`${error.field.label}: ${error.errorMessage}`}
- ))} -
- ) : null} -
+ + + {getValidationError(question) && ( +
+ {getValidationError(question)} +
+ )} + {getAnswerErrors(question.questionOptions.answers)?.length ? ( +
+
Answer Errors
+ {getAnswerErrors(question.questionOptions.answers)?.map((error, index) => ( +
{`${error.field.label}: ${error.errorMessage}`}
+ ))} +
+ ) : null} +
); }) ) : ( diff --git a/src/components/interactive-builder/interactive-builder.scss b/src/components/interactive-builder/interactive-builder.scss index c92fa47b..09f864bd 100644 --- a/src/components/interactive-builder/interactive-builder.scss +++ b/src/components/interactive-builder/interactive-builder.scss @@ -113,4 +113,4 @@ margin-left: 2rem; margin-top: 1rem; font-size: 0.75rem; -} +} \ No newline at end of file diff --git a/src/components/interactive-builder/markdown-question.component.tsx b/src/components/interactive-builder/markdown-question.component.tsx new file mode 100644 index 00000000..6c9ec39f --- /dev/null +++ b/src/components/interactive-builder/markdown-question.component.tsx @@ -0,0 +1,54 @@ +import React from 'react'; +import ReactMde, { getDefaultToolbarCommands } from 'react-mde'; +import ReactMarkdown from 'react-markdown'; +import remarkGfm from 'remark-gfm'; +import rehypeRaw from 'rehype-raw'; +import styles from './markdown-question.scss'; + +interface MarkdownQuestionProps { + palceHolder?: string; + onValueChange: (value: string) => void; +} + +const MarkdownQuestion: React.FC = ({ palceHolder, onValueChange }) => { + const [value, setValue] = React.useState(palceHolder || ""); + const [selectedTab, setSelectedTab] = React.useState<"write" | "preview">("write"); + + const handleEditorChange = (newValue: string) => { + setValue(newValue); + onValueChange(newValue); + }; + + const handleTabChange = () => { + setSelectedTab((prevTab) => (prevTab === "write" ? "preview" : "write")); + }; + + return ( +
+ + Promise.resolve( + and + /> + ) + } + childProps={{ + writeButton: { + tabIndex: -1 + } + }} + loadingPreview='loading preview...' + /> +
+ ); +} + +export default MarkdownQuestion; \ No newline at end of file diff --git a/src/components/interactive-builder/markdown-question.scss b/src/components/interactive-builder/markdown-question.scss new file mode 100644 index 00000000..95b66467 --- /dev/null +++ b/src/components/interactive-builder/markdown-question.scss @@ -0,0 +1,372 @@ +// These styles are coppied from 'node_modules/react-mde/lib/styles/css/react-mde-all.css' +$mde-border-radius: 2px !default; +$mde-white-color: white !default; +$mde-border-color: #c8ccd0 !default; +$mde-button-color: #242729 !default; +$mde-toolbar-color: #f9f9f9 !default; +$mde-selected-color: #0366d6 !default; +$mde-toolbar-padding: 10px !default; +$mde-editor-default-min-height: 200px !default; +$mde-editor-padding: 10px !default; +$mde-preview-horizontal-padding: 10px !default; +$mde-preview-padding: 10px !default; +$mde-preview-default-min-height: $mde-editor-default-min-height !default; +$mde-preview-default-height: auto !default; + +.container { + position: relative; +} + +.markdownPlaceholder { + position: absolute; + top: 12px; + left: 12px; + color: #888; + pointer-events: none; /* Allows click-through to editor */ + font-size: 14px; +} + +:global(.mde-header) { + flex-shrink: 0; + display: flex; + flex-direction: row; + flex-wrap: wrap; + align-items: stretch; + border-bottom: 1px solid $mde-border-color; + border-radius: $mde-border-radius $mde-border-radius 0 0; + background: $mde-toolbar-color; + + :global(.mde-tabs) { + display: flex; + flex-direction: row; + + button { + border-radius: $mde-border-radius; + margin: 6px 3px; + background-color: transparent; + border: 1px solid transparent; + cursor: pointer; + &:first-child { + margin-left: 6px; + } + &:global(.selected) { + border: 1px solid $mde-border-color + } + } + } + + :global(.svg-icon) { + width: 1em; + height: 1em; + display: inline-block; + font-size: inherit; + overflow: visible; + vertical-align: -.125em; + } + + ul:global(.mde-header-group) { + display: flex !important; + flex-direction: row; + margin: 0; + padding: $mde-toolbar-padding; + list-style: none; + display: flex; + flex-wrap: nowrap; + + &:global(.hidden) { + visibility: hidden; + } + + li:global(.mde-header-item) { + display: inline-block; + position: relative; + margin: 0 4px; + button { + text-align: left; + cursor: pointer; + height: 22px; + padding: 4px; + margin: 0; + border: none; + background: none; + color: $mde-button-color; + @keyframes tooltip-appear { + from { + opacity: 0; + } + to { + opacity: 1; + } + } + @mixin tooltip-animation { + animation-name: tooltip-appear; + animation-duration: 0.2s; + animation-delay: 0.5s; + animation-fill-mode: forwards; + } + + &:global(.tooltipped) { + &:hover::before { + @include tooltip-animation(); + opacity: 0; + position: absolute; + z-index: 1000001; + width: 0; + height: 0; + color: rgba(0, 0, 0, 0.8); + pointer-events: none; + content: ""; + border: 5px solid transparent; + top: -5px; + right: 50%; + bottom: auto; + margin-right: -5px; + border-top-color: rgba(0, 0, 0, 0.8); + } + &:hover::after { + @include tooltip-animation(); + font-size: 11px; + opacity: 0; + position: absolute; + z-index: 1000000; + padding: 5px 8px; + color: #fff; + pointer-events: none; + content: attr(aria-label); + background: rgba(0, 0, 0, 0.8); + border-radius: 3px; + right: 50%; + bottom: 100%; + transform: translateX(50%); + margin-bottom: 5px; + white-space: nowrap; + } + } + } + } + } +} + +:global(.mde-textarea-wrapper) { + position: relative; + margin: 2px; + + textarea:global(.mde-text) { + width: 100%; + border: 0; + padding: $mde-editor-padding; + vertical-align: top; + resize: vertical; + height: auto; + overflow-y: auto; + max-height: 200px; + } + + textarea:focus { + border: 2px solid blue !important; + border-radius: 3px; + outline: none; + } +} + +:global(.mde-preview) { + :global(.mde-preview-content) { + padding: $mde-preview-padding; + + p, blockquote, ul, ol, dl, table, pre { + margin-top: 0; + margin-bottom: 16px; + } + + h1, h2, h3 { + margin-top: 24px; + margin-bottom: 16px; + font-weight: 600; + line-height: 1.25; + border-bottom: 1px solid #eee; + padding-bottom: 0.3em; + } + h1 { + font-size: 1.6em; + } + h2 { + font-size: 1.4em; + } + h3 { + font-size: 1.2em; + } + ul, ol { + padding-left: 2em; + } + blockquote { + margin-left: 0; + padding: 0 1em; + color: #777; + border-left: 0.25em solid #ddd; + & > :first-child { + margin-top: 0; + } + & > :last-child { + margin-bottom: 0; + } + } + + code { + padding: 0.2em 0 0.2em 0; + margin: 0; + font-size: 90%; + background-color: rgba(0, 0, 0, 0.04); + border-radius: 3px; + &::before, &::after { + letter-spacing: -0.2em; + content: "\00a0"; + } + } + + pre { + padding: 16px; + overflow: auto; + font-size: 85%; + line-height: 1.45; + background-color: #f7f7f7; + border-radius: 3px; + + code { + display: inline; + padding: 0; + margin: 0; + overflow: visible; + line-height: inherit; + word-wrap: normal; + background-color: transparent; + border: 0; + &::before, &::after { + content: none; + } + } + + > code { + padding: 0; + margin: 0; + font-size: 100%; + word-break: normal; + white-space: pre; + background: transparent; + border: 0; + } + } + + a { + color: #4078c0; + text-decoration: none; + &:hover { + text-decoration: underline; + } + } + & > *:first-child { + margin-top: 0 !important; + } + & > *:last-child { + margin-bottom: 0 !important; + } + &::after { + display: table; + clear: both; + content: ""; + } + + table { + display: block; + width: 100%; + border-spacing: 0; + border-collapse: collapse; + thead { + th { + font-weight: bold; + } + } + th, td { + padding: 6px 13px; + border: 1px solid $mde-border-color; + } + } + } +} + +:global(.react-mde) { + border: 1px solid $mde-border-color; + border-radius: $mde-border-radius; + + * { + box-sizing: border-box; + } + + :global(.invisible) { + display: none; + } + + :global(.image-tip) { + user-select: none; + display: flex !important; + padding: 7px 10px; + margin: 0; + font-size: 13px; + line-height: 16px; + color: gray; + background-color: $mde-toolbar-color; + border-top: 1px solid $mde-border-color; + position: relative; + + :global(.image-input) { + min-height: 0; + opacity: .01; + width: 100% !important; + position: absolute; + top: 0; + left: 0; + padding: 5px; + cursor: pointer; + } + } +} + +ul:global(.mde-suggestions) { + position: absolute; + min-width: 180px; + padding: 0; + margin: 20px 0 0; + list-style: none; + cursor: pointer; + background: #fff; + border: 1px solid $mde-border-color; + border-radius: 3px; + box-shadow: 0 1px 5px rgba(27, 31, 35, .15); + background-color: blue !important; + + li { + padding: 4px 8px; + border-bottom: 1px solid #e1e4e8; + + &:first-child { + border-top-left-radius: $mde-border-radius; + border-top-right-radius: $mde-border-radius; + } + + &:last-child { + border-bottom-right-radius: $mde-border-radius; + border-bottom-left-radius: $mde-border-radius; + } + + &:hover, &[aria-selected=true] { + color: $mde-white-color; + background-color: $mde-selected-color; + } + } +} + +:global(sub) { + vertical-align: sub; +} +:global(sup) { + vertical-align: super; +} \ No newline at end of file diff --git a/src/config-schema.ts b/src/config-schema.ts index 07789090..fe479ebc 100644 --- a/src/config-schema.ts +++ b/src/config-schema.ts @@ -41,6 +41,7 @@ export const configSchema = { 'textarea', 'ui-select-extended', 'toggle', + 'markdown' ], }, showSchemaSaveWarning: { diff --git a/src/types.ts b/src/types.ts index 16718ed7..98086aef 100644 --- a/src/types.ts +++ b/src/types.ts @@ -62,7 +62,8 @@ export interface Schema { isExpanded: string; questions: Array<{ id: string; - label: string; + label?: string; + value?:string; type: string; required?: string | boolean | RequiredFieldProps; questionOptions: { @@ -115,7 +116,8 @@ export interface Section { export interface Question { id: string; - label: string; + label?: string; + value?: string; type: string; questionOptions: QuestionOptions; datePickerFormat?: DatePickerType;