diff --git a/eq-author-api/utils/createQuestionnaireIntroduction.js b/eq-author-api/utils/createQuestionnaireIntroduction.js index dd3a3f5bfd..4ff9cc5371 100644 --- a/eq-author-api/utils/createQuestionnaireIntroduction.js +++ b/eq-author-api/utils/createQuestionnaireIntroduction.js @@ -23,7 +23,7 @@ module.exports = (metadata) => { additionalGuidancePanelSwitch: false, additionalGuidancePanel: "", description: - "", + "", legalBasis: NOTICE_1, // TODO: previewQuestions previewQuestions: false, diff --git a/eq-author/package.json b/eq-author/package.json index 2e3b4adf90..1562f6ab95 100644 --- a/eq-author/package.json +++ b/eq-author/package.json @@ -107,6 +107,7 @@ "draft-js-block-breakout-plugin": "latest", "draft-js-plugins-editor": "latest", "draft-js-raw-content-state": "latest", + "draft-js-import-html": "latest", "draftjs-filters": "^2.5.0", "firebase": "latest", "firebaseui": "latest", diff --git a/eq-author/src/components/AnswerTypeSelector/AnswerTypeButton.js b/eq-author/src/components/AnswerTypeSelector/AnswerTypeButton.js index 9f77cadfb9..dcb4a48180 100644 --- a/eq-author/src/components/AnswerTypeSelector/AnswerTypeButton.js +++ b/eq-author/src/components/AnswerTypeSelector/AnswerTypeButton.js @@ -46,6 +46,7 @@ export default class AnswerTypeButton extends React.Component { doNotShowDR: PropTypes.bool, mutuallyExclusiveEnabled: PropTypes.bool, radioEnabled: PropTypes.bool, + selectEnabled: PropTypes.bool, }; handleClick = () => { @@ -58,6 +59,7 @@ export default class AnswerTypeButton extends React.Component { doNotShowDR={this.props.doNotShowDR} mutuallyExclusiveEnabled={this.props.mutuallyExclusiveEnabled} radioEnabled={this.props.radioEnabled} + selectEnabled={this.props.selectEnabled} disabled={this.props.disabled} iconSrc={icons[this.props.type]} onClick={this.handleClick} diff --git a/eq-author/src/components/AnswerTypeSelector/AnswerTypeGrid.js b/eq-author/src/components/AnswerTypeSelector/AnswerTypeGrid.js index e52dce2104..1169985edd 100644 --- a/eq-author/src/components/AnswerTypeSelector/AnswerTypeGrid.js +++ b/eq-author/src/components/AnswerTypeSelector/AnswerTypeGrid.js @@ -73,6 +73,7 @@ class AnswerTypeGrid extends React.Component { doNotShowDR: PropTypes.bool, mutuallyExclusiveEnabled: PropTypes.bool, radioEnabled: PropTypes.bool, + selectEnabled: PropTypes.bool, }; handleSelect = (type) => { @@ -90,6 +91,7 @@ class AnswerTypeGrid extends React.Component { doNotShowDR, mutuallyExclusiveEnabled, radioEnabled, + selectEnabled, ...otherProps } = this.props; return ( @@ -111,6 +113,7 @@ class AnswerTypeGrid extends React.Component { doNotShowDR={doNotShowDR} mutuallyExclusiveEnabled={mutuallyExclusiveEnabled} radioEnabled={radioEnabled} + selectEnabled={selectEnabled} {...props} /> ); diff --git a/eq-author/src/components/AnswerTypeSelector/index.js b/eq-author/src/components/AnswerTypeSelector/index.js index 7fd65fea5b..02f463116d 100644 --- a/eq-author/src/components/AnswerTypeSelector/index.js +++ b/eq-author/src/components/AnswerTypeSelector/index.js @@ -7,7 +7,7 @@ import IconText from "components/IconText"; import Button from "components/buttons/Button"; import ValidationError from "components/ValidationError"; import { QUESTION_ANSWER_NOT_SELECTED } from "constants/validationMessages"; -import { RADIO, MUTUALLY_EXCLUSIVE } from "constants/answer-types"; +import { RADIO, MUTUALLY_EXCLUSIVE, SELECT } from "constants/answer-types"; import answersHaveAnswerType from "utils/answersHaveAnswerType"; @@ -49,7 +49,7 @@ const ErrorContext = styled.div` `} `; -const mutuallyExclusiveEnabled = (answers, hasRadioAnswer) => { +const mutuallyExclusiveEnabled = (answers, hasRadioAnswer, hasSelectAnswer) => { let allowMutuallyExclusive = false; // Mutually exclusive button will be disabled when page has no answers, page has a radio answer, or page already has mutually exclusive answer // Does not need to handle date range as "Add an answer" button is disabled when page has a date range answer @@ -57,6 +57,7 @@ const mutuallyExclusiveEnabled = (answers, hasRadioAnswer) => { answers.length === 0 || !answers || hasRadioAnswer || + hasSelectAnswer || answersHaveAnswerType(answers, MUTUALLY_EXCLUSIVE) || answers.length > 1 // TODO: (Mutually exclusive) When Runner supports multiple answers with mutually exclusive, answers.length > 1 can be removed ) { @@ -101,6 +102,7 @@ class AnswerTypeSelector extends React.Component { let hasDateRange = false; let hasOtherAnswerType = false; let hasRadioAnswer = false; + let hasSelectAnswer = false; let hasMutuallyExclusiveAnswer = false; const answers = Array.from(this.props.page.answers); @@ -118,6 +120,9 @@ class AnswerTypeSelector extends React.Component { if (answersHaveAnswerType(this.props.page.answers, RADIO)) { hasRadioAnswer = true; } + if (answersHaveAnswerType(this.props.page.answers, SELECT)) { + hasSelectAnswer = true; + } if (answersHaveAnswerType(this.props.page.answers, MUTUALLY_EXCLUSIVE)) { hasMutuallyExclusiveAnswer = true; } @@ -159,9 +164,11 @@ class AnswerTypeSelector extends React.Component { doNotShowDR={hasOtherAnswerType} mutuallyExclusiveEnabled={mutuallyExclusiveEnabled( this.props.page.answers, - hasRadioAnswer + hasRadioAnswer, + hasSelectAnswer )} radioEnabled={!hasMutuallyExclusiveAnswer} + selectEnabled={!hasMutuallyExclusiveAnswer} /> diff --git a/eq-author/src/components/AnswerTypeSelector/index.test.js b/eq-author/src/components/AnswerTypeSelector/index.test.js index 5d62efe76c..edd044917d 100644 --- a/eq-author/src/components/AnswerTypeSelector/index.test.js +++ b/eq-author/src/components/AnswerTypeSelector/index.test.js @@ -3,6 +3,7 @@ import { NUMBER, CURRENCY, RADIO, + SELECT, // MUTUALLY_EXCLUSIVE, } from "constants/answer-types"; @@ -14,7 +15,6 @@ import { } from "tests/utils/rtl"; import AnswerTypeSelector from "."; - describe("Answer Type Selector", () => { let props; beforeEach(() => { @@ -161,6 +161,17 @@ describe("Answer Type Selector", () => { ); }); + it("should disable mutually exclusive if there is a select answer", () => { + props.page.answers[0] = { type: SELECT }; + const { getByText, getByTestId } = render( + + ); + fireEvent.click(getByText(/Add another answer/)); + expect(getByTestId("btn-answer-type-mutuallyexclusive")).toHaveAttribute( + "disabled" + ); + }); + // TODO: (Mutually exclusive) When Runner supports multiple answers with mutually exclusive, the commented tests and MUTUALLY_EXCLUSIVE import can be uncommented // it("should disable radio if there is a mutually exclusive answer", () => { // props.page.answers = [{ type: NUMBER }, { type: MUTUALLY_EXCLUSIVE }]; diff --git a/eq-author/src/components/IconGrid/IconGridButton.js b/eq-author/src/components/IconGrid/IconGridButton.js index 4229b47f1d..4975e503cb 100644 --- a/eq-author/src/components/IconGrid/IconGridButton.js +++ b/eq-author/src/components/IconGrid/IconGridButton.js @@ -52,12 +52,14 @@ const IconGridButton = ({ doNotShowDR, mutuallyExclusiveEnabled, radioEnabled, + selectEnabled, ...otherProps }) => { if ( (doNotShowDR && title === "Date range") || (!mutuallyExclusiveEnabled && title === "OR answer") || - (!radioEnabled && title === "Radio") + (!radioEnabled && title === "Radio") || + (!selectEnabled && title === "Select") ) { disabled = true; } @@ -87,6 +89,7 @@ IconGridButton.propTypes = { doNotShowDR: PropTypes.bool, mutuallyExclusiveEnabled: PropTypes.bool, radioEnabled: PropTypes.bool, + selectEnabled: PropTypes.bool, }; export default IconGridButton; diff --git a/eq-author/src/components/RichTextEditor/index.js b/eq-author/src/components/RichTextEditor/index.js index d64efb2ef0..ff62438798 100644 --- a/eq-author/src/components/RichTextEditor/index.js +++ b/eq-author/src/components/RichTextEditor/index.js @@ -3,6 +3,7 @@ import PropTypes from "prop-types"; import styled, { css } from "styled-components"; import Editor from "draft-js-plugins-editor"; import { EditorState, RichUtils, Modifier, CompositeDecorator } from "draft-js"; +import { stateFromHTML } from "draft-js-import-html"; import "draft-js/dist/Draft.css"; import createBlockBreakoutPlugin from "draft-js-block-breakout-plugin"; @@ -27,9 +28,7 @@ import { sharedStyles } from "components/Forms/css"; import { Field, Label } from "components/Forms"; import ValidationError from "components/ValidationError"; -import PasteModal, { - preserveRichFormatting, -} from "components/modals/PasteModal"; +import PasteModal from "components/modals/PasteModal"; import { colors } from "../../constants/theme"; @@ -395,12 +394,12 @@ class RichTextEditor extends React.Component { state = { showPasteModal: false, text: "", multiline: false }; - handlePaste = (text) => { + handlePaste = (text, html) => { if (/\s{2,}/g.test(text)) { this.setState({ showPasteModal: true, multiline: false, - text: text, + text: html || text, }); } else { this.handleChange( @@ -418,12 +417,12 @@ class RichTextEditor extends React.Component { return "handled"; }; - handlePasteMultiline = (text) => { + handlePasteMultiline = (text, html) => { if (/\s{2,}/g.test(text)) { this.setState({ showPasteModal: true, multiline: true, - text: text, + text: html || text, }); return "handled"; } else { @@ -436,27 +435,76 @@ class RichTextEditor extends React.Component { const currentContent = editorState.getCurrentContent(); const currentSelection = editorState.getSelection(); - let modifiedText; + let newEditorState; + let processedText = text; + + // Simple HTML sanitization function + const sanitizeHtml = (html) => { + const doc = new DOMParser().parseFromString(html, "text/html"); + return doc.body.innerHTML; + }; + + // Sanitize the input HTML + const sanitizedHtml = sanitizeHtml(processedText); if (multiline) { - modifiedText = preserveRichFormatting(text); - } else { - modifiedText = text.replace(/\n/g, " ").trim().replace(/\s+/g, " "); - } + // Process the text to remove multiple spaces + const div = document.createElement("div"); + div.innerHTML = sanitizedHtml; + const walker = document.createTreeWalker( + div, + NodeFilter.SHOW_TEXT, + null, + false + ); + while (walker.nextNode()) { + walker.currentNode.nodeValue = walker.currentNode.nodeValue.replace( + /\s+/g, + " " + ); + } + processedText = div.innerHTML; - // Replace the selected text with the pasted content - const newContentState = Modifier.replaceText( - currentContent, - currentSelection, - modifiedText - ); + // Convert processed text from HTML to ContentState + const contentState = stateFromHTML(processedText); + const fragment = contentState.getBlockMap(); - // Create a new EditorState with the updated content - const newEditorState = EditorState.push( - editorState, - newContentState, - "insert-characters" - ); + // Replace the selected text with the pasted content + const newContentState = Modifier.replaceWithFragment( + currentContent, + currentSelection, + fragment + ); + + // Create a new EditorState with the updated content + newEditorState = EditorState.push( + editorState, + newContentState, + "insert-characters" + ); + } else { + // For single line pastes, replace multiple spaces with a single space + processedText = processedText + .replace(/\n/g, " ") + .replace(/\s+/g, " ") + .trim(); + const contentState = stateFromHTML(processedText); + const fragment = contentState.getBlockMap(); + + // Replace the selected text with the pasted content + const newContentState = Modifier.replaceWithFragment( + currentContent, + currentSelection, + fragment + ); + + // Create a new EditorState with the updated content + newEditorState = EditorState.push( + editorState, + newContentState, + "insert-characters" + ); + } // Set the new editor state and close the paste modal this.setState({ diff --git a/eq-author/src/components/modals/PasteModal/index.js b/eq-author/src/components/modals/PasteModal/index.js index 4e0ab19885..bb1ba1ab99 100644 --- a/eq-author/src/components/modals/PasteModal/index.js +++ b/eq-author/src/components/modals/PasteModal/index.js @@ -5,19 +5,6 @@ import PropTypes from "prop-types"; const Message = styled.div``; -export const preserveRichFormatting = (text) => { - // Replace multiple spaces and tabs with a single space - let formattedText = text.replace(/[ \t]+/g, " "); - - // Split the text into lines - let lines = formattedText.split(/\r?\n/); - - // Remove leading and trailing spaces from each line and join them back with newline characters - formattedText = lines.map((line) => line.trim()).join("\n"); - - return formattedText; -}; - const ModalWrapper = styled.div` .modal-button-container { margin-top: 1em; diff --git a/eq-author/src/components/modals/PasteModal/index.test.js b/eq-author/src/components/modals/PasteModal/index.test.js index 11f7631f15..1f3b70e8b5 100644 --- a/eq-author/src/components/modals/PasteModal/index.test.js +++ b/eq-author/src/components/modals/PasteModal/index.test.js @@ -1,27 +1,11 @@ import React from "react"; import { render, fireEvent } from "tests/utils/rtl"; -import PasteModal, { preserveRichFormatting } from "."; +import PasteModal from "."; import { keyCodes } from "constants/keyCodes"; const { Escape } = keyCodes; -describe("preserveRichFormatting function", () => { - it("should replace multiple spaces and tabs with a single space", () => { - const inputText = " Hello \t\t World "; - const expectedOutput = "Hello World"; - const result = preserveRichFormatting(inputText); - expect(result).toBe(expectedOutput); - }); - - it("should remove leading and trailing spaces from each line", () => { - const inputText = " Line 1 \n Line 2 \n Line 3 "; - const expectedOutput = "Line 1\nLine 2\nLine 3"; - const result = preserveRichFormatting(inputText); - expect(result).toBe(expectedOutput); - }); -}); - describe("PasteModal", () => { let onConfirm, onCancel; onConfirm = jest.fn(); diff --git a/eq-author/yarn.lock b/eq-author/yarn.lock index 7301bd4997..fabdcc8a4f 100644 --- a/eq-author/yarn.lock +++ b/eq-author/yarn.lock @@ -7331,6 +7331,21 @@ draft-js-block-breakout-plugin@latest: dependencies: immutable "~3.7.4" +draft-js-import-element@^1.4.0: + version "1.4.0" + resolved "https://registry.yarnpkg.com/draft-js-import-element/-/draft-js-import-element-1.4.0.tgz#8760acbfeb60ed824a1c8319ec049f702681df66" + integrity sha512-WmYT5PrCm47lGL5FkH6sRO3TTAcn7qNHsD3igiPqLG/RXrqyKrqN4+wBgbcT2lhna/yfWTRtgzAbQsSJoS1Meg== + dependencies: + draft-js-utils "^1.4.0" + synthetic-dom "^1.4.0" + +draft-js-import-html@latest: + version "1.4.1" + resolved "https://registry.yarnpkg.com/draft-js-import-html/-/draft-js-import-html-1.4.1.tgz#c222a3a40ab27dee5874fcf78526b64734fe6ea4" + integrity sha512-KOZmtgxZriCDgg5Smr3Y09TjubvXe7rHPy/2fuLSsL+aSzwUDwH/aHDA/k47U+WfpmL4qgyg4oZhqx9TYJV0tg== + dependencies: + draft-js-import-element "^1.4.0" + draft-js-plugins-editor@latest: version "3.0.0" resolved "https://registry.yarnpkg.com/draft-js-plugins-editor/-/draft-js-plugins-editor-3.0.0.tgz#196d1e065e2c29faebaab4ec081b734fdef294a2" @@ -7346,6 +7361,11 @@ draft-js-raw-content-state@latest: dependencies: draft-js "^0.10.5" +draft-js-utils@^1.4.0: + version "1.4.1" + resolved "https://registry.yarnpkg.com/draft-js-utils/-/draft-js-utils-1.4.1.tgz#a59c792ad621f7050292031a237d524708a6d509" + integrity sha512-xE81Y+z/muC5D5z9qWmKfxEW1XyXfsBzSbSBk2JRsoD0yzMGGHQm/0MtuqHl/EUDkaBJJLjJ2EACycoDMY/OOg== + draft-js@^0.10.5: version "0.10.5" resolved "https://registry.yarnpkg.com/draft-js/-/draft-js-0.10.5.tgz#bfa9beb018fe0533dbb08d6675c371a6b08fa742" @@ -16753,6 +16773,11 @@ synchronous-promise@latest: resolved "https://registry.yarnpkg.com/synchronous-promise/-/synchronous-promise-2.0.15.tgz#07ca1822b9de0001f5ff73595f3d08c4f720eb8e" integrity sha512-k8uzYIkIVwmT+TcglpdN50pS2y1BDcUnBPK9iJeGu0Pl1lOI8pD6wtzgw91Pjpe+RxtTncw32tLxs/R0yNL2Mg== +synthetic-dom@^1.4.0: + version "1.4.0" + resolved "https://registry.yarnpkg.com/synthetic-dom/-/synthetic-dom-1.4.0.tgz#d988d7a4652458e2fc8706a875417af913e4dd34" + integrity sha512-mHv51ZsmZ+ShT/4s5kg+MGUIhY7Ltq4v03xpN1c8T1Krb5pScsh/lzEjyhrVD0soVDbThbd2e+4dD9vnDG4rhg== + tabbable@^6.0.0: version "6.0.0" resolved "https://registry.yarnpkg.com/tabbable/-/tabbable-6.0.0.tgz#7f95ea69134e9335979092ba63866fe67b521b01"