diff --git a/eq-author-api/constants/legalBases.js b/eq-author-api/constants/legalBases.js new file mode 100644 index 0000000000..fde2957322 --- /dev/null +++ b/eq-author-api/constants/legalBases.js @@ -0,0 +1,3 @@ +module.exports.NOTICE_1 = "NOTICE_1"; +module.exports.NOTICE_2 = "NOTICE_2"; +module.exports.VOLUNTARY = "VOLUNTARY"; diff --git a/eq-author-api/db/models/DynamoDB.js b/eq-author-api/db/models/DynamoDB.js index 7e40865200..0ebfeb6afc 100644 --- a/eq-author-api/db/models/DynamoDB.js +++ b/eq-author-api/db/models/DynamoDB.js @@ -42,6 +42,9 @@ const baseQuestionnaireSchema = { shortTitle: { type: String, }, + introduction: { + type: Object, + }, }; const questionnanaireSchema = new dynamoose.Schema( diff --git a/eq-author-api/migrations/addBusinessQuestionnaireIntroduction.js b/eq-author-api/migrations/addBusinessQuestionnaireIntroduction.js new file mode 100644 index 0000000000..41a0d06c2c --- /dev/null +++ b/eq-author-api/migrations/addBusinessQuestionnaireIntroduction.js @@ -0,0 +1,32 @@ +//This is an auto-generated file. Do NOT modify the method signature. + +const { find } = require("lodash"); +const { BUSINESS } = require("../constants/questionnaireTypes"); +const { + createDefaultBusinessSurveyMetadata, +} = require("../utils/defaultMetadata"); +const { + createQuestionnaireIntroduction, +} = require("../schema/resolvers/questionnaireIntroduction"); + +module.exports = function addBusinessQuestionnaireIntroduction(questionnaire) { + if (questionnaire.type !== BUSINESS) { + return questionnaire; + } + + const defaultMetadata = createDefaultBusinessSurveyMetadata(); + const metadataToAdd = defaultMetadata + .filter(({ key }) => !find(questionnaire.metadata, { key })) + .map((md, index) => ({ + ...md, + id: `migrated-md-${index}`, + })); + questionnaire.metadata = [...questionnaire.metadata, ...metadataToAdd]; + + questionnaire.introduction = createQuestionnaireIntroduction( + questionnaire.metadata + ); + questionnaire.introduction.id = "questionnaire-introduction"; + + return questionnaire; +}; diff --git a/eq-author-api/migrations/addBusinessQuestionnaireIntroduction.test.js b/eq-author-api/migrations/addBusinessQuestionnaireIntroduction.test.js new file mode 100644 index 0000000000..8a731eac4f --- /dev/null +++ b/eq-author-api/migrations/addBusinessQuestionnaireIntroduction.test.js @@ -0,0 +1,57 @@ +const { cloneDeep } = require("lodash"); + +const { SOCIAL, BUSINESS } = require("../constants/questionnaireTypes"); + +const addBusinessQuestionnaireIntroduction = require("./addBusinessQuestionnaireIntroduction.js"); + +describe("addBusinessQuestionnaireIntroduction", () => { + it("should be deterministic", () => { + const questionnaire = { + type: BUSINESS, + metadata: [], + }; + + expect( + addBusinessQuestionnaireIntroduction(cloneDeep(questionnaire)) + ).toMatchObject( + addBusinessQuestionnaireIntroduction(cloneDeep(questionnaire)) + ); + }); + + it("should not touch social", () => { + const questionnaire = { + type: SOCIAL, + }; + + expect(addBusinessQuestionnaireIntroduction(questionnaire)).toEqual( + questionnaire + ); + }); + + it("should add missing default metadata", () => { + const questionnaire = { + type: BUSINESS, + metadata: [ + { + key: "ru_name", + alias: "Ru Name", + type: "Text", + value: "ESSENTIAL ENTERPRISE LTD.", + }, + ], + }; + + expect( + addBusinessQuestionnaireIntroduction(questionnaire).metadata.map( + md => md.key + ) + ).toEqual([ + "ru_name", + "trad_as", + "period_id", + "ref_p_start_date", + "ref_p_end_date", + "employmentDate", + ]); + }); +}); diff --git a/eq-author-api/migrations/index.js b/eq-author-api/migrations/index.js index 5116d6b226..bf178f2b20 100644 --- a/eq-author-api/migrations/index.js +++ b/eq-author-api/migrations/index.js @@ -2,12 +2,14 @@ const addVersion = require("./addVersion"); const addOptionalFieldProperties = require("./addOptionalFieldProperties"); const addQuestionnaireType = require("./addQuestionnaireType"); const updateMetadataValue = require("./updateMetadataValue"); +const addBusinessQuestionnaireIntroduction = require("./addBusinessQuestionnaireIntroduction"); const migrations = [ addVersion, addOptionalFieldProperties, addQuestionnaireType, updateMetadataValue, + addBusinessQuestionnaireIntroduction, ]; const currentVersion = migrations.length; diff --git a/eq-author-api/schema/resolvers/base.js b/eq-author-api/schema/resolvers/base.js index 5af7b458d1..4b569b6925 100644 --- a/eq-author-api/schema/resolvers/base.js +++ b/eq-author-api/schema/resolvers/base.js @@ -55,7 +55,7 @@ const { } = require("../../utils/datastore"); const { - defaultBusinessSurveyMetadata, + createDefaultBusinessSurveyMetadata, } = require("../../utils/defaultMetadata"); const { listQuestionnaires } = require("../../utils/datastore"); @@ -64,6 +64,11 @@ const { DATE, DATE_RANGE } = require("../../constants/answerTypes"); const { DATE: METADATA_DATE } = require("../../constants/metadataTypes"); const { VALIDATION_TYPES } = require("../../constants/validationTypes"); +const { + createQuestionnaireIntroduction, +} = require("./questionnaireIntroduction"); + + const getQuestionnaireList = () => { return listQuestionnaires(); }; @@ -131,22 +136,39 @@ const createSection = (input = {}) => ({ ...input, }); -const createNewQuestionnaire = input => ({ - id: uuid.v4(), - title: null, - description: null, - theme: "default", - legalBasis: "Voluntary", - navigation: false, - surveyId: "", - createdAt: new Date(), - metadata: input.type === BUSINESS ? defaultBusinessSurveyMetadata : [], - sections: [createSection()], - summary: false, - version: currentVersion, - shortTitle: "", - ...input, -}); +const createNewQuestionnaire = input => { + const defaultQuestionnaire = { + id: uuid.v4(), + title: null, + description: null, + theme: "default", + legalBasis: "Voluntary", + navigation: false, + surveyId: "", + createdAt: new Date(), + metadata: [], + sections: [createSection()], + summary: false, + version: currentVersion, + shortTitle: "", + introduction: null, + }; + + let changes = {}; + if (input.type === BUSINESS) { + const metadata = createDefaultBusinessSurveyMetadata(); + changes = { + metadata, + introduction: createQuestionnaireIntroduction(metadata), + }; + } + + return { + ...defaultQuestionnaire, + ...changes, + ...input, + }; +}; const Resolvers = { Query: { diff --git a/eq-author-api/schema/resolvers/index.js b/eq-author-api/schema/resolvers/index.js index 4984e95346..df2a65eab1 100644 --- a/eq-author-api/schema/resolvers/index.js +++ b/eq-author-api/schema/resolvers/index.js @@ -1,5 +1,6 @@ const base = require("./base"); const routing2 = require("./routing2"); const page = require("./pages"); +const questionnaireIntroduction = require("./questionnaireIntroduction"); -module.exports = [base, ...routing2, ...page]; +module.exports = [base, ...routing2, ...page, ...questionnaireIntroduction]; diff --git a/eq-author-api/schema/resolvers/questionnaireIntroduction.js b/eq-author-api/schema/resolvers/questionnaireIntroduction.js new file mode 100644 index 0000000000..b88d00cfca --- /dev/null +++ b/eq-author-api/schema/resolvers/questionnaireIntroduction.js @@ -0,0 +1,83 @@ +const { find, omit, first, remove } = require("lodash"); +const uuid = require("uuid").v4; + +const { saveQuestionnaire } = require("../../utils/datastore"); +const { NOTICE_1 } = require("../../constants/legalBases"); + +const createCollapsible = options => ({ + id: uuid(), + title: "", + description: "", + ...options, +}); + +const Resolvers = {}; +Resolvers.Query = { + questionnaireIntroduction: (root, args, ctx) => + ctx.questionnaire.introduction, +}; +Resolvers.Mutation = { + updateQuestionnaireIntroduction: async (_, { input }, ctx) => { + const introduction = ctx.questionnaire.introduction; + Object.assign(introduction, omit(input, "id")); + await saveQuestionnaire(ctx.questionnaire); + return introduction; + }, + createCollapsible: async (_, { input }, ctx) => { + const collapsible = createCollapsible(omit(input, "introductionId")); + ctx.questionnaire.introduction.collapsibles.push(collapsible); + await saveQuestionnaire(ctx.questionnaire); + return collapsible; + }, + updateCollapsible: async (_, { input: { id, ...rest } }, ctx) => { + const collapsible = ctx.questionnaire.introduction.collapsibles.find( + c => c.id === id + ); + Object.assign(collapsible, rest); + await saveQuestionnaire(ctx.questionnaire); + return collapsible; + }, + moveCollapsible: async (_, { input: { id, position } }, ctx) => { + const introduction = ctx.questionnaire.introduction; + const collapsibleMoving = first(remove(introduction.collapsibles, { id })); + introduction.collapsibles.splice(position, 0, collapsibleMoving); + await saveQuestionnaire(ctx.questionnaire); + return collapsibleMoving; + }, + deleteCollapsible: async (_, { input: { id } }, ctx) => { + const introduction = ctx.questionnaire.introduction; + remove(introduction.collapsibles, { id }); + await saveQuestionnaire(ctx.questionnaire); + return introduction; + }, +}; +Resolvers.QuestionnaireIntroduction = { + availablePipingMetadata: (root, args, ctx) => ctx.questionnaire.metadata, + availablePipingAnswers: () => [], +}; + +Resolvers.Collapsible = { + introduction: (root, args, ctx) => ctx.questionnaire.introduction, +}; + +module.exports = [Resolvers]; +module.exports.createQuestionnaireIntroduction = metadata => { + return { + id: uuid.v4(), + title: `

You are completing this for trad_as (ru_name)

`, + description: + "", + legalBasis: NOTICE_1, + secondaryTitle: "

Information you need

", + secondaryDescription: + "

You can select the dates of the period you are reporting for, if the given dates are not appropriate.

", + collapsibles: [], + tertiaryTitle: "

How we use your data

", + tertiaryDescription: + "", + }; +}; diff --git a/eq-author-api/schema/tests/collapsibles.test.js b/eq-author-api/schema/tests/collapsibles.test.js new file mode 100644 index 0000000000..4b680b2fc4 --- /dev/null +++ b/eq-author-api/schema/tests/collapsibles.test.js @@ -0,0 +1,174 @@ +const { + buildQuestionnaire, +} = require("../../tests/utils/questionnaireBuilder"); +const { + deleteQuestionnaire, +} = require("../../tests/utils/questionnaireBuilder/questionnaire"); +const { + queryCollapsible, + createCollapsible, + updateCollapsible, + moveCollapsible, + deleteCollapsible, +} = require("../../tests/utils/questionnaireBuilder/collapsible"); + +const { BUSINESS } = require("../../constants/questionnaireTypes"); + +describe("questionnaire", () => { + let questionnaire; + + afterEach(async () => { + await deleteQuestionnaire(questionnaire.id); + questionnaire = null; + }); + + describe("read", () => { + it("should return the collapsibles on the introduction", async () => { + questionnaire = await buildQuestionnaire({ + type: BUSINESS, + introduction: { + collapsibles: [ + { + title: "Collapsible title", + description: "Collapsible description", + }, + ], + }, + }); + + const collapsibles = await queryCollapsible( + questionnaire, + questionnaire.introduction.id + ); + expect(collapsibles).toEqual([ + { + id: expect.any(String), + title: "Collapsible title", + description: "Collapsible description", + introduction: { + id: questionnaire.introduction.id, + }, + }, + ]); + }); + }); + + describe("create", () => { + it("should add a collapsible", async () => { + questionnaire = await buildQuestionnaire({ type: BUSINESS }); + const collapsible = await createCollapsible(questionnaire, { + introductionId: questionnaire.introduction.id, + title: "Some title", + description: "Some description", + }); + expect(collapsible).toEqual({ + id: expect.any(String), + title: "Some title", + description: "Some description", + introduction: { + id: questionnaire.introduction.id, + }, + }); + }); + }); + + describe("update", () => { + it("should update the properties", async () => { + questionnaire = await buildQuestionnaire({ + type: BUSINESS, + introduction: { + collapsibles: [ + { + title: "Collapsible title", + description: "Collapsible description", + }, + ], + }, + }); + + const collapsible = await updateCollapsible(questionnaire, { + id: questionnaire.introduction.collapsibles[0].id, + title: "Some title", + description: "Some description", + }); + + expect(collapsible).toEqual({ + id: questionnaire.introduction.collapsibles[0].id, + title: "Some title", + description: "Some description", + }); + }); + }); + + describe("move", () => { + it("should move the collapsible forward", async () => { + questionnaire = await buildQuestionnaire({ + type: BUSINESS, + introduction: { + collapsibles: [{}, {}], + }, + }); + + const collapsibleIds = questionnaire.introduction.collapsibles.map( + c => c.id + ); + const [collapsible1Id, collapsible2Id] = collapsibleIds; + + const result = await moveCollapsible(questionnaire, { + id: collapsible2Id, + position: 0, + }); + expect(result.introduction.collapsibles.map(c => c.id)).toEqual([ + collapsible2Id, + collapsible1Id, + ]); + }); + + it("should move the collapsible backward", async () => { + questionnaire = await buildQuestionnaire({ + type: BUSINESS, + introduction: { + collapsibles: [{}, {}], + }, + }); + + const collapsibleIds = questionnaire.introduction.collapsibles.map( + c => c.id + ); + const [collapsible1Id, collapsible2Id] = collapsibleIds; + + const result = await moveCollapsible(questionnaire, { + id: collapsible1Id, + position: 1, + }); + expect(result.introduction.collapsibles.map(c => c.id)).toEqual([ + collapsible2Id, + collapsible1Id, + ]); + }); + }); + + describe("delete", () => { + it("should remove the collapsible", async () => { + questionnaire = await buildQuestionnaire({ + type: BUSINESS, + introduction: { + collapsibles: [{}, {}], + }, + }); + + const collapsibleIds = questionnaire.introduction.collapsibles.map( + c => c.id + ); + const [collapsible1Id, collapsible2Id] = collapsibleIds; + + const introduction = await deleteCollapsible(questionnaire, { + id: collapsible1Id, + }); + + expect(introduction.collapsibles.map(c => c.id)).toEqual([ + collapsible2Id, + ]); + }); + }); +}); diff --git a/eq-author-api/schema/tests/questionnaire.test.js b/eq-author-api/schema/tests/questionnaire.test.js index 582bfe46f6..fdd854cbf8 100644 --- a/eq-author-api/schema/tests/questionnaire.test.js +++ b/eq-author-api/schema/tests/questionnaire.test.js @@ -5,10 +5,8 @@ const { SOCIAL, BUSINESS } = require("../../constants/questionnaireTypes"); const { buildQuestionnaire, } = require("../../tests/utils/questionnaireBuilder"); -const executeQuery = require("../../tests/utils/executeQuery"); const { createQuestionnaire, - createQuestionnaireMutation, queryQuestionnaire, updateQuestionnaire, deleteQuestionnaire, @@ -34,22 +32,15 @@ describe("questionnaire", () => { description: "Description", surveyId: "1", theme: "default", - legalBasis: "Voluntary", navigation: false, summary: false, type: SOCIAL, shortTitle: "short title", }; - questionnaire = await createQuestionnaire(config); }); it("should create a questionnaire with a section and page", async () => { - const result = await executeQuery( - createQuestionnaireMutation, - { input: config }, - {} - ); - const questionnaire = result.data.createQuestionnaire; + const questionnaire = await createQuestionnaire(config); expect(questionnaire).toEqual( expect.objectContaining({ ...config, displayName: "short title" }) ); @@ -58,24 +49,29 @@ describe("questionnaire", () => { }); it("should create a questionnaire with no metadata when creating a social survey", async () => { - const result = await executeQuery( - createQuestionnaireMutation, - { input: config }, - {} - ); - const questionnaire = result.data.createQuestionnaire; + const questionnaire = await createQuestionnaire(config); expect(questionnaire.metadata).toEqual([]); }); it("should create a questionnaire with default business metadata when creating a business survey", async () => { - const result = await executeQuery( - createQuestionnaireMutation, - { input: { ...config, type: BUSINESS } }, - {} - ); - const questionnaire = result.data.createQuestionnaire; + const questionnaire = await createQuestionnaire({ + ...config, + type: BUSINESS, + }); expect(questionnaire.metadata).toHaveLength(6); }); + + it("should create a questionnaire introduction for business surveys", async () => { + const questionnaire = await createQuestionnaire({ + ...config, + type: BUSINESS, + }); + expect(questionnaire.introduction).toMatchObject({ + id: expect.any(String), + title: expect.any(String), + collapsibles: [], + }); + }); }); describe("mutate", () => { @@ -85,7 +81,6 @@ describe("questionnaire", () => { description: "Description", surveyId: "1", theme: "default", - legalBasis: "Voluntary", navigation: false, summary: false, metadata: [{}], @@ -96,7 +91,6 @@ describe("questionnaire", () => { title: "Questionnaire-updated", description: "Description-updated", theme: "census", - legalBasis: "StatisticsOfTradeAct", navigation: true, surveyId: "2-updated", summary: true, @@ -147,7 +141,6 @@ describe("questionnaire", () => { displayName: expect.any(String), description: expect.any(String), theme: expect.any(String), - legalBasis: expect.any(String), navigation: expect.any(Boolean), surveyId: expect.any(String), createdAt: expect.any(String), diff --git a/eq-author-api/schema/tests/questionnaireIntroduction.test.js b/eq-author-api/schema/tests/questionnaireIntroduction.test.js new file mode 100644 index 0000000000..04b9d0401f --- /dev/null +++ b/eq-author-api/schema/tests/questionnaireIntroduction.test.js @@ -0,0 +1,89 @@ +const { + buildQuestionnaire, +} = require("../../tests/utils/questionnaireBuilder"); +const { + deleteQuestionnaire, +} = require("../../tests/utils/questionnaireBuilder/questionnaire"); +const { + queryQuestionnaireIntroduction, + updateQuestionnaireIntroduction, +} = require("../../tests/utils/questionnaireBuilder/questionnaireIntroduction"); + +const { BUSINESS } = require("../../constants/questionnaireTypes"); +const { NOTICE_2 } = require("../../constants/legalBases"); + +describe("questionnaire", () => { + let questionnaire; + + beforeEach(async () => { + questionnaire = await buildQuestionnaire({ type: BUSINESS }); + }); + + afterEach(async () => { + await deleteQuestionnaire(questionnaire.id); + questionnaire = null; + }); + + describe("read", () => { + it("should return the questionnaire introduction", async () => { + const introduction = await queryQuestionnaireIntroduction( + questionnaire, + questionnaire.introduction.id + ); + expect(introduction).toEqual({ + id: expect.any(String), + title: expect.any(String), + description: expect.any(String), + secondaryTitle: expect.any(String), + secondaryDescription: expect.any(String), + collapsibles: expect.any(Array), + legalBasis: expect.any(String), + tertiaryTitle: expect.any(String), + tertiaryDescription: expect.any(String), + availablePipingAnswers: expect.any(Array), + availablePipingMetadata: expect.any(Array), + }); + }); + + it("should return the available piping metadata but no answers", async () => { + const introduction = await queryQuestionnaireIntroduction( + questionnaire, + questionnaire.introduction.id + ); + + expect(introduction.availablePipingAnswers).toEqual([]); + expect(introduction.availablePipingMetadata).not.toHaveLength(0); + expect(introduction.availablePipingMetadata.map(md => md.id)).toEqual( + questionnaire.metadata.map(md => md.id) + ); + }); + }); + + describe("update", () => { + it("should update the properties", async () => { + const changes = { + title: "new title", + description: "new description", + secondaryTitle: "new secondaryTitle", + secondaryDescription: "new secondaryDescription", + legalBasis: NOTICE_2, + tertiaryTitle: "new tertiaryTitle", + tertiaryDescription: "new tertiaryDescription", + }; + + const updatedIntroduction = await updateQuestionnaireIntroduction( + questionnaire, + { + id: questionnaire.introduction.id, + ...changes, + } + ); + + expect(updatedIntroduction).toEqual({ + id: questionnaire.introduction.id, + collapsibles: expect.any(Array), + ...changes, + }); + }); + }); +}); diff --git a/eq-author-api/schema/typeDefs.js b/eq-author-api/schema/typeDefs.js index f8adc970d6..98e5f2ab6a 100644 --- a/eq-author-api/schema/typeDefs.js +++ b/eq-author-api/schema/typeDefs.js @@ -29,7 +29,6 @@ type Questionnaire { title: String description: String theme: Theme - legalBasis: LegalBasis navigation: Boolean surveyId: String createdAt: Date @@ -41,6 +40,7 @@ type Questionnaire { type: QuestionnaireType! shortTitle: String displayName: String! + introduction: QuestionnaireIntroduction } type Section { @@ -321,11 +321,6 @@ enum AnswerType { Relationship } -enum LegalBasis { - Voluntary - StatisticsOfTradeAct -} - enum Theme { default census @@ -441,6 +436,33 @@ type BinaryExpression2 { expressionGroup: ExpressionGroup2! } +enum LegalBasis { + NOTICE_1 + NOTICE_2 + VOLUNTARY +} + +type Collapsible { + id: ID! + title: String! + description: String! + introduction: QuestionnaireIntroduction! +} + +type QuestionnaireIntroduction { + id: ID! + title: String! + description: String! + legalBasis: LegalBasis! + secondaryTitle: String! + secondaryDescription: String! + collapsibles: [Collapsible!]! + tertiaryTitle: String! + tertiaryDescription: String! + availablePipingAnswers: [Answer!]! + availablePipingMetadata: [Metadata!]! +} + type Query { questionnaires: [Questionnaire] questionnaire(input: QueryInput!): Questionnaire @@ -451,6 +473,7 @@ type Query { option(input: QueryInput!): Option pagesAffectedByDeletion(pageId: ID!): [Page]! @deprecated(reason: "Not implemented") questionConfirmation(id: ID!): QuestionConfirmation + questionnaireIntroduction(id: ID!): QuestionnaireIntroduction me: User! } @@ -508,6 +531,11 @@ type Mutation { updateLeftSide2(input: UpdateLeftSide2Input!): BinaryExpression2! updateRightSide2(input: UpdateRightSide2Input!): BinaryExpression2! deleteBinaryExpression2(input: DeleteBinaryExpression2Input!): ExpressionGroup2! + updateQuestionnaireIntroduction(input: UpdateQuestionnaireIntroductionInput): QuestionnaireIntroduction! + createCollapsible(input: CreateCollapsibleInput!): Collapsible! + updateCollapsible(input: UpdateCollapsibleInput!): Collapsible! + moveCollapsible(input: MoveCollapsibleInput!): Collapsible! + deleteCollapsible(input: DeleteCollapsibleInput!): QuestionnaireIntroduction! } input CreateRouting2Input { @@ -575,7 +603,6 @@ input CreateQuestionnaireInput { title: String! description: String theme: String! - legalBasis: LegalBasis! navigation: Boolean surveyId: String! summary: Boolean @@ -864,4 +891,35 @@ input DeleteQuestionConfirmationInput { id: ID! } +input UpdateQuestionnaireIntroductionInput { + id: ID! + title: String! + description: String! + legalBasis: LegalBasis! + secondaryTitle: String! + secondaryDescription: String! + tertiaryTitle: String! + tertiaryDescription: String! +} + +input CreateCollapsibleInput { + introductionId: ID! + title: String + description: String +} + +input UpdateCollapsibleInput { + id: ID! + title: String! + description: String! +} + +input MoveCollapsibleInput { + id: ID! + position: Int! +} + +input DeleteCollapsibleInput { + id: ID! +} `; diff --git a/eq-author-api/scripts/createMigration.js b/eq-author-api/scripts/createMigration.js index 89e0e6b02a..2a8a91cd80 100644 --- a/eq-author-api/scripts/createMigration.js +++ b/eq-author-api/scripts/createMigration.js @@ -24,9 +24,16 @@ module.exports = function ${name}(questionnaire) { }; `; -const testTemplate = `const ${name} = require("./${filename}"); +const testTemplate = `const { cloneDeep } = require("lodash"); +const ${name} = require("./${filename}"); describe("${name}", () => { + // This test must remain for your migration to always work + it("should be deterministic", () => { + const questionnaire = {}; // Fill in the structure of the questionnaire here + expect(${name}(cloneDeep(questionnaire))).toEqual(${name}(cloneDeep(questionnaire))); + }); + it.todo("should..."); }); diff --git a/eq-author-api/tests/utils/questionnaireBuilder/collapsible/create.js b/eq-author-api/tests/utils/questionnaireBuilder/collapsible/create.js new file mode 100644 index 0000000000..fb97978bde --- /dev/null +++ b/eq-author-api/tests/utils/questionnaireBuilder/collapsible/create.js @@ -0,0 +1,42 @@ +const { filter } = require("graphql-anywhere"); +const gql = require("graphql-tag"); + +const executeQuery = require("../../executeQuery"); + +const createCollapsibleMutation = ` + mutation CreateCollapsible($input: CreateCollapsibleInput!) { + createCollapsible(input: $input) { + id + title + description + introduction { + id + } + } + } +`; + +const createCollapsible = async (questionnaire, input) => { + const result = await executeQuery( + createCollapsibleMutation, + { + input: filter( + gql` + { + introductionId + title + description + } + `, + input + ), + }, + { questionnaire } + ); + return result.data.createCollapsible; +}; + +module.exports = { + createCollapsibleMutation, + createCollapsible, +}; diff --git a/eq-author-api/tests/utils/questionnaireBuilder/collapsible/delete.js b/eq-author-api/tests/utils/questionnaireBuilder/collapsible/delete.js new file mode 100644 index 0000000000..f85f0f2a3b --- /dev/null +++ b/eq-author-api/tests/utils/questionnaireBuilder/collapsible/delete.js @@ -0,0 +1,26 @@ +const executeQuery = require("../../executeQuery"); + +const deleteCollapsibleMutation = ` + mutation DeleteCollapsible($input: DeleteCollapsibleInput!) { + deleteCollapsible(input: $input) { + id + collapsibles { + id + } + } + } +`; + +const deleteCollapsible = async (questionnaire, input) => { + const result = await executeQuery( + deleteCollapsibleMutation, + { input }, + { questionnaire } + ); + return result.data.deleteCollapsible; +}; + +module.exports = { + deleteCollapsibleMutation, + deleteCollapsible, +}; diff --git a/eq-author-api/tests/utils/questionnaireBuilder/collapsible/index.js b/eq-author-api/tests/utils/questionnaireBuilder/collapsible/index.js new file mode 100644 index 0000000000..51150d8bc7 --- /dev/null +++ b/eq-author-api/tests/utils/questionnaireBuilder/collapsible/index.js @@ -0,0 +1,7 @@ +module.exports = { + ...require("./create"), + ...require("./query"), + ...require("./update"), + ...require("./move"), + ...require("./delete"), +}; diff --git a/eq-author-api/tests/utils/questionnaireBuilder/collapsible/move.js b/eq-author-api/tests/utils/questionnaireBuilder/collapsible/move.js new file mode 100644 index 0000000000..f92d2674c9 --- /dev/null +++ b/eq-author-api/tests/utils/questionnaireBuilder/collapsible/move.js @@ -0,0 +1,29 @@ +const executeQuery = require("../../executeQuery"); + +const moveCollapsibleMutation = ` + mutation MoveCollapsible($input: MoveCollapsibleInput!) { + moveCollapsible(input: $input) { + id + introduction { + id + collapsibles { + id + } + } + } + } +`; + +const moveCollapsible = async (questionnaire, input) => { + const result = await executeQuery( + moveCollapsibleMutation, + { input }, + { questionnaire } + ); + return result.data.moveCollapsible; +}; + +module.exports = { + moveCollapsibleMutation, + moveCollapsible, +}; diff --git a/eq-author-api/tests/utils/questionnaireBuilder/collapsible/query.js b/eq-author-api/tests/utils/questionnaireBuilder/collapsible/query.js new file mode 100644 index 0000000000..ed019748d9 --- /dev/null +++ b/eq-author-api/tests/utils/questionnaireBuilder/collapsible/query.js @@ -0,0 +1,31 @@ +const executeQuery = require("../../executeQuery"); + +const queryCollapsibleQuery = ` + query GetIntroductionCollapsibles($introductionId: ID!) { + questionnaireIntroduction(id: $introductionId) { + id + collapsibles { + id + title + description + introduction { + id + } + } + } + } +`; + +const queryCollapsible = async (questionnaire, introductionId) => { + const result = await executeQuery( + queryCollapsibleQuery, + { introductionId }, + { questionnaire } + ); + return result.data.questionnaireIntroduction.collapsibles; +}; + +module.exports = { + queryCollapsibleQuery, + queryCollapsible, +}; diff --git a/eq-author-api/tests/utils/questionnaireBuilder/collapsible/update.js b/eq-author-api/tests/utils/questionnaireBuilder/collapsible/update.js new file mode 100644 index 0000000000..2b3fcf8391 --- /dev/null +++ b/eq-author-api/tests/utils/questionnaireBuilder/collapsible/update.js @@ -0,0 +1,25 @@ +const executeQuery = require("../../executeQuery"); + +const updateCollapsibleMutation = ` + mutation UpdateCollapsible($input: UpdateCollapsibleInput!) { + updateCollapsible(input: $input) { + id + title + description + } + } +`; + +const updateCollapsible = async (questionnaire, input) => { + const result = await executeQuery( + updateCollapsibleMutation, + { input }, + { questionnaire } + ); + return result.data.updateCollapsible; +}; + +module.exports = { + updateCollapsibleMutation, + updateCollapsible, +}; diff --git a/eq-author-api/tests/utils/questionnaireBuilder/index.js b/eq-author-api/tests/utils/questionnaireBuilder/index.js index 6ce9ca1dc7..f7bd023050 100644 --- a/eq-author-api/tests/utils/questionnaireBuilder/index.js +++ b/eq-author-api/tests/utils/questionnaireBuilder/index.js @@ -15,22 +15,29 @@ const { deleteOption, } = require("./option"); const { createCalculatedSummaryPage } = require("./page/calculatedSummary"); - const { createQuestionConfirmation, updateQuestionConfirmation, } = require("./questionConfirmation"); +const { + updateQuestionnaireIntroduction, +} = require("./questionnaireIntroduction"); +const { createCollapsible } = require("./collapsible"); const buildRouting = require("./buildRouting"); //@todo - Split into smaller functions to avoid deeply nested chaining const buildQuestionnaire = async questionnaireConfig => { - const { sections, metadata, ...questionnaireProps } = questionnaireConfig; + const { + sections, + metadata, + introduction, + ...questionnaireProps + } = questionnaireConfig; const questionnaire = await createQuestionnaireReturningPersisted({ title: "Questionnaire", surveyId: "1", theme: "default", - legalBasis: "Voluntary", navigation: false, type: SOCIAL, ...questionnaireProps, @@ -126,6 +133,24 @@ const buildQuestionnaire = async questionnaireConfig => { } } + if (introduction) { + const { collapsibles, ...introductionProps } = introduction; + if (Object.keys(introductionProps).length > 0) { + await updateQuestionnaireIntroduction(questionnaire, { + id: questionnaire.introduction.id, + ...introductionProps, + }); + } + if (Array.isArray(collapsibles)) { + for (let i = 0; i < collapsibles.length; ++i) { + await createCollapsible(questionnaire, { + introductionId: questionnaire.introduction.id, + ...collapsibles[i], + }); + } + } + } + await buildRouting(questionnaire, questionnaireConfig); const getResult = await getQuestionnaire(questionnaire.id); diff --git a/eq-author-api/tests/utils/questionnaireBuilder/questionnaire/createQuestionnaire.js b/eq-author-api/tests/utils/questionnaireBuilder/questionnaire/createQuestionnaire.js index 670b9c89d2..94f5ecf66d 100644 --- a/eq-author-api/tests/utils/questionnaireBuilder/questionnaire/createQuestionnaire.js +++ b/eq-author-api/tests/utils/questionnaireBuilder/questionnaire/createQuestionnaire.js @@ -9,7 +9,6 @@ const createQuestionnaireMutation = ` displayName description theme - legalBasis navigation type surveyId @@ -30,6 +29,13 @@ const createQuestionnaireMutation = ` metadata { id } + introduction { + id + title + collapsibles { + id + } + } } } `; diff --git a/eq-author-api/tests/utils/questionnaireBuilder/questionnaire/duplicateQuestionnaire.js b/eq-author-api/tests/utils/questionnaireBuilder/questionnaire/duplicateQuestionnaire.js index 81994c6e51..b18ce865c2 100644 --- a/eq-author-api/tests/utils/questionnaireBuilder/questionnaire/duplicateQuestionnaire.js +++ b/eq-author-api/tests/utils/questionnaireBuilder/questionnaire/duplicateQuestionnaire.js @@ -9,7 +9,6 @@ const duplicateQuestionnaireMutation = ` displayName description theme - legalBasis navigation surveyId createdAt diff --git a/eq-author-api/tests/utils/questionnaireBuilder/questionnaire/queryQuestionnaire.js b/eq-author-api/tests/utils/questionnaireBuilder/questionnaire/queryQuestionnaire.js index 3bb3e76431..1dd14e170c 100644 --- a/eq-author-api/tests/utils/questionnaireBuilder/questionnaire/queryQuestionnaire.js +++ b/eq-author-api/tests/utils/questionnaireBuilder/questionnaire/queryQuestionnaire.js @@ -7,7 +7,6 @@ const getQuestionnaireQuery = ` title description theme - legalBasis navigation surveyId createdAt diff --git a/eq-author-api/tests/utils/questionnaireBuilder/questionnaire/updateQuestionnaire.js b/eq-author-api/tests/utils/questionnaireBuilder/questionnaire/updateQuestionnaire.js index 2188d33161..c10dc3f2c2 100644 --- a/eq-author-api/tests/utils/questionnaireBuilder/questionnaire/updateQuestionnaire.js +++ b/eq-author-api/tests/utils/questionnaireBuilder/questionnaire/updateQuestionnaire.js @@ -7,7 +7,6 @@ const updateQuestionnaireMutation = ` title description theme - legalBasis navigation surveyId summary diff --git a/eq-author-api/tests/utils/questionnaireBuilder/questionnaireIntroduction/index.js b/eq-author-api/tests/utils/questionnaireBuilder/questionnaireIntroduction/index.js new file mode 100644 index 0000000000..f3a2dfc15b --- /dev/null +++ b/eq-author-api/tests/utils/questionnaireBuilder/questionnaireIntroduction/index.js @@ -0,0 +1,4 @@ +module.exports = { + ...require("./query"), + ...require("./update"), +}; diff --git a/eq-author-api/tests/utils/questionnaireBuilder/questionnaireIntroduction/query.js b/eq-author-api/tests/utils/questionnaireBuilder/questionnaireIntroduction/query.js new file mode 100644 index 0000000000..a759f19a6b --- /dev/null +++ b/eq-author-api/tests/utils/questionnaireBuilder/questionnaireIntroduction/query.js @@ -0,0 +1,42 @@ +const executeQuery = require("../../executeQuery"); + +const queryIntroductionQuery = ` + query GetIntroduction($introductionId: ID!) { + questionnaireIntroduction(id: $introductionId) { + id + title + description + secondaryTitle + secondaryDescription + collapsibles { + id + } + legalBasis + tertiaryTitle + tertiaryDescription + availablePipingAnswers { + id + } + availablePipingMetadata { + id + } + } + } +`; + +const queryQuestionnaireIntroduction = async ( + questionnaire, + introductionId +) => { + const result = await executeQuery( + queryIntroductionQuery, + { introductionId }, + { questionnaire } + ); + return result.data.questionnaireIntroduction; +}; + +module.exports = { + queryIntroductionQuery, + queryQuestionnaireIntroduction, +}; diff --git a/eq-author-api/tests/utils/questionnaireBuilder/questionnaireIntroduction/update.js b/eq-author-api/tests/utils/questionnaireBuilder/questionnaireIntroduction/update.js new file mode 100644 index 0000000000..0265169fb8 --- /dev/null +++ b/eq-author-api/tests/utils/questionnaireBuilder/questionnaireIntroduction/update.js @@ -0,0 +1,33 @@ +const executeQuery = require("../../executeQuery"); + +const updateQuestionnaireIntroductionMutation = ` + mutation UpdateQuestionnaireIntroduction($input: UpdateQuestionnaireIntroductionInput!) { + updateQuestionnaireIntroduction(input: $input) { + id + title + description + secondaryTitle + secondaryDescription + collapsibles { + id + } + legalBasis + tertiaryTitle + tertiaryDescription + } + } +`; + +const updateQuestionnaireIntroduction = async (questionnaire, input) => { + const result = await executeQuery( + updateQuestionnaireIntroductionMutation, + { input }, + { questionnaire } + ); + return result.data.updateQuestionnaireIntroduction; +}; + +module.exports = { + updateQuestionnaireIntroductionMutation, + updateQuestionnaireIntroduction, +}; diff --git a/eq-author-api/utils/datastoreDynamo.js b/eq-author-api/utils/datastoreDynamo.js index f21631032e..6071b1a4dc 100644 --- a/eq-author-api/utils/datastoreDynamo.js +++ b/eq-author-api/utils/datastoreDynamo.js @@ -123,6 +123,16 @@ const saveQuestionnaire = async (questionnaireModel, count = 0, patch) => { ); const latestQuestionnaire = await getQuestionnaire(questionnaireModel.id); + + // We are done if we match the latest questionnaire + const diffToLatest = diffPatcher.diff( + omitTimestamps(latestQuestionnaire), + omitTimestamps(questionnaireModel) + ); + if (!diffToLatest) { + return; + } + diffPatcher.patch(latestQuestionnaire, patchToApply); await saveQuestionnaire(latestQuestionnaire, ++count, patchToApply); } diff --git a/eq-author-api/utils/defaultMetadata.js b/eq-author-api/utils/defaultMetadata.js index 9ba2beadfa..1e785d4693 100644 --- a/eq-author-api/utils/defaultMetadata.js +++ b/eq-author-api/utils/defaultMetadata.js @@ -1,6 +1,5 @@ const uuid = require("uuid"); -const { includes } = require("lodash"); -const { filter, flow, map } = require("lodash/fp"); +const { includes, filter } = require("lodash"); const defaultTypeValueNames = { Date: "dateValue", @@ -97,29 +96,29 @@ const defaultValues = [ }, ]; -const defaultBusinessSurveyMetadata = flow( - filter(({ key }) => - includes( - [ - "ru_name", - "trad_as", - "ref_p_start_date", - "ref_p_end_date", - "period_id", - "employmentDate", - ], - key - ) - ), - map(metadata => ({ +const DEFAULT_BUSINESS_SURVEY_METADATA = filter(defaultValues, ({ key }) => + includes( + [ + "ru_name", + "trad_as", + "ref_p_start_date", + "ref_p_end_date", + "period_id", + "employmentDate", + ], + key + ) +); + +const createDefaultBusinessSurveyMetadata = () => + DEFAULT_BUSINESS_SURVEY_METADATA.map(metadata => ({ id: uuid.v4(), ...metadata, - })) -)(defaultValues); + })); Object.assign(module.exports, { defaultTypeValueNames, defaultTypeValues, defaultValues, - defaultBusinessSurveyMetadata, + createDefaultBusinessSurveyMetadata, }); diff --git a/eq-author/cypress/builders/metadata.js b/eq-author/cypress/builders/metadata.js index 0686c4790a..99c6455202 100644 --- a/eq-author/cypress/builders/metadata.js +++ b/eq-author/cypress/builders/metadata.js @@ -1,6 +1,6 @@ import { selectOptionByLabel, testId } from "../utils"; -export const addMetadata = (metadataKey, type) => { +export const addMetadata = (metadataKey, type, existingCount = 0) => { cy.get(testId("metadata-btn")).as("metadataBtn"); cy.get("@metadataBtn").should("be.visible"); cy.get("@metadataBtn").click(); @@ -9,10 +9,14 @@ export const addMetadata = (metadataKey, type) => { cy.get("@addMetadataBtn").should("be.visible"); cy.get("@addMetadataBtn").click(); - cy.get(testId("metadata-table-row")).within(() => { - cy.get("[name='key']").as("metadataKey"); - cy.get("[name='type']").within(() => selectOptionByLabel(type)); - }); + cy.get(testId("metadata-table-row")).should("have.length", existingCount + 1); + + cy.get(testId("metadata-table-row")) + .last() + .within(() => { + cy.get("[name='key']").as("metadataKey"); + cy.get("[name='type']").within(() => selectOptionByLabel(type)); + }); cy.get("@metadataKey").type(metadataKey); cy.get("@metadataKey").should("have.value", metadataKey); diff --git a/eq-author/cypress/builders/questionnaire.js b/eq-author/cypress/builders/questionnaire.js index 5f92068ebc..e379e4b77a 100644 --- a/eq-author/cypress/builders/questionnaire.js +++ b/eq-author/cypress/builders/questionnaire.js @@ -2,13 +2,13 @@ import { testId, idRegex } from "../utils"; const checkIsOnDesignPage = () => cy.hash().should("match", /\/design$/); -const updateDetails = ({ title }) => { +const updateDetails = ({ title, type = "Social" }) => { cy.get(testId("questionnaire-settings-modal")).within(() => { cy.get(testId("txt-questionnaire-title")) .clear() .type(title); cy.get("label[for='navigation']").click(); - cy.get(testId("select-questionnaire-type")).select("Social"); + cy.get(testId("select-questionnaire-type")).select(type); cy.get("form").submit(); }); diff --git a/eq-author/cypress/fixtures/publisher.json b/eq-author/cypress/fixtures/publisher.json index 6ab32c9ef1..775ea0bb5f 100644 --- a/eq-author/cypress/fixtures/publisher.json +++ b/eq-author/cypress/fixtures/publisher.json @@ -1,36 +1,75 @@ { - "eq_id": "1", - "form_type": "1", + "eq_id": "3e947e58-bc66-439f-834d-82d80a45c53b", + "form_type": "3e947e58-bc66-439f-834d-82d80a45c53b", "mime_type": "application/json/ons/eq", "schema_version": "0.0.1", "data_version": "0.0.2", - "survey_id": "1", + "survey_id": "ukis", "title": "UKIS", - "view_submitted_response": { - "duration": 900, - "enabled": true - }, "sections": [ { - "id": "section1", + "id": "section584466ca-2485-4e32-b5ac-352bd7a26d2a", "title": "General Business Information", "groups": [ { - "id": "group1", + "id": "group584466ca-2485-4e32-b5ac-352bd7a26d2a", "title": "General Business Information", "blocks": [ { - "id": "block1", + "type": "Introduction", + "id": "introduction-block", + "primary_content": [ + { + "type": "Basic", + "id": "primary", + "content": [ + { + "list": [ + "Data should relate to all sites in England, Scotland, Wales and Northern Ireland unless otherwise stated.", + "You can provide info estimates if actual figures aren’t available.", + "We will treat your data securely and confidentially." + ] + } + ] + } + ], + "preview_content": { + "id": "preview", + "title": "Information you need", + "content": [ + { + "description": "You can select the dates of the period you are reporting for, if the given dates are not appropriate." + } + ], + "questions": [] + }, + "secondary_content": [ + { + "id": "secondary-content", + "title": "How we use your data", + "content": [ + { + "list": [ + "You cannot appeal your selection. Your business was selected to give us a comprehensive view of the UK economy.", + "The information you provide contributes to Gross Domestic Product (GDP)." + ] + } + ] + } + ] + }, + { + "id": "blockcecdd089-eac6-4cef-a6a5-98e829307a7a", "type": "Question", "questions": [ { - "id": "question1", + "id": "questioncecdd089-eac6-4cef-a6a5-98e829307a7a", "title": "In which geographic markets did Enterprise Ltd offer goods and / or services?", "description": "

Here is the question description

", "type": "General", "answers": [ { - "id": "answer1", + "id": "answerdeafc370-5c27-4d21-943a-6d19c6443146", "mandatory": false, "type": "Checkbox", "label": "", @@ -92,8 +131,8 @@ ] } ], - "theme": "social", - "legal_basis": "StatisticsOfTradeAct", + "theme": "default", + "legal_basis": "Notice is given under section 1 of the Statistics of Trade Act 1947.", "navigation": { "visible": true }, @@ -109,6 +148,26 @@ { "name": "ru_name", "validator": "string" + }, + { + "name": "trad_as", + "validator": "string" + }, + { + "name": "ref_p_start_date", + "validator": "date" + }, + { + "name": "ref_p_end_date", + "validator": "date" + }, + { + "name": "employmentDate", + "validator": "date" } - ] -} + ], + "view_submitted_response": { + "enabled": true, + "duration": 900 + } +} \ No newline at end of file diff --git a/eq-author/cypress/integration/authenticated/introduction_spec.js b/eq-author/cypress/integration/authenticated/introduction_spec.js new file mode 100644 index 0000000000..db307a7ab7 --- /dev/null +++ b/eq-author/cypress/integration/authenticated/introduction_spec.js @@ -0,0 +1,135 @@ +import { idRegex, testId, findInputByLabel } from "../../utils"; +import { questionnaire } from "../../builders"; + +describe("Questionnaire Introduction", () => { + const questionnaireTitle = "Questionnaire Introduction Test"; + + afterEach(() => { + cy.deleteQuestionnaire(questionnaireTitle); + }); + + beforeEach(() => { + cy.visit("/"); + cy.login(); + questionnaire.add({ title: questionnaireTitle, type: "Business" }); + }); + + it("should be the default launch page", () => { + const pattern = new RegExp(`/q/${idRegex}/introduction/${idRegex}/design`); + cy.hash().should("match", pattern); + + cy.get(testId("logo")).click(); + cy.contains(questionnaireTitle).click(); + + cy.hash().should("match", pattern); + }); + + it("should allow modification of the properties", () => { + cy.get(testId("txt-intro-description", "testid")).should("to.not.be.empty"); + cy.get(testId("txt-intro-description", "testid")) + .clear() + .type("Introduction description") + .blur(); + + cy.get(testId("txt-intro-secondary-title", "testid")).should( + "to.not.be.empty" + ); + cy.get(testId("txt-intro-secondary-title", "testid")) + .clear() + .type("Secondary title") + .blur(); + + cy.get(testId("txt-intro-secondary-description", "testid")).should( + "to.not.be.empty" + ); + cy.get(testId("txt-intro-secondary-description", "testid")) + .clear() + .type("Secondary description") + .blur(); + + findInputByLabel("Notice 2").click(); + cy.get(`${testId("intro-legal-basis")} [value='NOTICE_2']`).should( + "be.checked" + ); + + cy.get(testId("txt-intro-tertiary-title", "testid")).should( + "to.not.be.empty" + ); + cy.get(testId("txt-intro-tertiary-title", "testid")) + .clear() + .type("tertiary title") + .blur(); + + cy.get(testId("txt-intro-tertiary-description", "testid")).should( + "to.not.be.empty" + ); + cy.get(testId("txt-intro-tertiary-description", "testid")) + .clear() + .type("tertiary description") + .blur(); + }); + + it("should be able to add and modify collapsibles", () => { + cy.get(testId("collapsible-editor")).should("have.length", 0); + cy.get(testId("add-collapsible-btn")).click(); + cy.get(testId("collapsible-editor")).should("have.length", 1); + + cy.get(testId("txt-collapsible-title")) + .type("Collapsible title") + .blur(); + + cy.get(testId("txt-collapsible-description", "testid")) + .type("Collapsible description") + .blur(); + }); + + it("should be able to re-order collapsibles", () => { + cy.get(testId("collapsible-editor")).should("have.length", 0); + + cy.get(testId("add-collapsible-btn")).click(); + cy.get(testId("collapsible-editor")).should("have.length", 1); + cy.get(testId("txt-collapsible-title")) + .eq(0) + .type("Title 1") + .blur(); + + cy.get(testId("add-collapsible-btn")).click(); + cy.get(testId("collapsible-editor")).should("have.length", 2); + cy.get(testId("txt-collapsible-title")) + .eq(1) + .type("Title 2") + .blur(); + + cy.get(testId("move-up-btn")) + .eq(1) + .click(); + + cy.contains("Untitled Section").click(); + cy.hash().should("match", new RegExp(`/q/${idRegex}/section`)); + cy.get(testId("side-nav")).within(() => { + cy.contains("Introduction").click(); + }); + cy.hash().should("match", new RegExp(`/q/${idRegex}/introduction`)); + + cy.get(testId("txt-collapsible-title")).should("have.length", 2); + + cy.get(testId("txt-collapsible-title")) + .eq(0) + .should("contain", "Title 2"); + }); + + it("should be able to delete collapsibles", () => { + cy.get(testId("collapsible-editor")).should("have.length", 0); + cy.get(testId("add-collapsible-btn")).click(); + cy.get(testId("collapsible-editor")).should("have.length", 1); + cy.get(testId("delete-collapsible-btn")).click(); + cy.get(testId("collapsible-editor")).should("have.length", 0); + }); + + it("should be previewable", () => { + cy.get(testId("preview")) + .contains("Preview") + .click(); + cy.hash().should("match", /\/preview$/); + }); +}); diff --git a/eq-author/cypress/integration/authenticated/piping_spec.js b/eq-author/cypress/integration/authenticated/piping_spec.js index 4d3cee9ee7..63a8d85526 100644 --- a/eq-author/cypress/integration/authenticated/piping_spec.js +++ b/eq-author/cypress/integration/authenticated/piping_spec.js @@ -5,7 +5,6 @@ import { addSection, testId, selectFirstAnswerFromContentPicker, - selectFirstMetadataContentPicker, enableDescription, enableGuidance, } from "../../utils"; @@ -61,13 +60,23 @@ const clickLastSection = () => .last() .click({ force: true }); //Metadata modal transition is sometimes too slow +const selectMetadata = () => { + cy.get(testId("picker-option")) + .contains(METADATA) + .click(); + + cy.get(testId("submit-button")).click(); +}; + const canPipeMetadata = ({ selector }) => { cy.get(testId(selector, "testid")).click(); cy.focused() .should("have.attr", "data-testid") .and("eq", selector); clickPipingButton(selector); - selectFirstMetadataContentPicker(); + + selectMetadata(); + cy.get(testId(selector, "testid")).should("contain", `[${METADATA}]`); }; @@ -76,11 +85,12 @@ describe("Piping", () => { beforeEach(() => { cy.visit("/"); cy.login(); - addQuestionnaire(questionnaireTitle); + addQuestionnaire(questionnaireTitle, "Business"); }); describe("Answers", () => { beforeEach(() => { + clickFirstPage(); addAnswerType("Number"); cy.get(testId("txt-answer-label")).type(ANSWER); addSection(); @@ -155,9 +165,12 @@ describe("Piping", () => { describe("Metadata", () => { beforeEach(() => { - addMetadata(METADATA, "Text"); + addMetadata(METADATA, "Text", 6); }); describe("Page", () => { + beforeEach(() => { + clickFirstPage(); + }); it("Can pipe metadata into page title", () => { cy.get(testId("txt-question-title", "testid")).type("title"); canPipeMetadata({ selector: "txt-question-title" }); @@ -200,6 +213,7 @@ describe("Piping", () => { describe("Question Confirmation", () => { beforeEach(() => { + clickFirstPage(); questionConfirmation.add(); }); @@ -210,6 +224,15 @@ describe("Piping", () => { canPipeMetadata({ selector: "txt-confirmation-title" }); }); }); + + describe("Questionnaire Introduction", () => { + it("Can pipe metadata into description", () => { + cy.get(testId("txt-intro-description", "testid")) + .clear() + .type("intro description"); + canPipeMetadata({ selector: "txt-intro-description" }); + }); + }); }); afterEach(() => { diff --git a/eq-author/cypress/integration/authenticated/routing_spec.js b/eq-author/cypress/integration/authenticated/routing_spec.js index 6d2ed8cf2c..b188270290 100644 --- a/eq-author/cypress/integration/authenticated/routing_spec.js +++ b/eq-author/cypress/integration/authenticated/routing_spec.js @@ -50,6 +50,7 @@ describe("Routing", () => { title = "Test no routing rules"; cy.createQuestionnaire(title); + cy.contains("Untitled Page").click(); typeIntoDraftEditor(testId("txt-question-title", "testid"), "Question 1"); buildMultipleChoiceAnswer(["A", "B", "C"]); @@ -67,6 +68,7 @@ describe("Routing", () => { title = "Test routing destination"; cy.createQuestionnaire(title); + cy.contains("Untitled Page").click(); typeIntoDraftEditor(testId("txt-question-title", "testid"), "Question 1"); buildMultipleChoiceAnswer(["A", "B", "C"]); @@ -99,6 +101,7 @@ describe("Routing", () => { it(`should be able to add a ${type} routing rule and edit the inputs`, () => { title = `Test add ${type}`; cy.createQuestionnaire(title); + cy.contains("Untitled Page").click(); typeIntoDraftEditor(testId("txt-question-title", "testid"), "Question 1"); addAnswerType(type); @@ -116,6 +119,7 @@ describe("Routing", () => { it("follows the link to add an answer and routing updates with the new answer", () => { title = "Test no answer"; cy.createQuestionnaire(title); + cy.contains("Untitled Page").click(); typeIntoDraftEditor(testId("txt-question-title", "testid"), "Question 1"); @@ -150,9 +154,7 @@ describe("Routing", () => { title = "Test OR rules"; cy.createQuestionnaire(title); - cy.get(testId("nav-section-link")) - .first() - .click(); + cy.contains("Untitled Section").click(); typeIntoDraftEditor(testId("txt-section-title", "testid"), "Section 1"); cy.get(testId("nav-page-link")) @@ -253,6 +255,7 @@ describe("Routing", () => { title = "Test future question"; cy.createQuestionnaire(title); + cy.contains("Untitled Page").click(); typeIntoDraftEditor(testId("txt-question-title", "testid"), "Question 1"); buildMultipleChoiceAnswer(["A", "B", "C"]); diff --git a/eq-author/cypress/integration/e2e/e2e_spec.js b/eq-author/cypress/integration/e2e/e2e_spec.js index bfa3e03704..71dbb4c7b8 100644 --- a/eq-author/cypress/integration/e2e/e2e_spec.js +++ b/eq-author/cypress/integration/e2e/e2e_spec.js @@ -29,7 +29,7 @@ describe("End to end", () => { it("Can create a questionnaire", () => { cy.visit("/"); cy.login(); - questionnaire.add({ title: "UKIS" }).then(({ id }) => { + questionnaire.add({ title: "UKIS", type: "Business" }).then(({ id }) => { questionnaireId = id; }); section.updateInitial({ diff --git a/eq-author/cypress/utils/index.js b/eq-author/cypress/utils/index.js index 005072b017..83f1abd85d 100644 --- a/eq-author/cypress/utils/index.js +++ b/eq-author/cypress/utils/index.js @@ -44,9 +44,9 @@ export function setQuestionnaireSettings({ title, type, shortTitle }) { }); } -export const addQuestionnaire = title => { +export const addQuestionnaire = (title, type = "Social") => { cy.get(testId("create-questionnaire")).click(); - setQuestionnaireSettings({ title, type: "Social" }); + setQuestionnaireSettings({ title, type }); }; export const addSection = (initialNumberOfSections = 1) => { diff --git a/eq-author/src/App/QuestionnaireDesignPage/NavigationSidebar/IntroductionNavItem.js b/eq-author/src/App/QuestionnaireDesignPage/NavigationSidebar/IntroductionNavItem.js new file mode 100644 index 0000000000..b01e142f91 --- /dev/null +++ b/eq-author/src/App/QuestionnaireDesignPage/NavigationSidebar/IntroductionNavItem.js @@ -0,0 +1,60 @@ +import React from "react"; +import styled from "styled-components"; +import { withRouter } from "react-router-dom"; +import CustomPropTypes from "custom-prop-types"; +import gql from "graphql-tag"; +import { propType } from "graphql-anywhere"; + +import { buildIntroductionPath } from "utils/UrlUtils"; +import NavLink from "./NavLink"; +import PageIcon from "./icon-survey-intro.svg?inline"; + +const StyledItem = styled.li` + padding: 0; + margin: 0; + position: relative; + display: flex; + align-items: center; + font-weight: bold; +`; + +export const UnwrappedIntroductionNavItem = ({ + questionnaire, + match, + ...otherProps +}) => ( + + + Introduction + + +); + +UnwrappedIntroductionNavItem.fragments = { + IntroductionNavItem: gql` + fragment IntroductionNavItem on Questionnaire { + id + introduction { + id + } + } + `, +}; + +UnwrappedIntroductionNavItem.propTypes = { + questionnaire: propType( + UnwrappedIntroductionNavItem.fragments.IntroductionNavItem + ), + match: CustomPropTypes.match, +}; + +export default withRouter(UnwrappedIntroductionNavItem); diff --git a/eq-author/src/App/QuestionnaireDesignPage/NavigationSidebar/__snapshots__/index.test.js.snap b/eq-author/src/App/QuestionnaireDesignPage/NavigationSidebar/__snapshots__/index.test.js.snap index e64e540c5e..2a61a76333 100644 --- a/eq-author/src/App/QuestionnaireDesignPage/NavigationSidebar/__snapshots__/index.test.js.snap +++ b/eq-author/src/App/QuestionnaireDesignPage/NavigationSidebar/__snapshots__/index.test.js.snap @@ -39,27 +39,31 @@ exports[`NavigationSidebar should render 1`] = ` } /> - +
  • + + "title": "Questionnaire", + } + } + /> +
  • +
    `; diff --git a/eq-author/src/App/QuestionnaireDesignPage/NavigationSidebar/icon-survey-intro.svg b/eq-author/src/App/QuestionnaireDesignPage/NavigationSidebar/icon-survey-intro.svg new file mode 100644 index 0000000000..eb3ae86c8c --- /dev/null +++ b/eq-author/src/App/QuestionnaireDesignPage/NavigationSidebar/icon-survey-intro.svg @@ -0,0 +1,9 @@ + + + + icon-survey-intro + Created with Sketch. + + + + \ No newline at end of file diff --git a/eq-author/src/App/QuestionnaireDesignPage/NavigationSidebar/index.js b/eq-author/src/App/QuestionnaireDesignPage/NavigationSidebar/index.js index bc7ecf7451..2bd11ed6a2 100644 --- a/eq-author/src/App/QuestionnaireDesignPage/NavigationSidebar/index.js +++ b/eq-author/src/App/QuestionnaireDesignPage/NavigationSidebar/index.js @@ -12,6 +12,7 @@ import withUpdateQuestionnaire from "./withUpdateQuestionnaire"; import SectionNav from "./SectionNav"; import NavigationHeader from "./NavigationHeader"; +import IntroductionNavItem from "./IntroductionNavItem"; const Container = styled.div` background: ${colors.darkBlue}; @@ -29,6 +30,12 @@ const NavigationScrollPane = styled(ScrollPane)` } `; +const NavList = styled.ol` + margin: 0; + padding: 0; + list-style: none; +`; + export class UnwrappedNavigationSidebar extends Component { static propTypes = { questionnaire: CustomPropTypes.questionnaire, @@ -71,7 +78,17 @@ export class UnwrappedNavigationSidebar extends Component { data-test="nav-section-header" /> - + + {questionnaire.introduction && ( + + )} +
  • + +
  • +
    )} @@ -86,10 +103,12 @@ UnwrappedNavigationSidebar.fragments = { id ...SectionNav ...NavigationHeader + ...IntroductionNavItem } ${NavigationHeader.fragments.NavigationHeader} ${SectionNav.fragments.SectionNav} + ${IntroductionNavItem.fragments.IntroductionNavItem} `, }; diff --git a/eq-author/src/App/QuestionnaireDesignPage/NavigationSidebar/index.test.js b/eq-author/src/App/QuestionnaireDesignPage/NavigationSidebar/index.test.js index efa7408566..f322f1e1ff 100644 --- a/eq-author/src/App/QuestionnaireDesignPage/NavigationSidebar/index.test.js +++ b/eq-author/src/App/QuestionnaireDesignPage/NavigationSidebar/index.test.js @@ -4,63 +4,57 @@ import { UnwrappedNavigationSidebar as NavigationSidebar } from "./"; import { SynchronousPromise } from "synchronous-promise"; describe("NavigationSidebar", () => { - let wrapper, - handleAddSection, - handleAddQuestionPage, - handleUpdateQuestionnaire, - handleAddCalculatedSummaryPage, - handleAddQuestionConfirmation; - - const page = { id: "2", title: "Page", position: 0 }; - const section = { id: "3", title: "Section", pages: [page] }; - const questionnaire = { - id: "1", - title: "Questionnaire", - sections: [section], - }; - + let props; beforeEach(() => { - handleAddSection = jest.fn(() => SynchronousPromise.resolve(questionnaire)); - handleAddQuestionPage = jest.fn(() => - SynchronousPromise.resolve({ section }) - ); - handleUpdateQuestionnaire = jest.fn(); - handleAddQuestionConfirmation = jest.fn(); - handleAddCalculatedSummaryPage = jest.fn(); - - wrapper = shallow( - - ); + const page = { id: "2", title: "Page", position: 0 }; + const section = { id: "3", title: "Section", pages: [page] }; + const questionnaire = { + id: "1", + title: "Questionnaire", + sections: [section], + }; + props = { + questionnaire, + onAddSection: jest.fn(() => SynchronousPromise.resolve(questionnaire)), + onAddQuestionPage: jest.fn(() => SynchronousPromise.resolve({ section })), + onAddCalculatedSummaryPage: jest.fn(), + onUpdateQuestionnaire: jest.fn(), + onAddQuestionConfirmation: jest.fn(), + canAddQuestionConfirmation: true, + loading: false, + }; }); it("should render", () => { + const wrapper = shallow(); expect(wrapper).toMatchSnapshot(); }); it("should only render container if loading", () => { - wrapper.setProps({ loading: true }); + const wrapper = shallow(); expect(wrapper).toMatchSnapshot(); }); it("should allow sections to be added", () => { + const wrapper = shallow(); wrapper.find("[data-test='nav-section-header']").simulate("addSection"); - expect(handleAddSection).toHaveBeenCalledWith(questionnaire.id); + expect(props.onAddSection).toHaveBeenCalledWith(props.questionnaire.id); }); it("should allow pages to be added", () => { + const wrapper = shallow(); wrapper .find("[data-test='nav-section-header']") .simulate("addQuestionPage"); - expect(handleAddQuestionPage).toHaveBeenCalledWith(); + expect(props.onAddQuestionPage).toHaveBeenCalledWith(); + }); + + it("should render an introduction nav item when the questionnaire has one", () => { + props.questionnaire.introduction = { + id: "1", + }; + const wrapper = shallow(); + expect(wrapper.find("[data-test='nav-introduction']")).toHaveLength(1); }); }); diff --git a/eq-author/src/App/QuestionnaireDesignPage/NavigationSidebar/withUpdateQuestionnaire.js b/eq-author/src/App/QuestionnaireDesignPage/NavigationSidebar/withUpdateQuestionnaire.js index b546bbd71a..73e8b4a3b2 100644 --- a/eq-author/src/App/QuestionnaireDesignPage/NavigationSidebar/withUpdateQuestionnaire.js +++ b/eq-author/src/App/QuestionnaireDesignPage/NavigationSidebar/withUpdateQuestionnaire.js @@ -11,7 +11,6 @@ const inputStructure = gql` title description theme - legalBasis navigation surveyId summary diff --git a/eq-author/src/App/QuestionnaireDesignPage/__snapshots__/index.test.js.snap b/eq-author/src/App/QuestionnaireDesignPage/__snapshots__/index.test.js.snap index 2a9c07ac4f..7d7902f92a 100644 --- a/eq-author/src/App/QuestionnaireDesignPage/__snapshots__/index.test.js.snap +++ b/eq-author/src/App/QuestionnaireDesignPage/__snapshots__/index.test.js.snap @@ -4,6 +4,13 @@ exports[`QuestionnaireDesignPage getTitle should display existing title if loadi exports[`QuestionnaireDesignPage getTitle should display questionnaire title if no longer loading 1`] = `"hello world - foo"`; +exports[`QuestionnaireDesignPage should redirect to the introduction if it has one 1`] = ` + +`; + exports[`QuestionnaireDesignPage should render 1`] = ` + + + ); + } + return ( @@ -217,6 +230,9 @@ const withMutations = flowRight( const QUESTIONNAIRE_QUERY = gql` query GetQuestionnaire($input: QueryInput!) { questionnaire(input: $input) { + introduction { + id + } ...NavigationSidebar } } diff --git a/eq-author/src/App/QuestionnaireDesignPage/index.test.js b/eq-author/src/App/QuestionnaireDesignPage/index.test.js index 5c841869b2..5b49cca7a7 100644 --- a/eq-author/src/App/QuestionnaireDesignPage/index.test.js +++ b/eq-author/src/App/QuestionnaireDesignPage/index.test.js @@ -91,6 +91,20 @@ describe("QuestionnaireDesignPage", () => { expect(wrapper.instance().renderRedirect()).toMatchSnapshot(); }); + it("should redirect to the introduction if it has one", () => { + wrapper.setProps({ + data: { + questionnaire: { + ...questionnaire, + introduction: { + id: "1", + }, + }, + }, + }); + expect(wrapper.instance().renderRedirect()).toMatchSnapshot(); + }); + describe("onAddQuestionPage", () => { it("should add new page below current page", () => { wrapper.find(NavigationSidebar).simulate("addQuestionPage"); diff --git a/eq-author/src/App/QuestionnaireSettingsModal/QuestionnaireMeta/QuestionnaireMeta.test.js b/eq-author/src/App/QuestionnaireSettingsModal/QuestionnaireMeta/QuestionnaireMeta.test.js index 73a6c35dcf..2b89ae02e5 100644 --- a/eq-author/src/App/QuestionnaireSettingsModal/QuestionnaireMeta/QuestionnaireMeta.test.js +++ b/eq-author/src/App/QuestionnaireSettingsModal/QuestionnaireMeta/QuestionnaireMeta.test.js @@ -13,7 +13,6 @@ const handleUpdate = jest.fn(); const questionnaire = { shortTitle: "I am the shortTitle", - legalBasis: "StatisticsOfTradeAct", type: "", title: "I am the title", id: "123", diff --git a/eq-author/src/App/QuestionnaireSettingsModal/index.js b/eq-author/src/App/QuestionnaireSettingsModal/index.js index c0d4338c23..c8113d1837 100644 --- a/eq-author/src/App/QuestionnaireSettingsModal/index.js +++ b/eq-author/src/App/QuestionnaireSettingsModal/index.js @@ -14,7 +14,6 @@ const defaultQuestionnaire = { description: "", surveyId: "", theme: "default", - legalBasis: "StatisticsOfTradeAct", navigation: false, }; diff --git a/eq-author/src/App/QuestionnairesPage/__snapshots__/index.test.js.snap b/eq-author/src/App/QuestionnairesPage/__snapshots__/index.test.js.snap index 7dc7523df5..5015286207 100644 --- a/eq-author/src/App/QuestionnairesPage/__snapshots__/index.test.js.snap +++ b/eq-author/src/App/QuestionnairesPage/__snapshots__/index.test.js.snap @@ -31,7 +31,6 @@ exports[`components/QuestionnairesPage should not render table whilst data is lo questionnaire={ Object { "description": "", - "legalBasis": "StatisticsOfTradeAct", "navigation": false, "surveyId": "", "theme": "default", @@ -233,7 +232,6 @@ exports[`components/QuestionnairesPage should render table when there are questi questionnaire={ Object { "description": "", - "legalBasis": "StatisticsOfTradeAct", "navigation": false, "surveyId": "", "theme": "default", @@ -450,7 +448,6 @@ exports[`components/QuestionnairesPage should render when there are no questionn questionnaire={ Object { "description": "", - "legalBasis": "StatisticsOfTradeAct", "navigation": false, "surveyId": "", "theme": "default", diff --git a/eq-author/src/App/QuestionnairesPage/withCreateQuestionnaire.js b/eq-author/src/App/QuestionnairesPage/withCreateQuestionnaire.js index c301ee0457..601a22676b 100644 --- a/eq-author/src/App/QuestionnairesPage/withCreateQuestionnaire.js +++ b/eq-author/src/App/QuestionnairesPage/withCreateQuestionnaire.js @@ -1,17 +1,27 @@ import { graphql } from "react-apollo"; import createQuestionnaireQuery from "graphql/createQuestionnaire.graphql"; import getQuestionnaireList from "graphql/getQuestionnaireList.graphql"; -import { buildPagePath } from "utils/UrlUtils"; +import { buildPagePath, buildIntroductionPath } from "utils/UrlUtils"; export const redirectToDesigner = history => ({ data }) => { const questionnaire = data.createQuestionnaire; + + if (questionnaire.introduction) { + history.push( + buildIntroductionPath({ + questionnaireId: questionnaire.id, + introductionId: questionnaire.introduction.id, + }) + ); + return; + } + const section = questionnaire.sections[0]; const page = section.pages[0]; history.push( buildPagePath({ questionnaireId: questionnaire.id, - sectionId: section.id, pageId: page.id, }) ); diff --git a/eq-author/src/App/QuestionnairesPage/withCreateQuestionnaire.test.js b/eq-author/src/App/QuestionnairesPage/withCreateQuestionnaire.test.js index d44ee6767b..ff10734ace 100644 --- a/eq-author/src/App/QuestionnairesPage/withCreateQuestionnaire.test.js +++ b/eq-author/src/App/QuestionnairesPage/withCreateQuestionnaire.test.js @@ -3,17 +3,17 @@ import { mapMutateToProps, updateQuestionnaireList, } from "App/QuestionnairesPage/withCreateQuestionnaire"; -import { buildPagePath } from "utils/UrlUtils"; +import { buildPagePath, buildIntroductionPath } from "utils/UrlUtils"; import getQuestionnaireList from "graphql/getQuestionnaireList.graphql"; describe("withCreateQuestionnaire", () => { - let history, mutate, results, user; - - const page = { id: "3" }; - const section = { id: "2", pages: [page] }; - const questionnaire = { id: "1", sections: [section] }; + let history, mutate, results, user, page, section, questionnaire; beforeEach(() => { + page = { id: "3" }; + section = { id: "2", pages: [page] }; + questionnaire = { id: "1", sections: [section] }; + results = { data: { createQuestionnaire: questionnaire }, }; @@ -30,13 +30,26 @@ describe("withCreateQuestionnaire", () => { }); describe("redirectToDesigner", () => { + it("should redirect to the introduction if it has one", () => { + results.data.createQuestionnaire.introduction = { + id: "4", + }; + redirectToDesigner(history)(results); + + expect(history.push).toHaveBeenCalledWith( + buildIntroductionPath({ + questionnaireId: questionnaire.id, + introductionId: "4", + }) + ); + }); + it("should redirect to correct location", () => { redirectToDesigner(history)(results); expect(history.push).toHaveBeenCalledWith( buildPagePath({ questionnaireId: questionnaire.id, - sectionId: section.id, pageId: page.id, }) ); diff --git a/eq-author/src/App/introduction/Design/IntroductionEditor/CollapsiblesEditor/CollapsibleEditor/__snapshots__/index.test.js.snap b/eq-author/src/App/introduction/Design/IntroductionEditor/CollapsiblesEditor/CollapsibleEditor/__snapshots__/index.test.js.snap new file mode 100644 index 0000000000..672223cd14 --- /dev/null +++ b/eq-author/src/App/introduction/Design/IntroductionEditor/CollapsiblesEditor/CollapsibleEditor/__snapshots__/index.test.js.snap @@ -0,0 +1,70 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`CollapsibleEditor should render 1`] = ` + + + + + + + + + + + + + + + + +`; diff --git a/eq-author/src/App/introduction/Design/IntroductionEditor/CollapsiblesEditor/CollapsibleEditor/index.js b/eq-author/src/App/introduction/Design/IntroductionEditor/CollapsiblesEditor/CollapsibleEditor/index.js new file mode 100644 index 0000000000..577100e5c1 --- /dev/null +++ b/eq-author/src/App/introduction/Design/IntroductionEditor/CollapsiblesEditor/CollapsibleEditor/index.js @@ -0,0 +1,142 @@ +import React from "react"; +import { flowRight } from "lodash"; +import styled from "styled-components"; +import gql from "graphql-tag"; +import PropTypes from "prop-types"; +import { propType } from "graphql-anywhere"; + +import DeleteButton from "components/buttons/DeleteButton"; +import MoveButton, { IconUp, IconDown } from "components/buttons/MoveButton"; +import RichTextEditor from "components/RichTextEditor"; +import { colors } from "constants/theme"; +import { Field, Label } from "components/Forms"; +import WrappingInput from "components/Forms/WrappingInput"; + +import withChangeUpdate from "enhancers/withChangeUpdate"; +import withPropRenamed from "enhancers/withPropRenamed"; +import withEntityEditor from "components/withEntityEditor"; + +import withUpdateCollapsible from "./withUpdateCollapsible"; +import withDeleteCollapsible from "./withDeleteCollapsible"; + +const Detail = styled.div` + border: 1px solid ${colors.bordersLight}; + padding: 2em 1em 0; + border-radius: 4px; + margin-bottom: 1em; + background: white; + position: relative; +`; + +const DetailHeader = styled.div` + position: absolute; + top: 0.5em; + right: 0.5em; + display: flex; + justify-content: space-around; + z-index: 2; + width: 7em; +`; + +const DetailDeleteButton = styled(DeleteButton)` + position: relative; +`; + +export const CollapsibleEditor = ({ + collapsible, + onChangeUpdate, + onChange, + onUpdate, + onMoveUp, + onMoveDown, + canMoveUp, + canMoveDown, + isMoving, + deleteCollapsible, +}) => { + const { id, title, description } = collapsible; + return ( + + + + + + + + + deleteCollapsible(collapsible)} + data-test="delete-collapsible-btn" + /> + + + + + + + + + ); +}; + +const fragment = gql` + fragment CollapsibleEditor on Collapsible { + id + title + description + } +`; + +CollapsibleEditor.fragments = [fragment]; + +CollapsibleEditor.propTypes = { + collapsible: propType(fragment).isRequired, + onChange: PropTypes.func.isRequired, + onUpdate: PropTypes.func.isRequired, + onChangeUpdate: PropTypes.func.isRequired, + onMoveUp: PropTypes.func.isRequired, + onMoveDown: PropTypes.func.isRequired, + canMoveUp: PropTypes.bool.isRequired, + canMoveDown: PropTypes.bool.isRequired, + isMoving: PropTypes.bool.isRequired, + deleteCollapsible: PropTypes.func.isRequired, +}; + +export default flowRight( + withUpdateCollapsible, + withDeleteCollapsible, + withPropRenamed("updateCollapsible", "onUpdate"), + withEntityEditor("collapsible"), + withChangeUpdate +)(CollapsibleEditor); diff --git a/eq-author/src/App/introduction/Design/IntroductionEditor/CollapsiblesEditor/CollapsibleEditor/index.test.js b/eq-author/src/App/introduction/Design/IntroductionEditor/CollapsiblesEditor/CollapsibleEditor/index.test.js new file mode 100644 index 0000000000..ed006f6da9 --- /dev/null +++ b/eq-author/src/App/introduction/Design/IntroductionEditor/CollapsiblesEditor/CollapsibleEditor/index.test.js @@ -0,0 +1,107 @@ +import React from "react"; +import { shallow } from "enzyme"; + +import { CollapsibleEditor } from "./"; + +describe("CollapsibleEditor", () => { + let props; + beforeEach(() => { + props = { + collapsible: { id: "1", title: "title", description: "description" }, + onChangeUpdate: jest.fn(), + onChange: jest.fn(), + onUpdate: jest.fn(), + onMoveUp: jest.fn(), + onMoveDown: jest.fn(), + canMoveUp: false, + canMoveDown: false, + isMoving: false, + deleteCollapsible: jest.fn(), + }; + }); + + it("should render", () => { + const wrapper = shallow(); + expect(wrapper).toMatchSnapshot(); + }); + + it("should disable buttons whilst moving", () => { + const wrapper = shallow( + + ); + expect(wrapper.find("[data-test='move-up-btn']").prop("disabled")).toEqual( + true + ); + expect( + wrapper.find("[data-test='move-down-btn']").prop("disabled") + ).toEqual(true); + expect( + wrapper.find("[data-test='delete-collapsible-btn']").prop("disabled") + ).toEqual(true); + }); + + it("should trigger changeUpdate when description is changed", () => { + const wrapper = shallow(); + wrapper + .find("[label='Description']") + .simulate("update", { name: "description", value: "description change" }); + expect(props.onChangeUpdate).toHaveBeenCalledWith({ + name: "description", + value: "description change", + }); + }); + + it("should trigger onMoveUp when move up button is clicked", () => { + shallow() + .find("[data-test='move-up-btn']") + .simulate("click"); + + expect(props.onMoveUp).toHaveBeenCalled(); + }); + + it("should disable the move up button when canMoveUp is false", () => { + expect( + shallow() + .find("[data-test='move-up-btn']") + .prop("disabled") + ).toEqual(true); + }); + + it("should trigger onMoveDown when move down button is clicked", () => { + shallow() + .find("[data-test='move-down-btn']") + .simulate("click"); + + expect(props.onMoveDown).toHaveBeenCalled(); + }); + + it("should disable the move down button when canMoveDown is false", () => { + expect( + shallow() + .find("[data-test='move-down-btn']") + .prop("disabled") + ).toEqual(true); + }); + + it("should trigger deleteCollapsible when the delete button is clicked", () => { + shallow() + .find("[data-test='delete-collapsible-btn']") + .simulate("click"); + + expect(props.deleteCollapsible).toHaveBeenCalled(); + }); + + it("should trigger onChange when the title is changed", () => { + shallow() + .find("[data-test='txt-collapsible-title']") + .simulate("change", { e: "change" }); + expect(props.onChange).toHaveBeenCalledWith({ e: "change" }); + }); + + it("should trigger onUpdate when the title is blurred", () => { + shallow() + .find("[data-test='txt-collapsible-title']") + .simulate("blur", { e: "blur" }); + expect(props.onUpdate).toHaveBeenCalledWith({ e: "blur" }); + }); +}); diff --git a/eq-author/src/App/introduction/Design/IntroductionEditor/CollapsiblesEditor/CollapsibleEditor/withDeleteCollapsible.js b/eq-author/src/App/introduction/Design/IntroductionEditor/CollapsiblesEditor/CollapsibleEditor/withDeleteCollapsible.js new file mode 100644 index 0000000000..04e1705161 --- /dev/null +++ b/eq-author/src/App/introduction/Design/IntroductionEditor/CollapsiblesEditor/CollapsibleEditor/withDeleteCollapsible.js @@ -0,0 +1,32 @@ +import { graphql } from "react-apollo"; +import gql from "graphql-tag"; +import { filter } from "graphql-anywhere"; + +const mutation = gql` + mutation deleteCollapsible($input: DeleteCollapsibleInput!) { + deleteCollapsible(input: $input) { + id + collapsibles { + id + } + } + } +`; +const inputFilter = gql` + { + id + } +`; + +export const mapMutateToProps = ({ mutate }) => ({ + deleteCollapsible: collapsible => { + const data = filter(inputFilter, collapsible); + return mutate({ + variables: { input: data }, + }); + }, +}); + +export default graphql(mutation, { + props: mapMutateToProps, +}); diff --git a/eq-author/src/App/introduction/Design/IntroductionEditor/CollapsiblesEditor/CollapsibleEditor/withDeleteCollapsible.test.js b/eq-author/src/App/introduction/Design/IntroductionEditor/CollapsiblesEditor/CollapsibleEditor/withDeleteCollapsible.test.js new file mode 100644 index 0000000000..f7828f081b --- /dev/null +++ b/eq-author/src/App/introduction/Design/IntroductionEditor/CollapsiblesEditor/CollapsibleEditor/withDeleteCollapsible.test.js @@ -0,0 +1,29 @@ +import { mapMutateToProps } from "./withDeleteCollapsible"; + +describe("withDeleteCollapsible", () => { + let mutate; + beforeEach(() => { + mutate = jest.fn(); + }); + + it("should return a deleteCollapsible func", () => { + expect(mapMutateToProps({ mutate }).deleteCollapsible).toBeInstanceOf( + Function + ); + }); + + it("should filter the args to what is allowed and call mutate", () => { + mapMutateToProps({ mutate }).deleteCollapsible({ + id: "id", + title: "title", + description: "description", + }); + expect(mutate).toHaveBeenCalledWith({ + variables: { + input: { + id: "id", + }, + }, + }); + }); +}); diff --git a/eq-author/src/App/introduction/Design/IntroductionEditor/CollapsiblesEditor/CollapsibleEditor/withUpdateCollapsible.js b/eq-author/src/App/introduction/Design/IntroductionEditor/CollapsiblesEditor/CollapsibleEditor/withUpdateCollapsible.js new file mode 100644 index 0000000000..6634d41ab7 --- /dev/null +++ b/eq-author/src/App/introduction/Design/IntroductionEditor/CollapsiblesEditor/CollapsibleEditor/withUpdateCollapsible.js @@ -0,0 +1,40 @@ +import { graphql } from "react-apollo"; +import gql from "graphql-tag"; +import { filter } from "graphql-anywhere"; + +const mutation = gql` + mutation updateCollapsible($input: UpdateCollapsibleInput!) { + updateCollapsible(input: $input) { + id + title + description + } + } +`; +const inputFilter = gql` + { + id + title + description + } +`; + +export const mapMutateToProps = ({ mutate }) => ({ + updateCollapsible: collapsible => { + const data = filter(inputFilter, collapsible); + return mutate({ + variables: { input: data }, + optimisticResponse: { + updateCollapsible: { + ...collapsible, + ...data, + __typename: "Collapsible", + }, + }, + }); + }, +}); + +export default graphql(mutation, { + props: mapMutateToProps, +}); diff --git a/eq-author/src/App/introduction/Design/IntroductionEditor/CollapsiblesEditor/CollapsibleEditor/withUpdateCollapsible.test.js b/eq-author/src/App/introduction/Design/IntroductionEditor/CollapsiblesEditor/CollapsibleEditor/withUpdateCollapsible.test.js new file mode 100644 index 0000000000..2f4afefc70 --- /dev/null +++ b/eq-author/src/App/introduction/Design/IntroductionEditor/CollapsiblesEditor/CollapsibleEditor/withUpdateCollapsible.test.js @@ -0,0 +1,41 @@ +import { mapMutateToProps } from "./withUpdateCollapsible"; + +describe("withUpdateCollapsible", () => { + let mutate; + beforeEach(() => { + mutate = jest.fn(); + }); + + it("should return a updateCollapsible func", () => { + expect(mapMutateToProps({ mutate }).updateCollapsible).toBeInstanceOf( + Function + ); + }); + + it("should filter the args, create an optimistic response and call mutate", () => { + mapMutateToProps({ mutate }).updateCollapsible({ + id: "id", + title: "title", + description: "description", + foo: "foo", + }); + expect(mutate).toHaveBeenCalledWith({ + optimisticResponse: { + updateCollapsible: { + id: "id", + title: "title", + description: "description", + foo: "foo", + __typename: "Collapsible", + }, + }, + variables: { + input: { + id: "id", + title: "title", + description: "description", + }, + }, + }); + }); +}); diff --git a/eq-author/src/App/introduction/Design/IntroductionEditor/CollapsiblesEditor/Transition.js b/eq-author/src/App/introduction/Design/IntroductionEditor/CollapsiblesEditor/Transition.js new file mode 100644 index 0000000000..64a152121b --- /dev/null +++ b/eq-author/src/App/introduction/Design/IntroductionEditor/CollapsiblesEditor/Transition.js @@ -0,0 +1,58 @@ +import { CSSTransition } from "react-transition-group"; +import PropTypes from "prop-types"; +import styled from "styled-components"; + +const timeout = props => props.timeout; +const halfTimeout = props => props.timeout / 2; + +const handleExit = node => { + const { height } = node.getBoundingClientRect(); + node.style.height = `${height}px`; +}; + +const classNames = "transition"; + +const Transition = styled(CSSTransition).attrs({ + classNames, + onExit: () => handleExit, +})` + position: relative; + + &.${classNames}-enter { + opacity: 0; + transform: scale(0.9); + z-index: 200; + } + + &.${classNames}-enter-active { + opacity: 1; + transform: scale(1); + transition: opacity ${timeout}ms ease-out, + transform ${timeout}ms cubic-bezier(0.175, 0.885, 0.32, 1.275); + } + + &.${classNames}-exit { + opacity: 1; + transform: scale(1); + } + + &.${classNames}-exit-active { + opacity: 0; + height: 0 !important; + transform: scale(0.9); + transition: opacity ${halfTimeout}ms ease-out, + height ${halfTimeout}ms ease-in ${halfTimeout}ms, + transform ${halfTimeout}ms ease-in; + } +`; + +Transition.propTypes = { + timeout: PropTypes.number, + children: PropTypes.element, +}; + +Transition.defaultProps = { + timeout: 200, +}; + +export default Transition; diff --git a/eq-author/src/App/introduction/Design/IntroductionEditor/CollapsiblesEditor/Transition.test.js b/eq-author/src/App/introduction/Design/IntroductionEditor/CollapsiblesEditor/Transition.test.js new file mode 100644 index 0000000000..904ceef679 --- /dev/null +++ b/eq-author/src/App/introduction/Design/IntroductionEditor/CollapsiblesEditor/Transition.test.js @@ -0,0 +1,33 @@ +import React from "react"; +import { shallow } from "enzyme"; + +import Transition from "./Transition"; + +describe("Transition", () => { + it("should render", () => { + expect( + shallow( + +
    + + ) + ).toMatchSnapshot(); + }); + + it("should set the height on exit so it can animate it out", () => { + const onExit = shallow( + +
    + + ).prop("onExit"); + + const getBoundingClientRectStub = jest.fn().mockReturnValue({ height: 10 }); + const node = { + style: { height: 0 }, + getBoundingClientRect: getBoundingClientRectStub, + }; + + onExit(node); + expect(node.style.height).toEqual("10px"); + }); +}); diff --git a/eq-author/src/App/introduction/Design/IntroductionEditor/CollapsiblesEditor/__snapshots__/Transition.test.js.snap b/eq-author/src/App/introduction/Design/IntroductionEditor/CollapsiblesEditor/__snapshots__/Transition.test.js.snap new file mode 100644 index 0000000000..1feba9921b --- /dev/null +++ b/eq-author/src/App/introduction/Design/IntroductionEditor/CollapsiblesEditor/__snapshots__/Transition.test.js.snap @@ -0,0 +1,52 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`Transition should render 1`] = ` +.c0 { + position: relative; +} + +.c0.transition-enter { + opacity: 0; + -webkit-transform: scale(0.9); + -ms-transform: scale(0.9); + transform: scale(0.9); + z-index: 200; +} + +.c0.transition-enter-active { + opacity: 1; + -webkit-transform: scale(1); + -ms-transform: scale(1); + transform: scale(1); + -webkit-transition: opacity 200ms ease-out,-webkit-transform 200ms cubic-bezier(0.175,0.885,0.32,1.275); + -webkit-transition: opacity 200ms ease-out,transform 200ms cubic-bezier(0.175,0.885,0.32,1.275); + transition: opacity 200ms ease-out,transform 200ms cubic-bezier(0.175,0.885,0.32,1.275); +} + +.c0.transition-exit { + opacity: 1; + -webkit-transform: scale(1); + -ms-transform: scale(1); + transform: scale(1); +} + +.c0.transition-exit-active { + opacity: 0; + height: 0 !important; + -webkit-transform: scale(0.9); + -ms-transform: scale(0.9); + transform: scale(0.9); + -webkit-transition: opacity 100ms ease-out,height 100ms ease-in 100ms,-webkit-transform 100ms ease-in; + -webkit-transition: opacity 100ms ease-out,height 100ms ease-in 100ms,transform 100ms ease-in; + transition: opacity 100ms ease-out,height 100ms ease-in 100ms,transform 100ms ease-in; +} + + +
    + +`; diff --git a/eq-author/src/App/introduction/Design/IntroductionEditor/CollapsiblesEditor/__snapshots__/index.test.js.snap b/eq-author/src/App/introduction/Design/IntroductionEditor/CollapsiblesEditor/__snapshots__/index.test.js.snap new file mode 100644 index 0000000000..51d22326f7 --- /dev/null +++ b/eq-author/src/App/introduction/Design/IntroductionEditor/CollapsiblesEditor/__snapshots__/index.test.js.snap @@ -0,0 +1,29 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`CollapsiblesEditor should render 1`] = ` + + + + + + Add collapsible + + +`; diff --git a/eq-author/src/App/introduction/Design/IntroductionEditor/CollapsiblesEditor/index.js b/eq-author/src/App/introduction/Design/IntroductionEditor/CollapsiblesEditor/index.js new file mode 100644 index 0000000000..7e0cf0f3bc --- /dev/null +++ b/eq-author/src/App/introduction/Design/IntroductionEditor/CollapsiblesEditor/index.js @@ -0,0 +1,63 @@ +import React from "react"; +import styled from "styled-components"; +import { propType } from "graphql-anywhere"; +import PropTypes from "prop-types"; +import { flowRight } from "lodash"; + +import Button from "components/buttons/Button"; +import Reorder from "components/Reorder"; + +import Transition from "./Transition"; +import withCreateCollapsible from "./withCreateCollapsible"; +import CollapsibleEditor from "./CollapsibleEditor"; +import withMoveCollapsible from "./withMoveCollapsible"; + +const DetailList = styled.div` + margin-bottom: 2em; +`; + +const AddButton = styled(Button)` + width: 100%; +`; + +export const CollapsiblesEditor = ({ + collapsibles, + createCollapsible, + moveCollapsible, + introductionId, +}) => ( + + + {(props, collapsible) => ( + + )} + + createCollapsible({ introductionId })} + data-test="add-collapsible-btn" + > + Add collapsible + + +); + +CollapsiblesEditor.fragments = [...CollapsibleEditor.fragments]; + +CollapsiblesEditor.propTypes = { + collapsibles: PropTypes.arrayOf(propType(CollapsibleEditor.fragments[0])) + .isRequired, + createCollapsible: PropTypes.func.isRequired, + moveCollapsible: PropTypes.func.isRequired, + introductionId: PropTypes.string.isRequired, +}; + +export default flowRight( + withCreateCollapsible, + withMoveCollapsible +)(CollapsiblesEditor); diff --git a/eq-author/src/App/introduction/Design/IntroductionEditor/CollapsiblesEditor/index.test.js b/eq-author/src/App/introduction/Design/IntroductionEditor/CollapsiblesEditor/index.test.js new file mode 100644 index 0000000000..8bcd5a033e --- /dev/null +++ b/eq-author/src/App/introduction/Design/IntroductionEditor/CollapsiblesEditor/index.test.js @@ -0,0 +1,60 @@ +import React from "react"; +import { shallow } from "enzyme"; + +import { CollapsiblesEditor } from "./"; + +describe("CollapsiblesEditor", () => { + let props; + beforeEach(() => { + props = { + collapsibles: [ + { + id: "1", + title: "title", + description: "description", + }, + ], + createCollapsible: jest.fn(), + moveCollapsible: jest.fn(), + introductionId: "introId", + }; + }); + + it("should render", () => { + const wrapper = shallow(); + expect(wrapper).toMatchSnapshot(); + }); + + it("should render the collapsible editor and additional props for the collapsible given", () => { + const wrapper = shallow() + .find("[data-test='collapsibles-list']") + .renderProp("children")({ prop1: "1", prop2: 2 }, props.collapsibles[0]); + + expect(wrapper.props()).toMatchObject({ + collapsible: props.collapsibles[0], + prop1: "1", + prop2: 2, + }); + }); + + it("should create the collapsible when the add button is clicked", () => { + shallow() + .find("[data-test='add-collapsible-btn']") + .simulate("click"); + + expect(props.createCollapsible).toHaveBeenCalledWith({ + introductionId: "introId", + }); + }); + + it("should move the collapsible when move is triggered", () => { + shallow() + .find("[data-test='collapsibles-list']") + .simulate("move", { id: "1", position: 2 }); + + expect(props.moveCollapsible).toHaveBeenCalledWith({ + id: "1", + position: 2, + }); + }); +}); diff --git a/eq-author/src/App/introduction/Design/IntroductionEditor/CollapsiblesEditor/withCreateCollapsible.js b/eq-author/src/App/introduction/Design/IntroductionEditor/CollapsiblesEditor/withCreateCollapsible.js new file mode 100644 index 0000000000..c0e5dc71be --- /dev/null +++ b/eq-author/src/App/introduction/Design/IntroductionEditor/CollapsiblesEditor/withCreateCollapsible.js @@ -0,0 +1,39 @@ +import { graphql } from "react-apollo"; +import gql from "graphql-tag"; +import { filter } from "graphql-anywhere"; + +const mutation = gql` + mutation createCollapsible($input: CreateCollapsibleInput!) { + createCollapsible(input: $input) { + id + title + description + introduction { + id + collapsibles { + id + } + } + } + } +`; +const inputFilter = gql` + { + introductionId + title + description + } +`; + +export const mapMutateToProps = ({ mutate }) => ({ + createCollapsible: collapsible => { + const data = filter(inputFilter, collapsible); + return mutate({ + variables: { input: data }, + }); + }, +}); + +export default graphql(mutation, { + props: mapMutateToProps, +}); diff --git a/eq-author/src/App/introduction/Design/IntroductionEditor/CollapsiblesEditor/withCreateCollapsible.test.js b/eq-author/src/App/introduction/Design/IntroductionEditor/CollapsiblesEditor/withCreateCollapsible.test.js new file mode 100644 index 0000000000..721c69caa3 --- /dev/null +++ b/eq-author/src/App/introduction/Design/IntroductionEditor/CollapsiblesEditor/withCreateCollapsible.test.js @@ -0,0 +1,32 @@ +import { mapMutateToProps } from "./withCreateCollapsible"; + +describe("withCreateCollapsible", () => { + let mutate; + beforeEach(() => { + mutate = jest.fn(); + }); + + it("should return a createCollapsible func", () => { + expect(mapMutateToProps({ mutate }).createCollapsible).toBeInstanceOf( + Function + ); + }); + + it("should filter the args to what is allowed and call mutate", () => { + mapMutateToProps({ mutate }).createCollapsible({ + introductionId: "introId", + title: "title", + description: "description", + foo: "foo", + }); + expect(mutate).toHaveBeenCalledWith({ + variables: { + input: { + introductionId: "introId", + title: "title", + description: "description", + }, + }, + }); + }); +}); diff --git a/eq-author/src/App/introduction/Design/IntroductionEditor/CollapsiblesEditor/withMoveCollapsible.js b/eq-author/src/App/introduction/Design/IntroductionEditor/CollapsiblesEditor/withMoveCollapsible.js new file mode 100644 index 0000000000..7d8f5ecd6d --- /dev/null +++ b/eq-author/src/App/introduction/Design/IntroductionEditor/CollapsiblesEditor/withMoveCollapsible.js @@ -0,0 +1,36 @@ +import { graphql } from "react-apollo"; +import gql from "graphql-tag"; +import { filter } from "graphql-anywhere"; + +const mutation = gql` + mutation moveCollapsible($input: MoveCollapsibleInput!) { + moveCollapsible(input: $input) { + id + introduction { + id + collapsibles { + id + } + } + } + } +`; +const inputFilter = gql` + { + id + position + } +`; + +export const mapMutateToProps = ({ mutate }) => ({ + moveCollapsible: collapsible => { + const data = filter(inputFilter, collapsible); + return mutate({ + variables: { input: data }, + }); + }, +}); + +export default graphql(mutation, { + props: mapMutateToProps, +}); diff --git a/eq-author/src/App/introduction/Design/IntroductionEditor/CollapsiblesEditor/withMoveCollapsible.test.js b/eq-author/src/App/introduction/Design/IntroductionEditor/CollapsiblesEditor/withMoveCollapsible.test.js new file mode 100644 index 0000000000..6766296258 --- /dev/null +++ b/eq-author/src/App/introduction/Design/IntroductionEditor/CollapsiblesEditor/withMoveCollapsible.test.js @@ -0,0 +1,30 @@ +import { mapMutateToProps } from "./withMoveCollapsible"; + +describe("withMoveCollapsible", () => { + let mutate; + beforeEach(() => { + mutate = jest.fn(); + }); + + it("should return a moveCollapsible func", () => { + expect(mapMutateToProps({ mutate }).moveCollapsible).toBeInstanceOf( + Function + ); + }); + + it("should filter the args to what is allowed and call mutate", () => { + mapMutateToProps({ mutate }).moveCollapsible({ + id: "id", + position: 1, + foo: "foo", + }); + expect(mutate).toHaveBeenCalledWith({ + variables: { + input: { + id: "id", + position: 1, + }, + }, + }); + }); +}); diff --git a/eq-author/src/App/introduction/Design/IntroductionEditor/LegalBasisField.js b/eq-author/src/App/introduction/Design/IntroductionEditor/LegalBasisField.js new file mode 100644 index 0000000000..1bd2cb2236 --- /dev/null +++ b/eq-author/src/App/introduction/Design/IntroductionEditor/LegalBasisField.js @@ -0,0 +1,172 @@ +import React from "react"; +import styled, { css } from "styled-components"; +import PropTypes from "prop-types"; + +import { Input } from "components/Forms"; +import { colors } from "constants/theme"; + +import iconCheck from "./icon-check.svg"; + +const LegalField = styled.div` + display: flex; + margin-bottom: 2em; +`; + +const LegalInput = styled(Input)` + position: absolute; + overflow: hidden; + clip: rect(0 0 0 0); + height: 1px; + width: 1px; + margin: -1px; + padding: 0; + border: 0; + &:hover, + &:focus { + border: none; + outline: none; + box-shadow: none; + } +`; + +const LegalLabel = styled.label` + padding: 2.5em 1.5em; + border-radius: 4px; + border: 1px solid ${colors.bordersLight}; + flex: 1 1 33.3333333%; + text-align: center; + cursor: pointer; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + background: #fff; + box-shadow: 0 2px 2px 0 rgba(0, 0, 0, 0.18); + color: ${colors.textLight}; + position: relative; + transition: padding 200ms ease-in-out; + + &:not(:last-of-type) { + margin-right: 1em; + } + + &:focus-within { + border-color: ${colors.blue}; + outline-color: ${colors.blue}; + box-shadow: 0 0 0 3px ${colors.tertiary}; + } + + &::before { + content: url(${iconCheck}); + display: inline-block; + width: 1em; + height: 1em; + margin: 0 auto; + z-index: 1; + transform: scale(0); + opacity: 0; + transition: all 100ms ease-out 100ms; + margin-top: -1.5em; + margin-bottom: 0.5em; + } + + ${props => + props.selected && + css` + border: 1px solid ${colors.primary}; + padding: 3em 1.5em 2em; + &::before { + transform: scale(1); + opacity: 1; + } + `}; +`; + +const LegalTitle = styled.span` + font-size: 0.85em; + font-weight: bold; + text-transform: uppercase; + letter-spacing: 0.05em; + margin: 0 0 1em; + color: ${colors.text}; +`; + +const LegalNotice = styled.span` + font-weight: bold; + margin-bottom: 1em; + width: 8em; +`; + +const LegalDescription = styled.span` + font-size: 1em; +`; + +export const LegalOption = ({ name, value, children, onChange, selected }) => ( + + + {children} + +); +LegalOption.propTypes = { + name: PropTypes.string.isRequired, + value: PropTypes.string.isRequired, + children: PropTypes.node.isRequired, + onChange: PropTypes.func.isRequired, + selected: PropTypes.bool.isRequired, +}; + +const OPTIONS = [ + { + title: "Notice 1", + notice: true, + description: + "Notice is given under section 1 of the Statistics of Trade Act 1947.", + value: "NOTICE_1", + }, + { + title: "Notice 2", + notice: true, + description: + "Notice is given under sections 3 and 4 of the Statistics of Trade Act 1947.", + value: "NOTICE_2", + }, + { + title: "Voluntary", + description: "No legal notice will be displayed.", + value: "VOLUNTARY", + }, +]; + +const LegalBasisField = ({ name, value, onChange, ...rest }) => ( + + {OPTIONS.map(option => ( + + {option.title} + {option.notice && ( + Your response is legally required. + )} + {option.description} + + ))} + +); + +LegalBasisField.propTypes = { + name: PropTypes.string.isRequired, + value: PropTypes.string.isRequired, + onChange: PropTypes.func.isRequired, +}; + +export default LegalBasisField; diff --git a/eq-author/src/App/introduction/Design/IntroductionEditor/LegalBasisField.test.js b/eq-author/src/App/introduction/Design/IntroductionEditor/LegalBasisField.test.js new file mode 100644 index 0000000000..9df6e0e133 --- /dev/null +++ b/eq-author/src/App/introduction/Design/IntroductionEditor/LegalBasisField.test.js @@ -0,0 +1,51 @@ +import React from "react"; +import { shallow } from "enzyme"; + +import LegalBasisField, { LegalOption } from "./LegalBasisField"; + +describe("LegalBasisField", () => { + let props; + + beforeEach(() => { + props = { + name: "legalBasis", + value: "NOTICE_1", + onChange: jest.fn(), + }; + }); + it("should render", () => { + expect(shallow()).toMatchSnapshot(); + }); + + it("should show the option matching the value as selected", () => { + const option = shallow().find( + "[selected=true]" + ); + expect(option.prop("value")).toEqual(props.value); + }); + + it("picking the option should trigger onChange", () => { + shallow() + .find("[value='NOTICE_2']") + .simulate("change", { name: props.name, value: "NOTICE_2" }); + + expect(props.onChange).toHaveBeenCalledWith({ + name: props.name, + value: "NOTICE_2", + }); + }); + + describe("LegalOption", () => { + it("should render", () => { + const props = { + name: "legalBasis", + value: "NOTICE_1", + onChange: jest.fn(), + selected: false, + }; + expect( + shallow(hello world) + ).toMatchSnapshot(); + }); + }); +}); diff --git a/eq-author/src/App/introduction/Design/IntroductionEditor/__snapshots__/LegalBasisField.test.js.snap b/eq-author/src/App/introduction/Design/IntroductionEditor/__snapshots__/LegalBasisField.test.js.snap new file mode 100644 index 0000000000..a1cfa7b7ce --- /dev/null +++ b/eq-author/src/App/introduction/Design/IntroductionEditor/__snapshots__/LegalBasisField.test.js.snap @@ -0,0 +1,70 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`LegalBasisField LegalOption should render 1`] = ` + + + hello world + +`; + +exports[`LegalBasisField should render 1`] = ` + + + + Notice 1 + + + Your response is legally required. + + + Notice is given under section 1 of the Statistics of Trade Act 1947. + + + + + Notice 2 + + + Your response is legally required. + + + Notice is given under sections 3 and 4 of the Statistics of Trade Act 1947. + + + + + Voluntary + + + No legal notice will be displayed. + + + +`; diff --git a/eq-author/src/App/introduction/Design/IntroductionEditor/__snapshots__/index.test.js.snap b/eq-author/src/App/introduction/Design/IntroductionEditor/__snapshots__/index.test.js.snap new file mode 100644 index 0000000000..f00d44ed83 --- /dev/null +++ b/eq-author/src/App/introduction/Design/IntroductionEditor/__snapshots__/index.test.js.snap @@ -0,0 +1,181 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`IntroductionEditor should render 1`] = ` + + + + + Introduction content + + + This content is displayed above the “start survey” button. The title is not editable. + + + + + Legal basis + + + + + + + + Secondary content + + + This content is displayed below the “start survey” button. + + + + + Collapsibles + + + Information which is displayed in a collapsible “twistie”. + + + + + + + + Tertiary content + + + + + + +`; diff --git a/eq-author/src/App/introduction/Design/IntroductionEditor/icon-check.svg b/eq-author/src/App/introduction/Design/IntroductionEditor/icon-check.svg new file mode 100644 index 0000000000..c95e0e66dc --- /dev/null +++ b/eq-author/src/App/introduction/Design/IntroductionEditor/icon-check.svg @@ -0,0 +1,9 @@ + + + + icon-check copy + Created with Sketch. + + + + \ No newline at end of file diff --git a/eq-author/src/App/introduction/Design/IntroductionEditor/index.js b/eq-author/src/App/introduction/Design/IntroductionEditor/index.js new file mode 100644 index 0000000000..8396b5d1ba --- /dev/null +++ b/eq-author/src/App/introduction/Design/IntroductionEditor/index.js @@ -0,0 +1,208 @@ +import React from "react"; +import gql from "graphql-tag"; +import styled from "styled-components"; +import { flowRight, noop } from "lodash/fp"; +import { propType } from "graphql-anywhere"; +import PropTypes from "prop-types"; + +import withPropRenamed from "enhancers/withPropRenamed"; +import withChangeUpdate from "enhancers/withChangeUpdate"; + +import RichTextEditor from "components/RichTextEditor"; +import withEntityEditor from "components/withEntityEditor"; + +import { colors } from "constants/theme"; + +import transformNestedFragments from "utils/transformNestedFragments"; + +import LegalBasisField from "./LegalBasisField"; +import CollapsiblesEditor from "./CollapsiblesEditor"; + +import withUpdateQuestionnaireIntroduction from "./withUpdateQuestionnaireIntroduction"; + +const Section = styled.section` + &:not(:last-of-type) { + border-bottom: 1px solid #e0e0e0; + margin-bottom: 2em; + } +`; + +const Padding = styled.div` + padding: 0 2em; +`; + +const SectionTitle = styled.h2` + font-size: 1.1em; + font-weight: bold; + color: ${colors.text}; + margin: 0 0 1em; +`; + +const SectionDescription = styled.p` + margin: 0.1em 0 1em; + color: ${colors.textLight}; +`; + +const titleControls = { + emphasis: true, + piping: true, +}; + +const descriptionControls = { + bold: true, + emphasis: true, + piping: true, + list: true, +}; + +export const IntroductionEditor = ({ introduction, onChangeUpdate }) => { + const { + id, + collapsibles, + title, + description, + secondaryTitle, + secondaryDescription, + tertiaryTitle, + tertiaryDescription, + legalBasis, + } = introduction; + + return ( + <> +
    + + + Introduction content + + + This content is displayed above the “start survey” button. The title + is not editable. + + + + Legal basis + + +
    +
    + + + Secondary content + + + This content is displayed below the “start survey” button. + + + + + Collapsibles + + + Information which is displayed in a collapsible “twistie”. + + + +
    +
    + + Tertiary content + + + +
    + + ); +}; +const fragment = gql` + fragment IntroductionEditor on QuestionnaireIntroduction { + id + title + description + secondaryTitle + secondaryDescription + collapsibles { + ...CollapsibleEditor + } + tertiaryTitle + tertiaryDescription + legalBasis + } +`; + +IntroductionEditor.fragments = [fragment, ...CollapsiblesEditor.fragments]; + +IntroductionEditor.propTypes = { + introduction: propType( + transformNestedFragments(fragment, CollapsiblesEditor.fragments) + ).isRequired, + onChangeUpdate: PropTypes.func.isRequired, +}; + +const withWrappers = flowRight( + withUpdateQuestionnaireIntroduction, + withPropRenamed("updateQuestionnaireIntroduction", "onUpdate"), + withEntityEditor("introduction"), + withChangeUpdate +); + +export default withWrappers(IntroductionEditor); diff --git a/eq-author/src/App/introduction/Design/IntroductionEditor/index.test.js b/eq-author/src/App/introduction/Design/IntroductionEditor/index.test.js new file mode 100644 index 0000000000..039ce963ee --- /dev/null +++ b/eq-author/src/App/introduction/Design/IntroductionEditor/index.test.js @@ -0,0 +1,28 @@ +import React from "react"; +import { shallow } from "enzyme"; + +import { IntroductionEditor } from "./"; + +describe("IntroductionEditor", () => { + let props; + beforeEach(() => { + props = { + introduction: { + id: "1", + title: "title", + description: "description", + secondaryTitle: "secondary title", + secondaryDescription: "secondary description", + collapsibles: [], + tertiaryTitle: "tertiary title", + tertiaryDescription: "tertiary description", + legalBasis: "VOLUNTARY", + }, + onChangeUpdate: jest.fn(), + }; + }); + + it("should render", () => { + expect(shallow()).toMatchSnapshot(); + }); +}); diff --git a/eq-author/src/App/introduction/Design/IntroductionEditor/withUpdateQuestionnaireIntroduction.js b/eq-author/src/App/introduction/Design/IntroductionEditor/withUpdateQuestionnaireIntroduction.js new file mode 100644 index 0000000000..ec86546068 --- /dev/null +++ b/eq-author/src/App/introduction/Design/IntroductionEditor/withUpdateQuestionnaireIntroduction.js @@ -0,0 +1,52 @@ +import { graphql } from "react-apollo"; +import gql from "graphql-tag"; +import { filter } from "graphql-anywhere"; + +const mutation = gql` + mutation UpdateQuestionnaireIntroduction( + $input: UpdateQuestionnaireIntroductionInput! + ) { + updateQuestionnaireIntroduction(input: $input) { + id + title + description + legalBasis + secondaryTitle + secondaryDescription + tertiaryTitle + tertiaryDescription + } + } +`; +const inputFilter = gql` + { + id + title + description + legalBasis + secondaryTitle + secondaryDescription + tertiaryTitle + tertiaryDescription + } +`; + +export const mapMutateToProps = ({ mutate }) => ({ + updateQuestionnaireIntroduction: introduction => { + const data = filter(inputFilter, introduction); + return mutate({ + variables: { input: data }, + optimisticResponse: { + updateQuestionnaireIntroduction: { + ...introduction, + ...data, + __typename: "QuestionnaireIntroduction", + }, + }, + }); + }, +}); + +export default graphql(mutation, { + props: mapMutateToProps, +}); diff --git a/eq-author/src/App/introduction/Design/IntroductionEditor/withUpdateQuestionnaireIntroduction.test.js b/eq-author/src/App/introduction/Design/IntroductionEditor/withUpdateQuestionnaireIntroduction.test.js new file mode 100644 index 0000000000..e0becdb651 --- /dev/null +++ b/eq-author/src/App/introduction/Design/IntroductionEditor/withUpdateQuestionnaireIntroduction.test.js @@ -0,0 +1,40 @@ +import { mapMutateToProps } from "./withUpdateQuestionnaireIntroduction"; + +describe("withUpdateQuestionnaireIntroduction", () => { + let mutate; + beforeEach(() => { + mutate = jest.fn(); + }); + + it("should return a updateQuestionnaireIntroduction func", () => { + expect( + mapMutateToProps({ mutate }).updateQuestionnaireIntroduction + ).toBeInstanceOf(Function); + }); + + it("should filter the args to what is allowed and call mutate", () => { + mapMutateToProps({ mutate }).updateQuestionnaireIntroduction({ + introductionId: "introId", + title: "title", + description: "description", + foo: "foo", + }); + expect(mutate).toHaveBeenCalledWith({ + optimisticResponse: { + updateQuestionnaireIntroduction: { + introductionId: "introId", + title: "title", + description: "description", + foo: "foo", + __typename: "QuestionnaireIntroduction", + }, + }, + variables: { + input: { + title: "title", + description: "description", + }, + }, + }); + }); +}); diff --git a/eq-author/src/App/introduction/Design/index.js b/eq-author/src/App/introduction/Design/index.js new file mode 100644 index 0000000000..cf6b284278 --- /dev/null +++ b/eq-author/src/App/introduction/Design/index.js @@ -0,0 +1,78 @@ +import React from "react"; +import PropTypes from "prop-types"; +import { Query } from "react-apollo"; +import gql from "graphql-tag"; +import { get, isEmpty } from "lodash/fp"; +import { propType } from "graphql-anywhere"; + +import Loading from "components/Loading"; +import Error from "components/Error"; + +import transformNestedFragments from "utils/transformNestedFragments"; + +import IntroductionLayout from "../IntroductionLayout"; + +import IntroductionEditor from "./IntroductionEditor"; + +export const IntroductionDesign = ({ loading, error, data }) => { + if (loading) { + return ( + + Page loading… + + ); + } + + const introduction = get("questionnaireIntroduction", data); + if (error || isEmpty(introduction)) { + return Something went wrong; + } + + return ( + + + + ); +}; + +IntroductionDesign.propTypes = { + loading: PropTypes.bool.isRequired, + error: PropTypes.object, // eslint-disable-line + data: PropTypes.shape({ + introduction: propType(...IntroductionEditor.fragments), + }), +}; + +const query = gql` + query GetQuestionnaireIntroduction($id: ID!) { + questionnaireIntroduction(id: $id) { + id + ...IntroductionEditor + } + } +`; + +const INTRODUCTION_QUERY = transformNestedFragments( + query, + IntroductionEditor.fragments +); + +const IntroductionDesignWithData = props => ( + + {queryProps => } + +); +IntroductionDesignWithData.propTypes = { + match: PropTypes.shape({ + params: PropTypes.shape({ + introductionId: PropTypes.string.isRequired, + }).isRequired, + }).isRequired, +}; + +export default IntroductionDesignWithData; diff --git a/eq-author/src/App/introduction/Design/index.test.js b/eq-author/src/App/introduction/Design/index.test.js new file mode 100644 index 0000000000..21fc805c0d --- /dev/null +++ b/eq-author/src/App/introduction/Design/index.test.js @@ -0,0 +1,50 @@ +import React from "react"; +import { shallow } from "enzyme"; + +import Loading from "components/Loading"; +import { default as ErrorComponent } from "components/Error"; + +import { IntroductionDesign } from "./"; +import IntroductionEditor from "./IntroductionEditor"; + +describe("Introduction Design", () => { + let props; + + beforeEach(() => { + props = { + loading: false, + error: null, + data: { + questionnaireIntroduction: { + id: "1", + }, + }, + }; + }); + + it("should render the editor when loaded", () => { + expect( + shallow().find(IntroductionEditor) + ).toHaveLength(1); + }); + + it("should render loading whilst loading", () => { + expect( + shallow().find(Loading) + ).toHaveLength(1); + }); + + it("should render error when there is no data but it is loaded", () => { + props.data = null; + expect( + shallow().find(ErrorComponent) + ).toHaveLength(1); + }); + + it("should render error when there an error and it has loaded", () => { + props.error = new Error("It broke"); + expect( + shallow().find(ErrorComponent) + ).toHaveLength(1); + }); +}); diff --git a/eq-author/src/App/introduction/IntroductionLayout.js b/eq-author/src/App/introduction/IntroductionLayout.js new file mode 100644 index 0000000000..00537bb268 --- /dev/null +++ b/eq-author/src/App/introduction/IntroductionLayout.js @@ -0,0 +1,28 @@ +import React from "react"; +import PropTypes from "prop-types"; +import { Titled } from "react-titled"; +import styled from "styled-components"; + +import EditorLayout from "App/page/Design/EditorLayout"; + +const Padding = styled.div` + padding: 2em 0 0; + position: relative; + width: 100%; +`; + +const setTitle = title => `Introduction — ${title}`; + +const IntroductionLayout = ({ children }) => ( + + + {children} + + +); + +IntroductionLayout.propTypes = { + children: PropTypes.node.isRequired, +}; + +export default IntroductionLayout; diff --git a/eq-author/src/App/introduction/IntroductionLayout.test.js b/eq-author/src/App/introduction/IntroductionLayout.test.js new file mode 100644 index 0000000000..c233dc1dad --- /dev/null +++ b/eq-author/src/App/introduction/IntroductionLayout.test.js @@ -0,0 +1,29 @@ +import React from "react"; +import { shallow } from "enzyme"; +import { Titled } from "react-titled"; + +import IntroductionLayout from "./IntroductionLayout"; + +describe("IntroductionLayout", () => { + it("should render", () => { + expect( + shallow( + +
    + + ) + ).toMatchSnapshot(); + }); + + it("should apply introduction to the title", () => { + const titleFunc = shallow( + +
    + + ) + .find(Titled) + .prop("title"); + + expect(titleFunc("Something")).toEqual("Introduction — Something"); + }); +}); diff --git a/eq-author/src/App/introduction/Preview/__snapshots__/index.test.js.snap b/eq-author/src/App/introduction/Preview/__snapshots__/index.test.js.snap new file mode 100644 index 0000000000..eccdab8aad --- /dev/null +++ b/eq-author/src/App/introduction/Preview/__snapshots__/index.test.js.snap @@ -0,0 +1,90 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`Introduction Preview should render 1`] = ` + + +

    + If the company details or structure have changed contact us on + + + 0300 1234 931 + + or email + + surveys@ons.gov.uk + +

    + bar

    ", + } + } + data-test="description" + /> + + + Your response is legally required + +

    + Notice is given under section 1 of the Statistics of Trade Act 1947. +

    +
    + + Start survey + + + secondaryDescription

    ", + } + } + /> + + +
    +`; + +exports[`Introduction Preview should show section 1 legal basis when it is notice 1 1`] = ` + + + Your response is legally required + +

    + Notice is given under section 1 of the Statistics of Trade Act 1947. +

    +
    +`; + +exports[`Introduction Preview should show section 3 and 4 legal basis when it is notice 2 1`] = ` + + + Your response is legally required + +

    + Notice is given under section 3 and 4 of the Statistics of Trade Act 1947. +

    +
    +`; diff --git a/eq-author/src/App/introduction/Preview/icon-chevron.svg b/eq-author/src/App/introduction/Preview/icon-chevron.svg new file mode 100644 index 0000000000..14a96c473d --- /dev/null +++ b/eq-author/src/App/introduction/Preview/icon-chevron.svg @@ -0,0 +1,7 @@ + + + + + + + \ No newline at end of file diff --git a/eq-author/src/App/introduction/Preview/index.js b/eq-author/src/App/introduction/Preview/index.js new file mode 100644 index 0000000000..980c5c2071 --- /dev/null +++ b/eq-author/src/App/introduction/Preview/index.js @@ -0,0 +1,222 @@ +/* eslint-disable react/no-danger */ +import React from "react"; +import { Query } from "react-apollo"; +import gql from "graphql-tag"; +import styled from "styled-components"; +import { propType } from "graphql-anywhere"; +import PropTypes from "prop-types"; + +import Loading from "components/Loading"; +import { colors } from "constants/theme"; +import PageTitle from "components/preview/elements/PageTitle"; + +import IntroductionLayout from "../IntroductionLayout"; + +import iconChevron from "./icon-chevron.svg"; + +const Container = styled.div` + padding: 0 2em 2em; + max-width: 40em; + font-size: 1.1em; + p { + margin: 0 0 1em; + } + p:last-of-type { + margin-bottom: 0; + } + em { + background-color: #dce5b0; + padding: 0 0.125em; + font-style: normal; + } + span[data-piped] { + background-color: #e0e0e0; + padding: 0 0.125em; + border-radius: 4px; + white-space: pre; + } +`; + +const H2 = styled.h2` + font-size: 1.2em; + margin: 0 0 0.2rem; +`; + +const Description = styled.div` + margin-bottom: 1rem; + + li { + margin-bottom: 0.3em; + } +`; + +const Button = styled.div` + color: white; + background-color: #0f8243; + border: 2px solid #0f8243; + padding: 0.75rem 1rem; + margin: 0; + font-size: 1rem; + font-weight: 600; + border-radius: 3px; + display: inline-block; + text-rendering: optimizeLegibility; + margin-bottom: 2em; +`; + +export const Collapsibles = styled.div` + margin-bottom: 1em; +`; + +const CollapsiblesTitle = styled.div` + display: flex; + align-items: center; + color: ${colors.primary}; + margin-bottom: 0.5em; + &::before { + width: 32px; + height: 32px; + display: inline-block; + margin-left: -10px; + content: url(${iconChevron}); + transform: rotate(90deg); + } +`; + +const CollapsiblesContent = styled.div` + border-left: 2px solid #999; + margin-left: 6px; + padding: 0.2em 0 0.2em 1em; +`; + +const Link = styled.span` + text-decoration: underline; + color: ${colors.primary}; +`; + +export const IntroductionPreview = ({ loading, data }) => { + if (loading) { + return Preview loading…; + } + + const { + questionnaireIntroduction: { + title, + description, + legalBasis, + secondaryTitle, + secondaryDescription, + collapsibles, + tertiaryTitle, + tertiaryDescription, + }, + } = data; + + return ( + + +

    + If the company details or structure have changed contact us on{" "} + 0300 1234 931 or email surveys@ons.gov.uk +

    + + {legalBasis === "NOTICE_1" && ( + +

    Your response is legally required

    +

    + Notice is given under section 1 of the Statistics of Trade Act 1947. +

    +
    + )} + {legalBasis === "NOTICE_2" && ( + +

    Your response is legally required

    +

    + Notice is given under section 3 and 4 of the Statistics of Trade Act + 1947. +

    +
    + )} + + + + {collapsibles + .filter(collapsible => collapsible.title && collapsible.description) + .map((collapsible, index) => ( + + + + + + + ))} + + +
    + ); +}; + +const fragment = gql` + fragment QuestionnaireIntroduction on QuestionnaireIntroduction { + id + title + description + legalBasis + secondaryTitle + secondaryDescription + collapsibles { + id + title + description + } + tertiaryTitle + tertiaryDescription + } +`; + +IntroductionPreview.propTypes = { + data: PropTypes.shape({ + questionnaireIntroduction: propType(fragment), + }), + loading: PropTypes.bool.isRequired, +}; + +export const QUESTIONNAIRE_QUERY = gql` + query GetQuestionnaireIntroduction($id: ID!) { + questionnaireIntroduction(id: $id) { + ...QuestionnaireIntroduction + } + } + ${fragment} +`; + +const IntroductionPreviewWithData = props => ( + + + {innerProps => } + + +); +IntroductionPreviewWithData.propTypes = { + match: PropTypes.shape({ + params: PropTypes.shape({ + introductionId: PropTypes.string.isRequired, + }).isRequired, + }).isRequired, +}; + +export default IntroductionPreviewWithData; diff --git a/eq-author/src/App/introduction/Preview/index.test.js b/eq-author/src/App/introduction/Preview/index.test.js new file mode 100644 index 0000000000..916da14189 --- /dev/null +++ b/eq-author/src/App/introduction/Preview/index.test.js @@ -0,0 +1,80 @@ +import React from "react"; +import { shallow } from "enzyme"; + +import Loading from "components/Loading"; + +import { IntroductionPreview, Collapsibles } from "./"; + +describe("Introduction Preview", () => { + let props; + beforeEach(() => { + props = { + loading: false, + data: { + questionnaireIntroduction: { + id: "1", + title: "foo", + description: "

    bar

    ", + secondaryTitle: "secondaryTitle", + secondaryDescription: "

    secondaryDescription

    ", + legalBasis: "NOTICE_1", + collapsibles: [], + tertiaryTitle: "tertiaryTitle", + tertiaryDescription: "tertiaryDescription", + }, + }, + }; + }); + + it("should render", () => { + expect(shallow()).toMatchSnapshot(); + }); + + it("should not show incomplete collapsibles", () => { + props.data.questionnaireIntroduction.collapsibles = [ + { id: "2", title: "collapsible title", description: "" }, + { id: "3", title: "", description: "collapsible description" }, + { + id: "4", + title: "collapsible title", + description: "collapsible description", + }, + ]; + expect( + shallow().find(Collapsibles) + ).toHaveLength(1); + }); + + it("should show loading when loading", () => { + expect( + shallow().find(Loading) + ).toHaveLength(1); + }); + + it("should show no legal description when legal basis is voluntary", () => { + props.data.questionnaireIntroduction.legalBasis = "VOLUNTARY"; + expect( + shallow().find( + "[data-test='legalBasis']" + ) + ).toHaveLength(0); + }); + + it("should show section 1 legal basis when it is notice 1", () => { + props.data.questionnaireIntroduction.legalBasis = "NOTICE_1"; + expect( + shallow().find( + "[data-test='legalBasis']" + ) + ).toMatchSnapshot(); + }); + + it("should show section 3 and 4 legal basis when it is notice 2", () => { + props.data.questionnaireIntroduction.legalBasis = "NOTICE_2"; + expect( + shallow().find( + "[data-test='legalBasis']" + ) + ).toMatchSnapshot(); + }); +}); diff --git a/eq-author/src/App/introduction/__snapshots__/IntroductionLayout.test.js.snap b/eq-author/src/App/introduction/__snapshots__/IntroductionLayout.test.js.snap new file mode 100644 index 0000000000..a30608b468 --- /dev/null +++ b/eq-author/src/App/introduction/__snapshots__/IntroductionLayout.test.js.snap @@ -0,0 +1,15 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`IntroductionLayout should render 1`] = ` + + + +
    + + + +`; diff --git a/eq-author/src/App/introduction/index.js b/eq-author/src/App/introduction/index.js new file mode 100644 index 0000000000..ea392150d7 --- /dev/null +++ b/eq-author/src/App/introduction/index.js @@ -0,0 +1,19 @@ +import React from "react"; + +import { Route } from "react-router-dom"; + +import Design from "./Design"; +import Preview from "./Preview"; + +export default [ + , + , +]; diff --git a/eq-author/src/App/page/Design/CalculatedSummaryPageEditor/__snapshots__/index.test.js.snap b/eq-author/src/App/page/Design/CalculatedSummaryPageEditor/__snapshots__/index.test.js.snap index 8f0f0b61b4..726159afb9 100644 --- a/eq-author/src/App/page/Design/CalculatedSummaryPageEditor/__snapshots__/index.test.js.snap +++ b/eq-author/src/App/page/Design/CalculatedSummaryPageEditor/__snapshots__/index.test.js.snap @@ -48,6 +48,7 @@ exports[`CalculatedSummaryPageEditor should render 1`] = ` } } defaultTab="variables" + disabled={false} fetchAnswers={[MockFunction]} id="summary-title" label="Page title" @@ -93,6 +94,7 @@ exports[`CalculatedSummaryPageEditor should render 1`] = ` "piping": true, } } + disabled={false} fetchAnswers={[MockFunction]} id="total-title" label="Total title" diff --git a/eq-author/src/App/page/Design/QuestionPageEditor/AnswersEditor/AnswerEditor/__snapshots__/index.test.js.snap b/eq-author/src/App/page/Design/QuestionPageEditor/AnswersEditor/AnswerEditor/__snapshots__/index.test.js.snap index 9c95970852..10144b2838 100644 --- a/eq-author/src/App/page/Design/QuestionPageEditor/AnswersEditor/AnswerEditor/__snapshots__/index.test.js.snap +++ b/eq-author/src/App/page/Design/QuestionPageEditor/AnswersEditor/AnswerEditor/__snapshots__/index.test.js.snap @@ -260,7 +260,7 @@ exports[`Answer Editor should render Currency 1`] = ` - diff --git a/eq-author/src/App/page/Design/QuestionPageEditor/AnswersEditor/AnswerEditor/index.js b/eq-author/src/App/page/Design/QuestionPageEditor/AnswersEditor/AnswerEditor/index.js index ac2cbea59c..4869ba583b 100644 --- a/eq-author/src/App/page/Design/QuestionPageEditor/AnswersEditor/AnswerEditor/index.js +++ b/eq-author/src/App/page/Design/QuestionPageEditor/AnswersEditor/AnswerEditor/index.js @@ -6,11 +6,7 @@ import gql from "graphql-tag"; import fp from "lodash/fp"; import CustomPropTypes from "custom-prop-types"; -import DeleteButton from "components/buttons/DeleteButton"; -import MultipleChoiceAnswer from "App/page/Design/answers/MultipleChoiceAnswer"; -import DateRange from "App/page/Design/answers/DateRange"; -import Date from "App/page/Design/answers/Date"; import { TEXTFIELD, NUMBER, @@ -21,13 +17,14 @@ import { RADIO, DATE_RANGE, } from "constants/answer-types"; -import CurrencyAnswer from "App/page/Design/answers/CurrencyAnswer"; import Tooltip from "components/Forms/Tooltip"; -import BasicAnswer from "App/page/Design/answers/BasicAnswer"; +import DeleteButton from "components/buttons/DeleteButton"; +import MoveButton, { IconUp, IconDown } from "components/buttons/MoveButton"; -import MoveButton from "./MoveButton"; -import IconUp from "./icon-arrow-up.svg?inline"; -import IconDown from "./icon-arrow-down.svg?inline"; +import MultipleChoiceAnswer from "App/page/Design/answers/MultipleChoiceAnswer"; +import DateRange from "App/page/Design/answers/DateRange"; +import Date from "App/page/Design/answers/Date"; +import BasicAnswer from "App/page/Design/answers/BasicAnswer"; const Answer = styled.div` border: 1px solid ${colors.bordersLight}; @@ -38,6 +35,7 @@ const Answer = styled.div` border-color: ${colors.blue}; box-shadow: 0 0 0 1px ${colors.blue}; } + margin: 1em 2em; `; const AnswerHeader = styled.div` @@ -91,12 +89,9 @@ class AnswerEditor extends React.Component { if ([TEXTFIELD, TEXTAREA].includes(type)) { return ; } - if ([PERCENTAGE, NUMBER].includes(type)) { + if ([PERCENTAGE, NUMBER, CURRENCY].includes(type)) { return ; } - if (type === CURRENCY) { - return ; - } if (type === CHECKBOX) { return ; } diff --git a/eq-author/src/App/page/Design/QuestionPageEditor/AnswersEditor/__snapshots__/index.test.js.snap b/eq-author/src/App/page/Design/QuestionPageEditor/AnswersEditor/__snapshots__/index.test.js.snap index 75a19ed0f9..8dc1fe2bfb 100644 --- a/eq-author/src/App/page/Design/QuestionPageEditor/AnswersEditor/__snapshots__/index.test.js.snap +++ b/eq-author/src/App/page/Design/QuestionPageEditor/AnswersEditor/__snapshots__/index.test.js.snap @@ -1,73 +1,20 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP exports[`Answers Editor should render 1`] = ` - - - - - - - - - - - - + + `; diff --git a/eq-author/src/App/page/Design/QuestionPageEditor/AnswersEditor/index.js b/eq-author/src/App/page/Design/QuestionPageEditor/AnswersEditor/index.js index a0ad31702e..6d0578e1c8 100644 --- a/eq-author/src/App/page/Design/QuestionPageEditor/AnswersEditor/index.js +++ b/eq-author/src/App/page/Design/QuestionPageEditor/AnswersEditor/index.js @@ -1,47 +1,14 @@ -import React, { useState, useRef, useEffect } from "react"; +import React from "react"; import PropTypes from "prop-types"; import { propType } from "graphql-anywhere"; -import styled, { keyframes, css } from "styled-components"; -import { TransitionGroup } from "react-transition-group"; -import getIdForObject from "utils/getIdForObject"; +import Reorder from "components/Reorder"; import withMoveAnswer from "./withMoveAnswer"; import AnswerTransition from "./AnswerTransition"; import AnswerEditor from "./AnswerEditor"; -const MOVE_DURATION = 400; -const UP = "UP"; -const DOWN = "DOWN"; - -const move = ({ transform, scale }) => keyframes` - 0% { - transform: translateY(0) scale(1); - } - 50% { - transform: scale(${scale}); - } - 100% { - transform: translateY(calc(${transform})) scale(1); - } -`; - -export const AnswerSegment = styled.div` - padding: 1em 2em; - z-index: ${props => props.movement.zIndex}; - transform-origin: 50% 50%; - animation: ${({ movement }) => - movement.transform !== 0 && - css` - ${move( - movement - )} ${MOVE_DURATION}ms cubic-bezier(0.785, 0.135, 0.150, 0.860) 0s forwards 1; - `}; -`; - -const startingStyles = answers => answers.map(() => ({ transform: 0 })); - -export const UnwrappedAnswersEditor = ({ +export const AnswersEditor = ({ answers, onUpdate, onAddOption, @@ -51,108 +18,25 @@ export const UnwrappedAnswersEditor = ({ onDeleteAnswer, moveAnswer, }) => { - const [renderedAnswers, setRenderedAnswers] = useState(answers); - const [isTransitioning, setIsTransitioning] = useState(false); - const [answerStyles, setAnswerStyles] = useState(startingStyles(answers)); - - const hasNewAnswers = useRef(false); - const answerElements = useRef([]); - const prevAnswers = useRef(answers); - const animationTimeout = useRef(); - - if (prevAnswers.current !== answers) { - prevAnswers.current = answers; - hasNewAnswers.current = true; - } - - if (hasNewAnswers.current && !isTransitioning) { - setRenderedAnswers(answers); - setAnswerStyles(startingStyles(answers)); - hasNewAnswers.current = false; - } - - const handleRef = (node, index) => { - if (!node) { - return; - } - - answerElements.current[index] = node.getBoundingClientRect().height; - }; - - useEffect( - () => { - return () => { - if (animationTimeout.current) { - clearTimeout(animationTimeout.current); - animationTimeout.current = null; - } - }; - }, - [animationTimeout] - ); - - const handleMove = (answer, index, direction) => { - const isUp = direction === UP; - const indexA = index; - const indexB = isUp ? index - 1 : index + 1; - - const heightA = answerElements.current[indexA]; - const heightB = answerElements.current[indexB]; - - const newAnswerStyles = [...answerStyles]; - - newAnswerStyles[indexA] = { - transform: isUp ? `${0 - heightB}px` : `${heightB}px`, - zIndex: 2, - scale: 1.05, - }; - - newAnswerStyles[indexB] = { - transform: isUp ? `${heightA}px` : `${0 - heightA}px`, - zIndex: 1, - scale: 0.95, - }; - - setIsTransitioning(true); - setAnswerStyles(newAnswerStyles); - moveAnswer({ id: answer.id, position: indexB }); - animationTimeout.current = setTimeout(() => { - setIsTransitioning(false); - }, MOVE_DURATION); - }; - return ( - - {renderedAnswers.map((answer, index) => ( - - handleRef(node, index)} - movement={answerStyles[index]} - > - handleMove(answer, index, UP)} - onMoveDown={() => handleMove(answer, index, DOWN)} - canMoveUp={!isTransitioning && index > 0} - canMoveDown={ - !isTransitioning && index < renderedAnswers.length - 1 - } - onUpdate={onUpdate} - onAddOption={onAddOption} - onAddExclusive={onAddExclusive} - onUpdateOption={onUpdateOption} - onDeleteOption={onDeleteOption} - onDeleteAnswer={onDeleteAnswer} - /> - - - ))} - + + {(props, answer) => ( + + )} + ); }; -UnwrappedAnswersEditor.propTypes = { +AnswersEditor.propTypes = { answers: PropTypes.arrayOf(propType(AnswerEditor.fragments.AnswerEditor)) .isRequired, onUpdate: PropTypes.func.isRequired, @@ -164,8 +48,8 @@ UnwrappedAnswersEditor.propTypes = { moveAnswer: PropTypes.func.isRequired, }; -UnwrappedAnswersEditor.fragments = { +AnswersEditor.fragments = { AnswersEditor: AnswerEditor.fragments.AnswerEditor, }; -export default withMoveAnswer(UnwrappedAnswersEditor); +export default withMoveAnswer(AnswersEditor); diff --git a/eq-author/src/App/page/Design/QuestionPageEditor/AnswersEditor/index.test.js b/eq-author/src/App/page/Design/QuestionPageEditor/AnswersEditor/index.test.js index 4eb3689312..c7f3fa487e 100644 --- a/eq-author/src/App/page/Design/QuestionPageEditor/AnswersEditor/index.test.js +++ b/eq-author/src/App/page/Design/QuestionPageEditor/AnswersEditor/index.test.js @@ -1,12 +1,7 @@ import React from "react"; -import { shallow, mount } from "enzyme"; -import { act } from "react-dom/test-utils"; +import { shallow } from "enzyme"; -import TestProvider from "tests/utils/TestProvider"; - -import { UnwrappedAnswersEditor, AnswerSegment } from "./"; - -import AnswerEditor from "./AnswerEditor"; +import { AnswersEditor } from "./"; describe("Answers Editor", () => { jest.useFakeTimers(); @@ -27,114 +22,24 @@ describe("Answers Editor", () => { }); it("should render", () => { - const wrapper = shallow(); + const wrapper = shallow(); expect(wrapper).toMatchSnapshot(); }); - it("should call moveAnswer with the new index when an answer is moved down", () => { - const wrapper = shallow(); - wrapper - .find(AnswerEditor) - .at(0) - .simulate("moveDown"); - expect(props.moveAnswer).toHaveBeenCalledWith({ - id: "1", - position: 1, - }); - }); - - it("should call moveAnswer with the new index when an answer is moved up", () => { - const wrapper = shallow(); - wrapper - .find(AnswerEditor) - .at(1) - .simulate("moveUp"); - expect(props.moveAnswer).toHaveBeenCalledWith({ - id: "2", - position: 0, - }); - }); - - it("should not be able to moved down if its the last in the list", () => { - const wrapper = shallow(); - const editor = wrapper.find(AnswerEditor).at(1); - expect(editor.prop("canMoveDown")).toEqual(false); - expect(editor.prop("canMoveUp")).toEqual(true); - }); - - it("should not be able to moved up if its the first in the list", () => { - const wrapper = shallow(); - const editor = wrapper.find(AnswerEditor).at(0); - expect(editor.prop("canMoveDown")).toEqual(true); - expect(editor.prop("canMoveUp")).toEqual(false); - }); - - it("does not blow up when the segment ref is null", () => { - const wrapper = shallow(); - expect(() => - wrapper - .find(AnswerSegment) - .at(0) - .props() - .innerRef(null) - ).not.toThrow(); - }); - - it("should get the height of the element to work out how much to transition", () => { - const wrapper = shallow(); - const getBoundingClientRect = jest.fn().mockReturnValue({ height: 1 }); - wrapper - .find(AnswerSegment) - .at(0) - .props() - .innerRef({ getBoundingClientRect }); - - expect(getBoundingClientRect).toHaveBeenCalledWith(); - }); - - it("should not blow up if we unmount after triggering a move", () => { - const store = { - subscribe: jest.fn(), - dispatch: jest.fn(), - getState: jest.fn(), + it("should render the collapsible editor and additional props for the collapsible given", () => { + const extraProps = { + canMoveDown: false, + canMoveUp: false, + onMoveUp: jest.fn(), + onMoveDown: jest.fn(), }; - props.answers = [ - { - id: "1", - description: "description", - guidance: "guidance", - label: "label", - type: "Currency", - }, - { - id: "2", - description: "description", - guidance: "guidance", - label: "label", - type: "Currency", - }, - ]; - - const wrapper = mount( - - - - ); - - act(() => { - wrapper - .find("[data-test='btn-move-answer-up']") - .at(1) - .simulate("click"); - }); - - act(() => { - wrapper.unmount(); - }); + const wrapper = shallow().renderProp( + "children" + )(extraProps, props.answers[0]); - act(() => { - // This will throw an error if the timeout is not cleared - jest.runAllTimers(); + expect(wrapper.props()).toMatchObject({ + answer: props.answers[0], + ...extraProps, }); }); }); diff --git a/eq-author/src/App/page/Design/QuestionPageEditor/__snapshots__/AdditionalInfo.test.js.snap b/eq-author/src/App/page/Design/QuestionPageEditor/__snapshots__/AdditionalInfo.test.js.snap index c4bb00f78b..8ed8699b09 100644 --- a/eq-author/src/App/page/Design/QuestionPageEditor/__snapshots__/AdditionalInfo.test.js.snap +++ b/eq-author/src/App/page/Design/QuestionPageEditor/__snapshots__/AdditionalInfo.test.js.snap @@ -43,6 +43,7 @@ exports[`AdditionalInfo should render 1`] = ` "piping": true, } } + disabled={false} fetchAnswers={[MockFunction]} id="additional-info-content" label="Content" diff --git a/eq-author/src/App/page/Design/QuestionPageEditor/__snapshots__/MetaEditor.test.js.snap b/eq-author/src/App/page/Design/QuestionPageEditor/__snapshots__/MetaEditor.test.js.snap index 584d6ea2f4..bccaac4425 100644 --- a/eq-author/src/App/page/Design/QuestionPageEditor/__snapshots__/MetaEditor.test.js.snap +++ b/eq-author/src/App/page/Design/QuestionPageEditor/__snapshots__/MetaEditor.test.js.snap @@ -10,6 +10,7 @@ exports[`MetaEditor should render 1`] = ` "piping": true, } } + disabled={false} fetchAnswers={[MockFunction]} id="question-title" label="Question" @@ -40,6 +41,7 @@ exports[`MetaEditor should render 1`] = ` "piping": true, } } + disabled={false} fetchAnswers={[MockFunction]} id="question-description" label="Question description" @@ -93,6 +95,7 @@ exports[`MetaEditor should render 1`] = ` "piping": true, } } + disabled={false} fetchAnswers={[MockFunction]} id="definition-content" label="Content" @@ -121,6 +124,7 @@ exports[`MetaEditor should render 1`] = ` "piping": true, } } + disabled={false} fetchAnswers={[MockFunction]} id="question-guidance" label="Include/exclude" diff --git a/eq-author/src/App/page/Design/answers/CurrencyAnswer/CurrencyAnswer.test.js b/eq-author/src/App/page/Design/answers/CurrencyAnswer/CurrencyAnswer.test.js deleted file mode 100644 index da0091dfd7..0000000000 --- a/eq-author/src/App/page/Design/answers/CurrencyAnswer/CurrencyAnswer.test.js +++ /dev/null @@ -1,35 +0,0 @@ -import React from "react"; -import { shallow } from "enzyme"; -import CurrencyAnswer from "./"; -import createMockStore from "tests/utils/createMockStore"; - -const answer = { - title: "Lorem ipsum", - description: "Nullam id dolor id nibh ultricies.", -}; - -describe("CurrencyAnswer", () => { - let handleChange; - let handleUpdate; - let component; - let store; - - beforeEach(() => { - handleChange = jest.fn(); - handleUpdate = jest.fn(); - store = createMockStore(); - - component = shallow( - - ); - }); - - it("should render", () => { - expect(component).toMatchSnapshot(); - }); -}); diff --git a/eq-author/src/App/page/Design/answers/CurrencyAnswer/__snapshots__/CurrencyAnswer.test.js.snap b/eq-author/src/App/page/Design/answers/CurrencyAnswer/__snapshots__/CurrencyAnswer.test.js.snap deleted file mode 100644 index 6299c2d08d..0000000000 --- a/eq-author/src/App/page/Design/answers/CurrencyAnswer/__snapshots__/CurrencyAnswer.test.js.snap +++ /dev/null @@ -1,28 +0,0 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP - -exports[`CurrencyAnswer should render 1`] = ` - - - - - -`; diff --git a/eq-author/src/App/page/Design/answers/CurrencyAnswer/index.js b/eq-author/src/App/page/Design/answers/CurrencyAnswer/index.js deleted file mode 100644 index 01897a8d18..0000000000 --- a/eq-author/src/App/page/Design/answers/CurrencyAnswer/index.js +++ /dev/null @@ -1,56 +0,0 @@ -import React from "react"; -import PropTypes from "prop-types"; -import styled from "styled-components"; -import { colors, radius } from "constants/theme"; -import BasicAnswer from "../BasicAnswer"; - -const StyledSpan = styled.span` - display: inline-block; - background-color: ${colors.lighterGrey}; - border-right: 1px solid ${colors.borders}; - border-radius: ${radius} 0 0 ${radius}; - padding: 0.6em 0; - width: 2.5em; - font-weight: 700; - font-size: 1em; - line-height: 1.1; - text-align: center; - position: absolute; - left: 0; - top: 0; - color: ${colors.lightGrey}; -`; - -const FieldWrapper = styled.div` - display: block; - width: 50%; - margin-bottom: 1em; - position: relative; - overflow: hidden; -`; - -const CurrencyComponent = props => ( - {props.currencyUnit} -); - -CurrencyComponent.propTypes = { - currencyUnit: PropTypes.string, -}; - -CurrencyComponent.defaultProps = { - currencyUnit: "£", -}; - -const CurrencyAnswer = props => ( - - - - - -); - -CurrencyAnswer.fragments = { - Currency: BasicAnswer.fragments.Answer, -}; - -export default CurrencyAnswer; diff --git a/eq-author/src/App/questionConfirmation/Design/__snapshots__/Editor.test.js.snap b/eq-author/src/App/questionConfirmation/Design/__snapshots__/Editor.test.js.snap index 1b180d8166..08dd8fd513 100644 --- a/eq-author/src/App/questionConfirmation/Design/__snapshots__/Editor.test.js.snap +++ b/eq-author/src/App/questionConfirmation/Design/__snapshots__/Editor.test.js.snap @@ -12,6 +12,7 @@ exports[`Editor Editor Component should autoFocus the title when there is not on } } data-test="title-input" + disabled={false} id="confirmation-title" label="Confirmation question" multiline={false} @@ -65,6 +66,7 @@ exports[`Editor Editor Component should render 1`] = ` } } data-test="title-input" + disabled={false} id="confirmation-title" label="Confirmation question" multiline={false} diff --git a/eq-author/src/App/section/Design/SectionEditor/__snapshots__/SectionEditor.test.js.snap b/eq-author/src/App/section/Design/SectionEditor/__snapshots__/SectionEditor.test.js.snap index 49bbd0f03c..d1826072f5 100644 --- a/eq-author/src/App/section/Design/SectionEditor/__snapshots__/SectionEditor.test.js.snap +++ b/eq-author/src/App/section/Design/SectionEditor/__snapshots__/SectionEditor.test.js.snap @@ -26,6 +26,7 @@ exports[`SectionEditor should render 1`] = ` "emphasis": true, } } + disabled={false} id="section-title" label={ keyframes` + 0% { + transform: translateY(0) scale(1); + } + 50% { + transform: scale(${scale}); + } + 100% { + transform: translateY(calc(${transform})) scale(1); + } +`; + +export const Segment = styled.div` + z-index: ${props => props.movement.zIndex}; + transform-origin: 50% 50%; + animation: ${({ movement }) => + movement.transform !== 0 && + css` + ${move( + movement + )} ${MOVE_DURATION}ms cubic-bezier(0.785, 0.135, 0.150, 0.860) 0s forwards 1; + `}; +`; + +const startingStyles = items => items.map(() => ({ transform: 0 })); + +const Reorder = ({ list, onMove, children, Transition }) => { + const OuterWrapper = Transition ? TransitionGroup : React.Fragment; + const InnerWrapper = Transition || React.Fragment; + + const [renderedItems, setRenderedItems] = useState(list); + const [isTransitioning, setIsTransitioning] = useState(false); + const [itemStyles, setItemStyles] = useState(startingStyles(list)); + + const hasNewitems = useRef(false); + const itemElements = useRef([]); + const previtems = useRef(list); + const animationTimeout = useRef(); + + if (previtems.current !== list) { + previtems.current = list; + hasNewitems.current = true; + } + + if (hasNewitems.current && !isTransitioning) { + setRenderedItems(list); + setItemStyles(startingStyles(list)); + hasNewitems.current = false; + } + + const handleRef = (node, index) => { + if (!node) { + return; + } + + itemElements.current[index] = node.getBoundingClientRect().height; + }; + + useEffect( + () => () => { + if (animationTimeout.current) { + clearTimeout(animationTimeout.current); + animationTimeout.current = null; + } + }, + [animationTimeout] + ); + + const handleMove = (item, index, direction) => { + const isUp = direction === UP; + const indexA = index; + const indexB = isUp ? index - 1 : index + 1; + + const heightA = itemElements.current[indexA]; + const heightB = itemElements.current[indexB]; + + const newitemStyles = [...itemStyles]; + + newitemStyles[indexA] = { + transform: isUp ? `${0 - heightB}px` : `${heightB}px`, + zIndex: 2, + scale: 1.05, + }; + + newitemStyles[indexB] = { + transform: isUp ? `${heightA}px` : `${0 - heightA}px`, + zIndex: 1, + scale: 0.95, + }; + + setIsTransitioning(true); + setItemStyles(newitemStyles); + onMove({ id: item.id, position: indexB }); + animationTimeout.current = setTimeout(() => { + setIsTransitioning(false); + }, MOVE_DURATION); + }; + + return ( + + {renderedItems.map((item, index) => { + const itemProps = { + onMoveUp: () => handleMove(item, index, UP), + onMoveDown: () => handleMove(item, index, DOWN), + canMoveUp: !isTransitioning && index > 0, + canMoveDown: !isTransitioning && index < renderedItems.length - 1, + isMoving: isTransitioning, + }; + return ( + + handleRef(node, index)} + movement={itemStyles[index]} + > + {children(itemProps, item)} + + + ); + })} + + ); +}; + +Reorder.propTypes = { + list: PropTypes.array.isRequired, // eslint-disable-line react/forbid-prop-types + onMove: PropTypes.func.isRequired, + children: PropTypes.func.isRequired, + Transition: PropTypes.func, +}; + +export default Reorder; diff --git a/eq-author/src/components/Reorder/index.test.js b/eq-author/src/components/Reorder/index.test.js new file mode 100644 index 0000000000..49de7be1cd --- /dev/null +++ b/eq-author/src/components/Reorder/index.test.js @@ -0,0 +1,190 @@ +import React from "react"; +import { shallow, mount } from "enzyme"; +import { act } from "react-dom/test-utils"; + +import TestProvider from "tests/utils/TestProvider"; + +import Reorder, { Segment } from "./"; + +describe("Reorder", () => { + jest.useFakeTimers(); + + let props; + + beforeEach(() => { + props = { + list: [{ id: "1" }, { id: "2" }], + onMove: jest.fn(), + children: jest.fn(), + }; + }); + + it("should call moveAnswer with the new index when an answer is moved down", () => { + shallow(); + props.children.mock.calls[0][0].onMoveDown(); + expect(props.onMove).toHaveBeenCalledWith({ + id: "1", + position: 1, + }); + }); + + it("should call moveAnswer with the new index when an answer is moved up", () => { + shallow(); + props.children.mock.calls[1][0].onMoveUp(); + expect(props.onMove).toHaveBeenCalledWith({ + id: "2", + position: 0, + }); + }); + + it("should not be able to moved down if its the last in the list", () => { + shallow(); + const itemProps = props.children.mock.calls[1][0]; + expect(itemProps.canMoveDown).toEqual(false); + expect(itemProps.canMoveUp).toEqual(true); + }); + + it("should not be able to moved up if its the first in the list", () => { + shallow(); + const itemProps = props.children.mock.calls[0][0]; + expect(itemProps.canMoveDown).toEqual(true); + expect(itemProps.canMoveUp).toEqual(false); + }); + + it("does not blow up when the segment ref is null", () => { + const wrapper = shallow(); + expect(() => + wrapper + .find(Segment) + .at(0) + .props() + .innerRef(null) + ).not.toThrow(); + }); + + it("should get the height of the element to work out how much to transition", () => { + const wrapper = shallow(); + const getBoundingClientRect = jest.fn().mockReturnValue({ height: 1 }); + wrapper + .find(Segment) + .at(0) + .props() + .innerRef({ getBoundingClientRect }); + + expect(getBoundingClientRect).toHaveBeenCalledWith(); + }); + + it("should not blow up if we unmount after triggering a move", () => { + const store = { + subscribe: jest.fn(), + dispatch: jest.fn(), + getState: jest.fn(), + }; + props.list = [ + { + id: "1", + description: "description", + guidance: "guidance", + label: "label", + type: "Currency", + __typename: "Answer", + }, + { + id: "2", + description: "description", + guidance: "guidance", + label: "label", + type: "Currency", + __typename: "Answer", + }, + ]; + + const wrapper = mount( + + + + ); + + act(() => { + props.children.mock.calls[1][0].onMoveUp(); + }); + + act(() => { + wrapper.unmount(); + }); + + act(() => { + expect(() => jest.runAllTimers()).not.toThrow(); + }); + }); + + it("should not re-render until transitioning has finished", () => { + const store = { + subscribe: jest.fn(), + dispatch: jest.fn(), + getState: jest.fn(), + }; + props.list = [ + { + id: "1", + description: "description", + guidance: "guidance", + label: "label", + type: "Currency", + __typename: "Answer", + }, + { + id: "2", + description: "description", + guidance: "guidance", + label: "label", + type: "Currency", + __typename: "Answer", + }, + ]; + + props.children = jest.fn((props, item) => ( +
    {item.id}
    + )); + + const TestComponent = props => ( + + + + ); + + const wrapper = mount(); + + act(() => { + props.children.mock.calls[1][0].onMoveUp(); + }); + + act(() => { + wrapper.setProps({ + list: props.list.slice().reverse(), + }); + }); + + expect( + wrapper + .find("[data-test='item']") + .at(1) + .text() + ).toEqual("2"); + + act(() => { + jest.runAllTimers(); + }); + + act(() => { + wrapper.mount(); + }); + + expect( + wrapper + .find("[data-test='item']") + .at(1) + .text() + ).toEqual("1"); + }); +}); diff --git a/eq-author/src/components/RichTextEditor/AvailablePipingContentQuery.js b/eq-author/src/components/RichTextEditor/AvailablePipingContentQuery.js index e8a0562508..b6fad056c8 100644 --- a/eq-author/src/components/RichTextEditor/AvailablePipingContentQuery.js +++ b/eq-author/src/components/RichTextEditor/AvailablePipingContentQuery.js @@ -7,7 +7,7 @@ import AvailableAnswers from "graphql/fragments/available-answers.graphql"; import AvailableMetadata from "graphql/fragments/available-metadata.graphql"; export const GET_PIPING_CONTENT_PAGE = gql` - query GetAvailablePipingContent($input: QueryInput!) { + query GetAvailablePipingContentForPage($input: QueryInput!) { page(input: $input) { id displayName @@ -25,7 +25,7 @@ export const GET_PIPING_CONTENT_PAGE = gql` `; export const GET_PIPING_CONTENT_SECTION = gql` - query GetAvailablePipingContent($input: QueryInput!) { + query GetAvailablePipingContentForSection($input: QueryInput!) { section(input: $input) { id displayName @@ -42,7 +42,7 @@ export const GET_PIPING_CONTENT_SECTION = gql` `; export const GET_PIPING_CONTENT_QUESTION_CONFIRMATION = gql` - query GetAvailablePipingContent($id: ID!) { + query GetAvailablePipingContentForQuestionConfirmation($id: ID!) { questionConfirmation(id: $id) { id displayName @@ -58,11 +58,28 @@ export const GET_PIPING_CONTENT_QUESTION_CONFIRMATION = gql` ${AvailableMetadata} `; +export const GET_PIPING_CONTENT_INTRODUCTION = gql` + query GetAvailablePipingContentForQuestionIntroduction($id: ID!) { + questionnaireIntroduction(id: $id) { + id + availablePipingAnswers { + ...AvailableAnswers + } + availablePipingMetadata { + ...AvailableMetadata + } + } + } + ${AvailableAnswers} + ${AvailableMetadata} +`; + const determineQuery = ({ questionnaireId, confirmationId, pageId, sectionId, + introductionId, }) => { if (confirmationId) { return { @@ -76,10 +93,18 @@ const determineQuery = ({ query: GET_PIPING_CONTENT_PAGE, }; } - return { - variables: { input: { questionnaireId, sectionId } }, - query: GET_PIPING_CONTENT_SECTION, - }; + if (sectionId) { + return { + variables: { input: { questionnaireId, sectionId } }, + query: GET_PIPING_CONTENT_SECTION, + }; + } + if (introductionId) { + return { + variables: { id: introductionId }, + query: GET_PIPING_CONTENT_INTRODUCTION, + }; + } }; const AvailablePipingContentQuery = ({ @@ -87,6 +112,7 @@ const AvailablePipingContentQuery = ({ pageId, sectionId, confirmationId, + introductionId, children, }) => { const { variables, query } = determineQuery({ @@ -94,9 +120,10 @@ const AvailablePipingContentQuery = ({ pageId, sectionId, confirmationId, + introductionId, }); return ( - + {children} ); @@ -107,6 +134,7 @@ AvailablePipingContentQuery.propTypes = { pageId: PropTypes.string, sectionId: PropTypes.string, confirmationId: PropTypes.string, + introductionId: PropTypes.string, children: PropTypes.func.isRequired, }; diff --git a/eq-author/src/components/RichTextEditor/AvailablePipingContentQuery.test.js b/eq-author/src/components/RichTextEditor/AvailablePipingContentQuery.test.js index 60cd178ac3..2dcbf2f00e 100644 --- a/eq-author/src/components/RichTextEditor/AvailablePipingContentQuery.test.js +++ b/eq-author/src/components/RichTextEditor/AvailablePipingContentQuery.test.js @@ -6,6 +6,7 @@ import AvailablePipingContentQuery, { GET_PIPING_CONTENT_PAGE, GET_PIPING_CONTENT_SECTION, GET_PIPING_CONTENT_QUESTION_CONFIRMATION, + GET_PIPING_CONTENT_INTRODUCTION, } from "components/RichTextEditor/AvailablePipingContentQuery"; describe("Available Piping Content Query", () => { @@ -13,6 +14,7 @@ describe("Available Piping Content Query", () => { const pageId = "2"; const confirmationId = "3"; const questionnaireId = "4"; + const introductionId = "5"; it("should make a query for section data when on a section page", () => { const wrapper = shallow( @@ -32,7 +34,6 @@ describe("Available Piping Content Query", () => { it("should make a query for page data when on a question page", () => { const wrapper = shallow( @@ -48,8 +49,6 @@ describe("Available Piping Content Query", () => { it("should make a query for confirmation data when on a question confirmation page", () => { const wrapper = shallow( @@ -61,4 +60,19 @@ describe("Available Piping Content Query", () => { query: GET_PIPING_CONTENT_QUESTION_CONFIRMATION, }); }); + + it("should make a query for introduction data when on an introduction page", () => { + const wrapper = shallow( + + {() => {}} + + ); + expect(wrapper.find(Query).props()).toMatchObject({ + variables: { id: introductionId }, + query: GET_PIPING_CONTENT_INTRODUCTION, + }); + }); }); diff --git a/eq-author/src/components/RichTextEditor/PipingMenu.js b/eq-author/src/components/RichTextEditor/PipingMenu.js index cf7364a3b2..ad843fa656 100644 --- a/eq-author/src/components/RichTextEditor/PipingMenu.js +++ b/eq-author/src/components/RichTextEditor/PipingMenu.js @@ -121,14 +121,24 @@ export class Menu extends React.Component { } } -const calculateEntityName = ({ pageId, confirmationId }) => { +const calculateEntityName = ({ + sectionId, + pageId, + confirmationId, + introductionId, +}) => { if (confirmationId) { return "questionConfirmation"; } if (pageId) { return "page"; } - return "section"; + if (sectionId) { + return "section"; + } + if (introductionId) { + return "questionnaireIntroduction"; + } }; export const UnwrappedPipingMenu = props => { @@ -142,6 +152,7 @@ export const UnwrappedPipingMenu = props => { pageId={props.match.params.pageId} sectionId={props.match.params.sectionId} confirmationId={props.match.params.confirmationId} + introductionId={props.match.params.introductionId} > {({ data = {}, ...innerProps }) => { const entityName = calculateEntityName(props.match.params); diff --git a/eq-author/src/components/RichTextEditor/PipingMenu.test.js b/eq-author/src/components/RichTextEditor/PipingMenu.test.js index 8ba7945602..32958d508a 100644 --- a/eq-author/src/components/RichTextEditor/PipingMenu.test.js +++ b/eq-author/src/components/RichTextEditor/PipingMenu.test.js @@ -181,6 +181,13 @@ describe("PipingMenu", () => { sectionId: "3", }, }, + { + name: "questionnaireIntroduction", + params: { + questionnaireId: "4", + introductionId: "5", + }, + }, ]; entities.forEach(({ name, params }) => { diff --git a/eq-author/src/components/RichTextEditor/RichTextEditor.test.js b/eq-author/src/components/RichTextEditor/RichTextEditor.test.js index 094653bf9f..c1cb1f5da0 100644 --- a/eq-author/src/components/RichTextEditor/RichTextEditor.test.js +++ b/eq-author/src/components/RichTextEditor/RichTextEditor.test.js @@ -83,6 +83,11 @@ describe("components/RichTextEditor", function() { expect(wrapper).toMatchSnapshot(); }); + it("should show as disabled and readonly when disabled", () => { + wrapper = shallow(); + expect(wrapper).toMatchSnapshot(); + }); + it("should store a reference to the editor DOM node", () => { wrapper.instance().setEditorInstance(editorInstance); expect(wrapper.instance().editorInstance).toEqual(editorInstance); diff --git a/eq-author/src/components/RichTextEditor/__snapshots__/RichTextEditor.test.js.snap b/eq-author/src/components/RichTextEditor/__snapshots__/RichTextEditor.test.js.snap index 9cafbe6a04..a37af1389d 100644 --- a/eq-author/src/components/RichTextEditor/__snapshots__/RichTextEditor.test.js.snap +++ b/eq-author/src/components/RichTextEditor/__snapshots__/RichTextEditor.test.js.snap @@ -245,6 +245,7 @@ exports[`components/RichTextEditor should allow multiline input 1`] = ` }, ] } + readOnly={false} spellCheck={true} webDriverTestID="test-selector-foo" /> @@ -500,6 +501,7 @@ exports[`components/RichTextEditor should render 1`] = ` }, ] } + readOnly={false} spellCheck={true} webDriverTestID="test-selector-foo" /> @@ -861,6 +863,263 @@ exports[`components/RichTextEditor should render existing content 1`] = ` }, ] } + readOnly={false} + spellCheck={true} + webDriverTestID="test-selector-foo" + /> + + + +`; + +exports[`components/RichTextEditor should show as disabled and readonly when disabled 1`] = ` + + + + + + diff --git a/eq-author/src/components/RichTextEditor/index.js b/eq-author/src/components/RichTextEditor/index.js index 6f18b3025d..158b26168d 100644 --- a/eq-author/src/components/RichTextEditor/index.js +++ b/eq-author/src/components/RichTextEditor/index.js @@ -141,6 +141,7 @@ class RichTextEditor extends React.Component { placeholder: "", multiline: false, autoFocus: false, + disabled: false, }; state = { @@ -169,6 +170,7 @@ class RichTextEditor extends React.Component { alias: PropTypes.string, }) ), + disabled: PropTypes.bool, }; constructor(props) { @@ -457,6 +459,7 @@ class RichTextEditor extends React.Component { testSelector, id, placeholder, + disabled, ...otherProps } = this.props; @@ -468,6 +471,7 @@ class RichTextEditor extends React.Component { onBlur={this.handleBlur} onFocus={this.handleFocus} data-test="rte-field" + disabled={disabled} > diff --git a/eq-author/src/App/page/Design/QuestionPageEditor/AnswersEditor/AnswerEditor/__snapshots__/MoveButton.test.js.snap b/eq-author/src/components/buttons/MoveButton/__snapshots__/index.test.js.snap similarity index 100% rename from eq-author/src/App/page/Design/QuestionPageEditor/AnswersEditor/AnswerEditor/__snapshots__/MoveButton.test.js.snap rename to eq-author/src/components/buttons/MoveButton/__snapshots__/index.test.js.snap diff --git a/eq-author/src/App/page/Design/QuestionPageEditor/AnswersEditor/AnswerEditor/icon-arrow-down.svg b/eq-author/src/components/buttons/MoveButton/icon-arrow-down.svg similarity index 100% rename from eq-author/src/App/page/Design/QuestionPageEditor/AnswersEditor/AnswerEditor/icon-arrow-down.svg rename to eq-author/src/components/buttons/MoveButton/icon-arrow-down.svg diff --git a/eq-author/src/App/page/Design/QuestionPageEditor/AnswersEditor/AnswerEditor/icon-arrow-up.svg b/eq-author/src/components/buttons/MoveButton/icon-arrow-up.svg similarity index 100% rename from eq-author/src/App/page/Design/QuestionPageEditor/AnswersEditor/AnswerEditor/icon-arrow-up.svg rename to eq-author/src/components/buttons/MoveButton/icon-arrow-up.svg diff --git a/eq-author/src/App/page/Design/QuestionPageEditor/AnswersEditor/AnswerEditor/MoveButton.js b/eq-author/src/components/buttons/MoveButton/index.js similarity index 86% rename from eq-author/src/App/page/Design/QuestionPageEditor/AnswersEditor/AnswerEditor/MoveButton.js rename to eq-author/src/components/buttons/MoveButton/index.js index 3e080a18d1..8ff94c4c1a 100644 --- a/eq-author/src/App/page/Design/QuestionPageEditor/AnswersEditor/AnswerEditor/MoveButton.js +++ b/eq-author/src/components/buttons/MoveButton/index.js @@ -3,6 +3,12 @@ import PropTypes from "prop-types"; import styled from "styled-components"; import { colors } from "constants/theme"; +import IconArrowUp from "./icon-arrow-up.svg?inline"; +import IconArrowDown from "./icon-arrow-down.svg?inline"; + +export const IconUp = IconArrowUp; +export const IconDown = IconArrowDown; + const Button = styled.button` display: block; color: ${colors.secondary}; diff --git a/eq-author/src/App/page/Design/QuestionPageEditor/AnswersEditor/AnswerEditor/MoveButton.test.js b/eq-author/src/components/buttons/MoveButton/index.test.js similarity index 95% rename from eq-author/src/App/page/Design/QuestionPageEditor/AnswersEditor/AnswerEditor/MoveButton.test.js rename to eq-author/src/components/buttons/MoveButton/index.test.js index d301886a27..2400e6b255 100644 --- a/eq-author/src/App/page/Design/QuestionPageEditor/AnswersEditor/AnswerEditor/MoveButton.test.js +++ b/eq-author/src/components/buttons/MoveButton/index.test.js @@ -1,7 +1,7 @@ import React from "react"; import { shallow } from "enzyme"; -import MoveButton from "./MoveButton"; +import MoveButton from "."; describe("MoveButton", () => { it("should render", () => { diff --git a/eq-author/src/components/preview/elements/PageTitle.js b/eq-author/src/components/preview/elements/PageTitle.js index 74e56d41bd..b90d2eb380 100644 --- a/eq-author/src/components/preview/elements/PageTitle.js +++ b/eq-author/src/components/preview/elements/PageTitle.js @@ -9,7 +9,7 @@ const Title = styled.h1` margin: 0 0 1em; `; -const PageTitle = ({ title }) => { +const PageTitle = ({ title, missingText = "Missing Page Title" }) => { let pageTitle = title && title.replace(/(]+?>|

    |<\/p>)/gim, ""); return ( @@ -18,7 +18,7 @@ const PageTitle = ({ title }) => { <div dangerouslySetInnerHTML={{ __html: pageTitle }} /> ) : ( <Error data-test="no-title" large> - Missing Page Title + {missingText} </Error> )} @@ -27,6 +27,7 @@ const PageTitle = ({ title }) => { PageTitle.propTypes = { title: PropTypes.string, + missingText: PropTypes.string, }; export default PageTitle; diff --git a/eq-author/src/constants/entities.js b/eq-author/src/constants/entities.js index fb11201032..2529f05c84 100644 --- a/eq-author/src/constants/entities.js +++ b/eq-author/src/constants/entities.js @@ -1,3 +1,4 @@ export const SECTION = "section"; export const PAGE = "page"; export const QUESTION_CONFIRMATION = "question-confirmation"; +export const INTRODUCTION = "introduction"; diff --git a/eq-author/src/custom-prop-types/index.js b/eq-author/src/custom-prop-types/index.js index 0e1ba5ba1c..2e2af35952 100644 --- a/eq-author/src/custom-prop-types/index.js +++ b/eq-author/src/custom-prop-types/index.js @@ -9,7 +9,6 @@ const CustomPropTypes = { questionnaire: PropTypes.shape({ id: PropTypes.string, description: PropTypes.string, - legalBasis: PropTypes.string, theme: PropTypes.string, title: PropTypes.string, navigation: PropTypes.bool, diff --git a/eq-author/src/enhancers/withChangeUpdate.js b/eq-author/src/enhancers/withChangeUpdate.js index 428e110ecd..e76b1b5ee2 100644 --- a/eq-author/src/enhancers/withChangeUpdate.js +++ b/eq-author/src/enhancers/withChangeUpdate.js @@ -9,6 +9,9 @@ const withChangeUpdate = WrappedComponent => { }; static fragments = WrappedComponent.fragments; + static fragments = WrappedComponent.fragments; + static displayName = `withChangeUpdate(${WrappedComponent.displayName})`; + handleUpdate = update => this.props.onChange(update, this.props.onUpdate); render() { diff --git a/eq-author/src/enhancers/withPropRenamed.js b/eq-author/src/enhancers/withPropRenamed.js index 664c88f361..e348bceb04 100644 --- a/eq-author/src/enhancers/withPropRenamed.js +++ b/eq-author/src/enhancers/withPropRenamed.js @@ -1,11 +1,14 @@ import React from "react"; -export default (oldName, newName) => WrappedComponent => - class RemappedComponent extends React.Component { - static fragments = WrappedComponent.fragments; - static displayName = `withPropRenamed(${WrappedComponent.displayName})`; - render() { - const newProps = { ...this.props, [newName]: this.props[oldName] }; - return ; - } +export default (oldName, newName) => WrappedComponent => { + const Component = props => { + const newProps = { + [newName]: props[oldName], + ...props, + }; + return ; }; + Component.fragments = WrappedComponent.fragments; + Component.displayName = `withPropRenamed(${WrappedComponent.displayName})`; + return Component; +}; diff --git a/eq-author/src/graphql/createQuestionnaire.graphql b/eq-author/src/graphql/createQuestionnaire.graphql index 64ae96c733..095fc4ee8f 100644 --- a/eq-author/src/graphql/createQuestionnaire.graphql +++ b/eq-author/src/graphql/createQuestionnaire.graphql @@ -13,5 +13,8 @@ mutation createQuestionnaire($input: CreateQuestionnaireInput!) { id } } + introduction { + id + } } } diff --git a/eq-author/src/graphql/fragments/questionnaire.graphql b/eq-author/src/graphql/fragments/questionnaire.graphql index ab02280a17..cf72061359 100644 --- a/eq-author/src/graphql/fragments/questionnaire.graphql +++ b/eq-author/src/graphql/fragments/questionnaire.graphql @@ -4,7 +4,6 @@ fragment Questionnaire on Questionnaire { description surveyId theme - legalBasis navigation summary type diff --git a/eq-author/src/utils/UrlUtils.js b/eq-author/src/utils/UrlUtils.js index 9aceb7f543..018954e33d 100644 --- a/eq-author/src/utils/UrlUtils.js +++ b/eq-author/src/utils/UrlUtils.js @@ -1,7 +1,12 @@ import { curry } from "lodash"; import { generatePath as rrGeneratePath } from "react-router"; -import { SECTION, PAGE, QUESTION_CONFIRMATION } from "../constants/entities"; +import { + SECTION, + PAGE, + QUESTION_CONFIRMATION, + INTRODUCTION, +} from "../constants/entities"; export const Routes = { HOME: "/", @@ -57,6 +62,17 @@ export const buildConfirmationPath = ({ confirmationId, tab, ...rest }) => { entityName: QUESTION_CONFIRMATION, }); }; +export const buildIntroductionPath = ({ introductionId, tab, ...rest }) => { + if (!introductionId) { + throw new Error("Confirmation id must be provided"); + } + return generatePath(Routes.QUESTIONNAIRE)({ + ...rest, + tab: sanitiseTab(["design", "preview"])(tab), + entityId: introductionId, + entityName: INTRODUCTION, + }); +}; const buildTabSwitcher = tab => params => { let builder; @@ -72,6 +88,9 @@ const buildTabSwitcher = tab => params => { if (params.confirmationId) { builder = buildConfirmationPath; } + if (params.introductionId) { + builder = buildIntroductionPath; + } if (!builder) { throw new Error( `Cannot find builder for params: ${JSON.stringify(params)}` diff --git a/eq-author/src/utils/UrlUtils.test.js b/eq-author/src/utils/UrlUtils.test.js index 91f5afa82d..a6791ca54b 100644 --- a/eq-author/src/utils/UrlUtils.test.js +++ b/eq-author/src/utils/UrlUtils.test.js @@ -3,6 +3,7 @@ import { buildSectionPath, buildPagePath, buildConfirmationPath, + buildIntroductionPath, buildDesignPath, buildPreviewPath, buildRoutingPath, @@ -12,6 +13,7 @@ const questionnaireId = "1"; const sectionId = "2"; const pageId = "3"; const confirmationId = "4"; +const introductionId = "5"; describe("buildQuestionnairePath", () => { it("builds a valid path", () => { @@ -101,6 +103,36 @@ describe("buildConfirmationPath", () => { }); }); +describe("buildIntroductionPath", () => { + it("builds a valid path", () => { + const path = buildIntroductionPath({ + questionnaireId, + introductionId, + tab: "preview", + }); + expect(path).toEqual( + `/q/${questionnaireId}/introduction/${introductionId}/preview` + ); + }); + + it("throws if any param not supplied", () => { + expect(() => buildIntroductionPath({})).toThrow(); + expect(() => buildIntroductionPath({ questionnaireId })).toThrow(); + expect(() => buildIntroductionPath({ introductionId })).toThrow(); + }); + + it("rejects invalid tabs", () => { + const path = buildIntroductionPath({ + questionnaireId, + introductionId, + tab: "routing", + }); + expect(path).toEqual( + `/q/${questionnaireId}/introduction/${introductionId}/design` + ); + }); +}); + describe("buildDesignPath", () => { it("builds a page design path", () => { const path = buildDesignPath({ @@ -130,6 +162,17 @@ describe("buildDesignPath", () => { `/q/${questionnaireId}/question-confirmation/${confirmationId}/design` ); }); + + it("builds an introduction design path", () => { + const path = buildDesignPath({ + questionnaireId, + introductionId, + tab: "preview", + }); + expect(path).toEqual( + `/q/${questionnaireId}/introduction/${introductionId}/design` + ); + }); }); describe("buildPreviewPath", () => { @@ -161,6 +204,17 @@ describe("buildPreviewPath", () => { `/q/${questionnaireId}/question-confirmation/${confirmationId}/preview` ); }); + + it("builds an introduction Preview path", () => { + const path = buildPreviewPath({ + questionnaireId, + introductionId, + tab: "preview", + }); + expect(path).toEqual( + `/q/${questionnaireId}/introduction/${introductionId}/preview` + ); + }); }); describe("buildRoutingPath", () => { diff --git a/eq-publisher/.eslintrc b/eq-publisher/.eslintrc index c5575f71a4..1ca4d2380d 100644 --- a/eq-publisher/.eslintrc +++ b/eq-publisher/.eslintrc @@ -9,6 +9,6 @@ "sourceType": "script" }, "rules": { - "camelcase": 0 + "camelcase": "off" } } diff --git a/eq-publisher/src/constants/legalBases.js b/eq-publisher/src/constants/legalBases.js new file mode 100644 index 0000000000..665af50561 --- /dev/null +++ b/eq-publisher/src/constants/legalBases.js @@ -0,0 +1,16 @@ +const contentMap = { + NOTICE_1: + "Notice is given under section 1 of the Statistics of Trade Act 1947.", + NOTICE_2: + "Notice is given under sections 3 and 4 of the Statistics of Trade Act 1947.", + VOLUNTARY: null, +}; + +module.exports.types = Object.keys(contentMap).reduce( + (hash, key) => ({ + ...hash, + [key]: key, + }), + {} +); +module.exports.contentMap = contentMap; diff --git a/eq-publisher/src/eq_schema/Question.js b/eq-publisher/src/eq_schema/Question.js index 44b93e2a5b..d0d43a0fcc 100644 --- a/eq-publisher/src/eq_schema/Question.js +++ b/eq-publisher/src/eq_schema/Question.js @@ -106,7 +106,6 @@ class Question { ) { last(this.answers).guidance = { show_guidance: question.additionalInfoLabel, - hide_guidance: question.additionalInfoLabel, ...processContent(ctx)(question.additionalInfoContent), }; diff --git a/eq-publisher/src/eq_schema/Question.test.js b/eq-publisher/src/eq_schema/Question.test.js index 64d8e8774b..da93c27beb 100644 --- a/eq-publisher/src/eq_schema/Question.test.js +++ b/eq-publisher/src/eq_schema/Question.test.js @@ -413,7 +413,6 @@ describe("Question", () => { expect.objectContaining({ minimum: { value: "2017-02-17", - offset_by: { days: -4, }, @@ -424,7 +423,6 @@ describe("Question", () => { expect.objectContaining({ maximum: { value: "2018-02-17", - offset_by: { years: 10, }, diff --git a/eq-publisher/src/eq_schema/Questionnaire.js b/eq-publisher/src/eq_schema/Questionnaire.js index 55813d0a80..f54a51b873 100644 --- a/eq-publisher/src/eq_schema/Questionnaire.js +++ b/eq-publisher/src/eq_schema/Questionnaire.js @@ -1,9 +1,15 @@ const { last } = require("lodash"); const { SOCIAL } = require("../constants/questionnaireTypes"); +const { + types: { VOLUNTARY }, + contentMap, +} = require("../constants/legalBases"); + const Section = require("./Section"); const Summary = require("./block-types/Summary"); const Confirmation = require("./block-types/Confirmation"); +const Introduction = require("./block-types/Introduction"); const DEFAULT_METADATA = [ { @@ -40,9 +46,11 @@ class Questionnaire { const ctx = this.createContext(questionnaireJson); this.sections = this.buildSections(questionnaireJson.sections, ctx); + this.buildIntroduction(questionnaireJson.introduction, ctx); + this.theme = questionnaireJson.type === SOCIAL ? SOCIAL_THEME : DEFAULT_THEME; - this.legal_basis = questionnaireJson.legalBasis; + this.legal_basis = this.buildLegalBasis(questionnaireJson.introduction); this.navigation = { visible: questionnaireJson.navigation, }; @@ -82,6 +90,24 @@ class Questionnaire { return [...DEFAULT_METADATA, ...userMetadata]; } + + buildLegalBasis(introduction) { + if (!introduction || introduction.legalBasis === VOLUNTARY) { + return undefined; + } + return contentMap[introduction.legalBasis]; + } + + buildIntroduction(introduction, ctx) { + if (!introduction) { + return; + } + const groupToAddTo = this.sections[0].groups[0]; + groupToAddTo.blocks = [ + new Introduction(introduction, ctx), + ...groupToAddTo.blocks, + ]; + } } Questionnaire.DEFAULT_METADATA = DEFAULT_METADATA; diff --git a/eq-publisher/src/eq_schema/Questionnaire.test.js b/eq-publisher/src/eq_schema/Questionnaire.test.js index 609d1e4c12..41d139c976 100644 --- a/eq-publisher/src/eq_schema/Questionnaire.test.js +++ b/eq-publisher/src/eq_schema/Questionnaire.test.js @@ -1,6 +1,9 @@ const { last } = require("lodash"); const { BUSINESS, SOCIAL } = require("../constants/questionnaireTypes"); +const { + types: { NOTICE_1, VOLUNTARY }, +} = require("../constants/legalBases"); const Questionnaire = require("./Questionnaire"); const Summary = require("./block-types/Summary"); @@ -14,7 +17,6 @@ describe("Questionnaire", () => { title: "Quarterly Business Survey", description: "Quarterly Business Survey", type: BUSINESS, - legalBasis: "StatisticsOfTradeAct", navigation: false, surveyId: "0112", summary: true, @@ -26,6 +28,10 @@ describe("Questionnaire", () => { }, ], metadata: [], + introduction: { + legalBasis: NOTICE_1, + collapsibles: [], + }, }, questionnaire ); @@ -45,11 +51,28 @@ describe("Questionnaire", () => { title: "Quarterly Business Survey", theme: "default", sections: [expect.any(Section)], - legal_basis: "StatisticsOfTradeAct", + legal_basis: + "Notice is given under section 1 of the Statistics of Trade Act 1947.", metadata: expect.arrayContaining(Questionnaire.DEFAULT_METADATA), }); }); + it("should not set a legal basis when there is no introduction", () => { + questionnaire = new Questionnaire( + createQuestionnaireJSON({ introduction: null }) + ); + expect(questionnaire.legal_basis).toEqual(undefined); + }); + + it("should not set a legal basis when the legal basis is voluntary", () => { + questionnaire = new Questionnaire( + createQuestionnaireJSON({ + introduction: { legalBasis: VOLUNTARY, collapsibles: [] }, + }) + ); + expect(questionnaire.legal_basis).toEqual(undefined); + }); + it("should set the theme based on the type", () => { questionnaire = new Questionnaire( createQuestionnaireJSON({ type: SOCIAL }) diff --git a/eq-publisher/src/eq_schema/block-types/Introduction.js b/eq-publisher/src/eq_schema/block-types/Introduction.js new file mode 100644 index 0000000000..a818bb2980 --- /dev/null +++ b/eq-publisher/src/eq_schema/block-types/Introduction.js @@ -0,0 +1,71 @@ +const { flow } = require("lodash"); +const convertPipes = require("../../utils/convertPipes"); +const { + parseContent, + getInnerHTMLWithPiping, +} = require("../../utils/HTMLUtils"); + +const processContent = ctx => + flow( + convertPipes(ctx), + parseContent + ); + +const getSimpleText = (content, ctx) => + flow( + convertPipes(ctx), + getInnerHTMLWithPiping + )(content); + +const getComplexText = (content, ctx) => { + const result = processContent(ctx)(content); + if (result) { + return result.content; + } + return undefined; +}; + +module.exports = class Introduction { + constructor( + { + description, + secondaryTitle, + secondaryDescription, + collapsibles, + tertiaryTitle, + tertiaryDescription, + }, + ctx + ) { + this.type = "Introduction"; + this.id = "introduction-block"; + + this.primary_content = [ + { + type: "Basic", + id: "primary", + content: getComplexText(description, ctx), + }, + ]; + + this.preview_content = { + id: "preview", + title: getSimpleText(secondaryTitle, ctx), + content: getComplexText(secondaryDescription, ctx), + questions: collapsibles + .filter(collapsible => collapsible.title && collapsible.description) + .map(({ title, description }) => ({ + question: title, + content: getComplexText(description, ctx), + })), + }; + + this.secondary_content = [ + { + id: "secondary-content", + title: getSimpleText(tertiaryTitle, ctx), + content: getComplexText(tertiaryDescription, ctx), + }, + ]; + } +}; diff --git a/eq-publisher/src/eq_schema/block-types/Introduction.test.js b/eq-publisher/src/eq_schema/block-types/Introduction.test.js new file mode 100644 index 0000000000..b1ec583db2 --- /dev/null +++ b/eq-publisher/src/eq_schema/block-types/Introduction.test.js @@ -0,0 +1,137 @@ +const Introduction = require("./Introduction"); + +const piping = '[some_metadata]'; + +describe("Introduction", () => { + let apiData, context; + beforeEach(() => { + apiData = { + id: "1", + 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 aren’t available.
    • We will treat your data securely and confidentially.
    • ${piping}
    `, + legalBasis: "NOTICE_2", + secondaryTitle: `

    Information you need ${piping}

    `, + secondaryDescription: + "

    You can select the dates of the period you are reporting for, if the given dates are not appropriate.

    ", + collapsibles: [ + { + id: "d45bf1dd-f286-40ca-b6a2-fe0014574c36", + title: "

    Hello

    ", + description: `

    World ${piping}

    `, + }, + { + id: "1e7e5ecd-6f4c-4219-9893-6efdeea36ad0", + title: "

    Collapsible

    ", + description: "

    Description

    ", + }, + ], + tertiaryTitle: `

    How we use your data ${piping}

    `, + tertiaryDescription: `
    • You cannot appeal your selection. Your business was selected to give us a comprehensive view of the UK economy.
    • The information you provide contributes to Gross Domestic Product (GDP).
    • ${piping}
    `, + }; + context = { + questionnaireJson: { + metadata: [{ id: "1", key: "some_metadata" }], + }, + }; + }); + + it("set the correct id and type", () => { + const introduction = new Introduction(apiData, context); + expect(introduction.type).toEqual("Introduction"); + expect(introduction.id).toEqual("introduction-block"); + }); + + it("should define the primary_content", () => { + const introduction = new Introduction(apiData, context); + expect(introduction.primary_content).toMatchObject([ + { + content: [ + { + list: [ + "Data should relate to all sites in England, Scotland, Wales and Northern Ireland unless otherwise stated. ", + "You can provide info estimates if actual figures aren’t available.", + "We will treat your data securely and confidentially.", + "{{ metadata['some_metadata'] }}", + ], + }, + ], + id: "primary", + type: "Basic", + }, + ]); + }); + + it("should define the preview_content from the secondary settings", () => { + const introduction = new Introduction(apiData, context); + expect(introduction.preview_content).toMatchObject({ + content: [ + { + description: + "You can select the dates of the period you are reporting for, if the given dates are not appropriate.", + }, + ], + id: "preview", + questions: expect.any(Array), + title: "Information you need {{ metadata['some_metadata'] }}", + }); + }); + + it("should define the preview_content questions from collapsibles", () => { + const introduction = new Introduction(apiData, context); + expect(introduction.preview_content.questions).toMatchObject([ + { + content: [{ description: "World {{ metadata['some_metadata'] }}" }], + question: "

    Hello

    ", + }, + { + content: [{ description: "Description" }], + question: "

    Collapsible

    ", + }, + ]); + }); + + it("should not publish partially completed collapsibles", () => { + apiData.collapsibles = [ + { + id: "d45bf1dd-f286-40ca-b6a2-fe0014574c36", + title: "

    Hello

    ", + description: "

    World

    ", + }, + { + id: "d45bf1dd-f286-40ca-b6a2-fe0014574c36", + title: "

    Hello

    ", + description: "", + }, + { + id: "d45bf1dd-f286-40ca-b6a2-fe0014574c36", + title: "", + description: "

    Description

    ", + }, + ]; + const introduction = new Introduction(apiData, context); + expect(introduction.preview_content.questions).toMatchObject([ + { + content: [{ description: "World" }], + question: "

    Hello

    ", + }, + ]); + }); + + it("should define the secondary_content from the tertiary settings", () => { + const introduction = new Introduction(apiData, context); + expect(introduction.secondary_content).toMatchObject([ + { + content: [ + { + list: [ + "You cannot appeal your selection. Your business was selected to give us a comprehensive view of the UK economy.", + "The information you provide contributes to Gross Domestic Product (GDP).", + "{{ metadata['some_metadata'] }}", + ], + }, + ], + id: "secondary-content", + title: "How we use your data {{ metadata['some_metadata'] }}", + }, + ]); + }); +}); diff --git a/eq-publisher/src/getQuestionnaire/queries.js b/eq-publisher/src/getQuestionnaire/queries.js index af9c6f5b82..4bbf804874 100644 --- a/eq-publisher/src/getQuestionnaire/queries.js +++ b/eq-publisher/src/getQuestionnaire/queries.js @@ -155,7 +155,21 @@ exports.getQuestionnaire = ` title description type - legalBasis + introduction { + id + title + description + legalBasis + secondaryTitle + secondaryDescription + collapsibles { + id + title + description + } + tertiaryTitle + tertiaryDescription + } navigation surveyId summary diff --git a/eq-publisher/src/middleware/createAuthToken.test.js b/eq-publisher/src/middleware/createAuthToken.test.js index 9175bd8687..9d92a0116a 100644 --- a/eq-publisher/src/middleware/createAuthToken.test.js +++ b/eq-publisher/src/middleware/createAuthToken.test.js @@ -34,7 +34,6 @@ describe("createAuthToken", () => { email: "eq.team@ons.gov.uk", name: "Publisher", picture: "", - user_id: "Publisher", }); });