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:
- "
- Data should relate to all sites in England, Scotland, Wales and Northern Ireland unless otherwise stated.
- You can provide info estimates if actual figures are not available.
- We will treat your data securely and confidentially.
",
+ "- Data should relate to all sites in England, Scotland, Wales and Northern Ireland unless otherwise stated.
- You can provide info estimates if actual figures are not available.
- We will treat your data securely and confidentially.
",
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"