diff --git a/eq-author-api/schema/typeDefs.js b/eq-author-api/schema/typeDefs.js index 63957c5d31..8c348f761b 100644 --- a/eq-author-api/schema/typeDefs.js +++ b/eq-author-api/schema/typeDefs.js @@ -425,6 +425,7 @@ type BasicAnswer implements Answer { mutuallyExclusiveOption: Option repeatingLabelAndInput: Boolean repeatingLabelAndInputListId: ID + limitCharacter: Boolean } type MultipleChoiceAnswer implements Answer { @@ -1533,6 +1534,7 @@ input UpdateAnswerInput { defaultAnswer: Boolean repeatingLabelAndInput: Boolean repeatingLabelAndInputListId: ID + limitCharacter: Boolean } input UpdateAnswersOfTypeInput { diff --git a/eq-author-api/src/validation/schemas/answer.json b/eq-author-api/src/validation/schemas/answer.json index 6e320a2487..156f018ca5 100644 --- a/eq-author-api/src/validation/schemas/answer.json +++ b/eq-author-api/src/validation/schemas/answer.json @@ -99,6 +99,30 @@ "then": { "$ref": "#/definitions/dateRangeAnswer" } + }, + { + "if": { + "properties": { + "type": { + "const": "TextField" + } + } + }, + "then": { + "$ref": "#/definitions/textFieldAnswer" + } + }, + { + "if": { + "properties": { + "type": { + "const": "TextArea" + } + } + }, + "then": { + "$ref": "#/definitions/textAreaAnswer" + } } ], "definitions": { @@ -296,18 +320,56 @@ } ] }, - "properties": { + "textFieldAnswer": { + "type": "object", + "if": { + "properties": { + "limitCharacter": { + "const": true + } + } + }, + "then": { + "properties": { + "properties": { + "type": "object", + "properties": { + "maxLength": { + "type": "number", + "minimum": 8, + "maximum": 100, + "errorMessage": { + "minimum": "ERR_MAX_LENGTH_TOO_SMALL", + "maximum": "ERR_MAX_LENGTH_TOO_LARGE" + } + } + } + } + } + } + }, + "textAreaAnswer": { "type": "object", "properties": { - "maxLength": { - "type": "number", - "minimum": 10, - "maximum": 2000, - "errorMessage": { - "minimum": "ERR_MAX_LENGTH_TOO_SMALL", - "maximum": "ERR_MAX_LENGTH_TOO_LARGE" + "properties": { + "type": "object", + "properties": { + "maxLength": { + "type": "number", + "minimum": 10, + "maximum": 2000, + "errorMessage": { + "minimum": "ERR_MAX_LENGTH_TOO_SMALL", + "maximum": "ERR_MAX_LENGTH_TOO_LARGE" + } + } } - }, + } + } + }, + "properties": { + "type": "object", + "properties": { "unit": { "$ref": "definitions.json#/definitions/populatedString" } diff --git a/eq-author-api/utils/defaultAnswerProperties.js b/eq-author-api/utils/defaultAnswerProperties.js index 62e046fa50..0748822663 100644 --- a/eq-author-api/utils/defaultAnswerProperties.js +++ b/eq-author-api/utils/defaultAnswerProperties.js @@ -8,6 +8,7 @@ const { UNIT, DURATION, TEXTAREA, + TEXTFIELD, } = require("../constants/answerTypes"); const defaultAnswerPropertiesMap = { @@ -18,6 +19,7 @@ const defaultAnswerPropertiesMap = { [UNIT]: { required: false, decimals: 0, unit: "" }, [DURATION]: { required: false, unit: "YearsMonths" }, [TEXTAREA]: { required: false, maxLength: 2000 }, + [TEXTFIELD]: { required: false, maxLength: 100 }, }; module.exports = (type) => diff --git a/eq-author/src/App/page/Design/answers/BasicAnswer/BasicAnswer.test.js b/eq-author/src/App/page/Design/answers/BasicAnswer/BasicAnswer.test.js index 70a045635c..8c503c1a3c 100644 --- a/eq-author/src/App/page/Design/answers/BasicAnswer/BasicAnswer.test.js +++ b/eq-author/src/App/page/Design/answers/BasicAnswer/BasicAnswer.test.js @@ -54,6 +54,9 @@ describe("BasicAnswer", () => { description: "option description", }, ], + validationErrorInfo: { + errors: [], + }, }; onChange = jest.fn(); onUpdate = jest.fn(); diff --git a/eq-author/src/App/page/Design/answers/BasicAnswer/__snapshots__/BasicAnswer.test.js.snap b/eq-author/src/App/page/Design/answers/BasicAnswer/__snapshots__/BasicAnswer.test.js.snap index dadb53ae7c..ae621c1c37 100644 --- a/eq-author/src/App/page/Design/answers/BasicAnswer/__snapshots__/BasicAnswer.test.js.snap +++ b/eq-author/src/App/page/Design/answers/BasicAnswer/__snapshots__/BasicAnswer.test.js.snap @@ -20,6 +20,7 @@ exports[`BasicAnswer should render with description 1`] = ` } } data-test="txt-answer-label" + hasLabelErrors={false} id="answer-label-ansID1" label="Label" listId={null} @@ -70,6 +71,9 @@ exports[`BasicAnswer should render with description 1`] = ` }, "title": "Answer title", "type": "TextField", + "validationErrorInfo": Object { + "errors": Array [], + }, } } page={ @@ -108,6 +112,9 @@ exports[`BasicAnswer should render with description 1`] = ` }, "title": "Answer title", "type": "TextField", + "validationErrorInfo": Object { + "errors": Array [], + }, } } enableHorizontalRule={true} @@ -136,6 +143,7 @@ exports[`BasicAnswer should render without description 1`] = ` } } data-test="txt-answer-label" + hasLabelErrors={false} id="answer-label-ansID1" label="Label" listId={null} @@ -165,6 +173,9 @@ exports[`BasicAnswer should render without description 1`] = ` }, "title": "Answer title", "type": "TextField", + "validationErrorInfo": Object { + "errors": Array [], + }, } } page={ @@ -203,6 +214,9 @@ exports[`BasicAnswer should render without description 1`] = ` }, "title": "Answer title", "type": "TextField", + "validationErrorInfo": Object { + "errors": Array [], + }, } } enableHorizontalRule={true} diff --git a/eq-author/src/App/page/Design/answers/BasicAnswer/index.js b/eq-author/src/App/page/Design/answers/BasicAnswer/index.js index 513f1f29b0..f2b766905a 100644 --- a/eq-author/src/App/page/Design/answers/BasicAnswer/index.js +++ b/eq-author/src/App/page/Design/answers/BasicAnswer/index.js @@ -270,6 +270,7 @@ StatelessBasicAnswer.fragments = { BasicAnswer: gql` fragment BasicAnswer on BasicAnswer { repeatingLabelAndInput + limitCharacter repeatingLabelAndInputListId options { id diff --git a/eq-author/src/components/AnswerContent/AnswerProperties/TextAreaProperties.js b/eq-author/src/components/AnswerContent/AnswerProperties/TextAreaProperties.js index 5a5157cac8..edb07e765d 100644 --- a/eq-author/src/components/AnswerContent/AnswerProperties/TextAreaProperties.js +++ b/eq-author/src/components/AnswerContent/AnswerProperties/TextAreaProperties.js @@ -49,7 +49,10 @@ const TextAreaProperties = ({ return ( <> - + onUpdateMaxLength(maxLength)} onChange={({ value }) => setMaxLength(value)} - max={2000} invalid={errors.length > 0} data-test="maxCharacterInput" /> diff --git a/eq-author/src/components/AnswerContent/AnswerProperties/TextFieldProperties.js b/eq-author/src/components/AnswerContent/AnswerProperties/TextFieldProperties.js index c82b292b45..a95fb1e8e7 100644 --- a/eq-author/src/components/AnswerContent/AnswerProperties/TextFieldProperties.js +++ b/eq-author/src/components/AnswerContent/AnswerProperties/TextFieldProperties.js @@ -1,23 +1,104 @@ -import React from "react"; +import React, { useState, useEffect } from "react"; import PropTypes from "prop-types"; +import styled from "styled-components"; + import Required from "components/AnswerContent/Required"; +import InlineField from "components/AnswerContent/Format/InlineField"; +import CollapsibleToggled from "components/CollapsibleToggled"; +import Number, { NumberInput } from "components/Forms/Number"; +import ValidationError from "components/ValidationError"; + +import { radius } from "constants/theme"; +import { textFieldErrors } from "constants/validationMessages"; + +const SmallNumber = styled(Number)` + width: 7em; + margin-left: 0; + + ${NumberInput} { + border-radius: ${radius}; + padding: 0.25em 0.5em; + } +`; const TextFieldProperties = ({ answer, updateAnswer, hasMutuallyExclusiveAnswer, -}) => ( - -); +}) => { + const [maxLength, setMaxLength] = useState(answer.properties.maxLength); + useEffect(() => { + setMaxLength(answer.properties.maxLength); + }, [answer.properties.maxLength]); + + const errors = answer.validationErrorInfo.errors.filter( + (error) => error.field === "maxLength" + ); + + const onUpdateMaxLength = (value) => { + const newValue = value === null ? 100 : value; + updateAnswer({ + variables: { + input: { + id: answer.id, + properties: { ...answer.properties, maxLength: newValue }, + }, + }, + }); + setMaxLength(newValue); + }; + + return ( + <> + + updateAnswer({ + variables: { + input: { id: answer.id, limitCharacter: value }, + }, + }) + } + isOpen={answer.limitCharacter} + > + + onUpdateMaxLength(maxLength)} + onChange={({ value }) => setMaxLength(value)} + data-test="limitCharacterInput" + /> + + {errors.length > 0 && ( + + {textFieldErrors[errors[0].errorCode].message} + + )} + + + + + ); +}; TextFieldProperties.propTypes = { updateAnswer: PropTypes.func, answer: PropTypes.object, //eslint-disable-line hasMutuallyExclusiveAnswer: PropTypes.bool, + limitCharacter: PropTypes.bool, }; export default TextFieldProperties; diff --git a/eq-author/src/components/AnswerContent/AnswerProperties/TextFieldProperties.test.js b/eq-author/src/components/AnswerContent/AnswerProperties/TextFieldProperties.test.js new file mode 100644 index 0000000000..ca510422a1 --- /dev/null +++ b/eq-author/src/components/AnswerContent/AnswerProperties/TextFieldProperties.test.js @@ -0,0 +1,104 @@ +import React from "react"; +import { render, fireEvent, flushPromises } from "tests/utils/rtl"; +import TextFieldProperties from "./TextFieldProperties"; +import { act } from "react-dom/test-utils"; +import { useMutation } from "@apollo/react-hooks"; + +jest.mock("@apollo/react-hooks", () => ({ + useMutation: jest.fn(() => [() => null]), +})); + +const renderTextProperties = (props) => + render(); + +describe("TextField Property", () => { + let props; + const updateAnswer = jest.fn(); + beforeEach(() => { + props = { + answer: { + id: "1", + properties: { + required: false, + maxLength: 8, + }, + limitCharacter: true, + type: "TextField", + validationErrorInfo: { + errors: [], + }, + }, + updateAnswer, + }; + }); + + it("should set input box value to 100 when the textbox is cleared", async () => { + const { getByTestId } = renderTextProperties(props); + const inputBox = getByTestId("limitCharacterInput"); + + act(() => { + fireEvent.change(inputBox, { + target: { value: null }, + }); + }); + + expect(inputBox.value).toBe(""); + await act(async () => { + fireEvent.blur(inputBox); + await flushPromises(); + }); + expect(inputBox.value).toBe("100"); + }); + + it("should set input box value to 8", async () => { + const { getByTestId } = renderTextProperties(props); + const inputBox = getByTestId("limitCharacterInput"); + + act(() => { + fireEvent.change(inputBox, { + target: { value: "8" }, + }); + }); + + expect(inputBox.value).toBe("8"); + await act(async () => { + fireEvent.blur(inputBox); + await flushPromises(); + }); + expect(inputBox.value).toBe("8"); + }); + + it("should update limitCharacter when toggle-switch is clicked", () => { + const { getByTestId } = renderTextProperties(props); + useMutation.mockImplementation(jest.fn(() => [updateAnswer])); + + fireEvent.click(getByTestId("character-length-input")); + expect(updateAnswer).toHaveBeenCalled(); + }); + + it("should render minimum value message when there is ERR_MAX_LENGTH_TOO_SMALL error", () => { + props.answer.validationErrorInfo.errors[0] = { + errorCode: "ERR_MAX_LENGTH_TOO_SMALL", + field: "maxLength", + }; + props.answer.properties.maxLength = 5; + const { getByText } = renderTextProperties(props); + + expect( + getByText(/Enter a character limit greater than or equal to 8/) + ).toBeInTheDocument(); + }); + + it("should render maximum value message when there is ERR_MAX_LENGTH_TOO_LARGE error", () => { + props.answer.validationErrorInfo.errors[0] = { + errorCode: "ERR_MAX_LENGTH_TOO_LARGE", + field: "maxLength", + }; + props.answer.properties.maxLength = 105; + const { getByText } = renderTextProperties(props); + + expect( + getByText(/Enter a character limit less than or equal to 100/) + ).toBeInTheDocument(); + }); +}); diff --git a/eq-author/src/constants/validationMessages.js b/eq-author/src/constants/validationMessages.js index 08af755f93..5c8013063a 100644 --- a/eq-author/src/constants/validationMessages.js +++ b/eq-author/src/constants/validationMessages.js @@ -180,6 +180,17 @@ export const pageDescriptionErrors = { "The page description entered has already been used for another page. Enter a unique page description.", }; +export const textFieldErrors = { + ERR_MAX_LENGTH_TOO_LARGE: { + errorCode: "ERR_MAX_LENGTH_TOO_LARGE", + message: "Enter a character limit less than or equal to 100", + }, + ERR_MAX_LENGTH_TOO_SMALL: { + errorCode: "ERR_MAX_LENGTH_TOO_SMALL", + message: "Enter a character limit greater than or equal to 8", + }, +}; + export const textAreaErrors = { ERR_MAX_LENGTH_TOO_LARGE: { errorCode: "ERR_MAX_LENGTH_TOO_large", diff --git a/eq-author/src/graphql/createAnswer.graphql b/eq-author/src/graphql/createAnswer.graphql index 1f501b739a..1d83da335e 100644 --- a/eq-author/src/graphql/createAnswer.graphql +++ b/eq-author/src/graphql/createAnswer.graphql @@ -18,6 +18,7 @@ mutation createAnswer($input: CreateAnswerInput!) { ... on BasicAnswer { secondaryQCode repeatingLabelAndInput + limitCharacter repeatingLabelAndInputListId options { ...Option diff --git a/eq-author/src/graphql/updateAnswer.graphql b/eq-author/src/graphql/updateAnswer.graphql index f2863e921d..53b09ce554 100644 --- a/eq-author/src/graphql/updateAnswer.graphql +++ b/eq-author/src/graphql/updateAnswer.graphql @@ -10,6 +10,7 @@ mutation UpdateAnswer($input: UpdateAnswerInput!) { ... on BasicAnswer { secondaryQCode repeatingLabelAndInput + limitCharacter repeatingLabelAndInputListId validationErrorInfo { ...ValidationErrorInfo