diff --git a/cloudbuild-author-tests.yaml b/cloudbuild-author-tests.yaml index 477ffd3bb6..fa78e810e7 100644 --- a/cloudbuild-author-tests.yaml +++ b/cloudbuild-author-tests.yaml @@ -26,6 +26,6 @@ steps: exit $(( 1 - $result )) id: unit_tests entrypoint: /bin/ash -timeout: 1200s +timeout: 1800s options: machineType: E2_HIGHCPU_8 diff --git a/cloudbuild.yaml b/cloudbuild.yaml index 1158f038a8..cd6470ed30 100644 --- a/cloudbuild.yaml +++ b/cloudbuild.yaml @@ -1,10 +1,10 @@ steps: - - name: 'node:$_NODE_VERSION' + - name: "node:$_NODE_VERSION" id: yarn_install_and_build dir: eq-author entrypoint: /bin/bash args: - - '-c' + - "-c" - | if [ $_ENV = "staging" ]; then yarn install @@ -21,8 +21,8 @@ steps: entrypoint: sh waitFor: - yarn_install_and_build - args: - - '-c' + args: + - "-c" - | if [ $_ENV = "staging" ]; then docker build -t "eu.gcr.io/ons-eqbs-images/eq-author:$SHORT_SHA" . @@ -38,9 +38,9 @@ steps: dir: eq-author-api entrypoint: sh waitFor: - - '-' - args: - - '-c' + - "-" + args: + - "-c" - | if [ $_ENV = "staging" ]; then docker build -t "eu.gcr.io/ons-eqbs-images/eq-author-api:$SHORT_SHA" . @@ -51,11 +51,11 @@ steps: echo "*************************************************************" fi - - name: 'gcr.io/google.com/cloudsdktool/cloud-sdk:alpine' + - name: "gcr.io/google.com/cloudsdktool/cloud-sdk:alpine" id: tag_author_release entrypoint: /bin/bash args: - - '-c' + - "-c" - | if [ $_ENV = "preprod" ]; then gcloud container images add-tag \ @@ -67,11 +67,11 @@ steps: echo "*************************************************************" fi - - name: 'gcr.io/google.com/cloudsdktool/cloud-sdk:alpine' + - name: "gcr.io/google.com/cloudsdktool/cloud-sdk:alpine" id: tag_author_api_release entrypoint: /bin/bash args: - - '-c' + - "-c" - | if [ $_ENV = "preprod" ]; then gcloud container images add-tag \ @@ -83,11 +83,11 @@ steps: echo "*************************************************************" fi - - name: 'gcr.io/google.com/cloudsdktool/cloud-sdk:alpine' + - name: "gcr.io/google.com/cloudsdktool/cloud-sdk:alpine" id: deploy_author_api entrypoint: sh args: - - '-c' + - "-c" - | if [ $_ENV = "staging" ]; then gcloud run deploy eq-author-api \ @@ -100,12 +100,12 @@ steps: --region europe-west2 \ --platform managed fi - - - name: 'gcr.io/google.com/cloudsdktool/cloud-sdk:alpine' + + - name: "gcr.io/google.com/cloudsdktool/cloud-sdk:alpine" id: deploy_author entrypoint: sh args: - - '-c' + - "-c" - | if [ $_ENV = "staging" ]; then gcloud run deploy eq-author \ @@ -118,4 +118,4 @@ steps: --region europe-west2 \ --platform managed fi -timeout: 1200s +timeout: 1800s diff --git a/eq-author-api/constants/themes.js b/eq-author-api/constants/themes.js index 0f6c911271..055a23a271 100644 --- a/eq-author-api/constants/themes.js +++ b/eq-author-api/constants/themes.js @@ -13,6 +13,7 @@ const THEME_SHORT_NAMES = [ "ukhsa-ons", "desnz", "desnz-ni", + "ons-nhs", ]; module.exports = { THEME_SHORT_NAMES }; diff --git a/eq-author-api/constants/validationErrorCodes.js b/eq-author-api/constants/validationErrorCodes.js index 1b549dd0a4..880f769b32 100644 --- a/eq-author-api/constants/validationErrorCodes.js +++ b/eq-author-api/constants/validationErrorCodes.js @@ -38,6 +38,7 @@ const ERR_COUNT_OF_GREATER_THAN_AVAILABLE_OPTIONS = const ERR_VALID_PIPED_ANSWER_REQUIRED = "ERR_VALID_PIPED_ANSWER_REQUIRED"; const ERR_UNIQUE_PAGE_DESCRIPTION = "ERR_UNIQUE_PAGE_DESCRIPTION"; const ERR_NO_ANSWERS = "ERR_NO_ANSWERS"; +const ERR_SURVEY_ID_MISMATCH = "ERR_SURVEY_ID_MISMATCH"; module.exports = { ERR_INVALID, @@ -76,4 +77,5 @@ module.exports = { ERR_VALID_PIPED_ANSWER_REQUIRED, ERR_UNIQUE_PAGE_DESCRIPTION, ERR_NO_ANSWERS, + ERR_SURVEY_ID_MISMATCH, }; diff --git a/eq-author-api/db/baseQuestionnaireSchema.js b/eq-author-api/db/baseQuestionnaireSchema.js index 074a8408f9..5076379b92 100644 --- a/eq-author-api/db/baseQuestionnaireSchema.js +++ b/eq-author-api/db/baseQuestionnaireSchema.js @@ -61,7 +61,20 @@ const baseQuestionnaireFields = { locked, }; +const saveQuestionnaireFields = { + id, + isPublic, + title, + type, + shortTitle, + publishStatus, + introduction, + editors, + locked, +}; + module.exports = { baseQuestionnaireFields, ...baseQuestionnaireFields, + saveQuestionnaireFields, }; diff --git a/eq-author-api/db/datastore/datastore-firestore.js b/eq-author-api/db/datastore/datastore-firestore.js index efa9a49655..d2fee1bb6f 100644 --- a/eq-author-api/db/datastore/datastore-firestore.js +++ b/eq-author-api/db/datastore/datastore-firestore.js @@ -4,7 +4,10 @@ const { logger } = require("../../utils/logger"); const { pick } = require("lodash/fp"); const { omit } = require("lodash"); const { removeEmpty } = require("../../utils/removeEmpty"); -const { baseQuestionnaireFields } = require("../baseQuestionnaireSchema"); +const { + baseQuestionnaireFields, + saveQuestionnaireFields, +} = require("../baseQuestionnaireSchema"); const { questionnaireCreationEvent, historyCreationForImport, @@ -32,6 +35,14 @@ const BASE_FIELDS = [ const justListFields = pick(BASE_FIELDS); +const SAVE_FIELDS = [ + ...Object.keys(saveQuestionnaireFields), + "updatedAt", + "history", +]; + +const justListSaveFields = pick(SAVE_FIELDS); + const saveSections = (parentDoc, sections) => Promise.all( sections.map((section, position) => @@ -271,8 +282,7 @@ const saveQuestionnaire = async (changedQuestionnaire) => { "Unable to save questionnaire; cannot find required field: ID (from saveQuestionnaire)" ); } - const createdAt = new Date(); - const updatedAt = createdAt; + const updatedAt = new Date(); const originalQuestionnaire = await getQuestionnaire(id); @@ -297,7 +307,7 @@ const saveQuestionnaire = async (changedQuestionnaire) => { const baseDoc = db.collection("questionnaires").doc(id); await baseDoc.update({ - ...justListFields(updatedQuestionnaire), + ...justListSaveFields(updatedQuestionnaire), updatedAt, }); @@ -364,6 +374,79 @@ const listQuestionnaires = async () => { } }; +const listFilteredQuestionnaires = async (input) => { + try { + const { + resultsPerPage, + firstQuestionnaireIdOnPage, + lastQuestionnaireIdOnPage, + } = input; + + // Orders questionnaires by when they were created, starting with the newest + let questionnairesQuery = db + .collection("questionnaires") + .orderBy("createdAt", "desc"); + + // Gets questionnaires on first page when firstQuestionnaireIdOnPage and lastQuestionnaireIdOnPage are not provided + if (!firstQuestionnaireIdOnPage && !lastQuestionnaireIdOnPage) { + questionnairesQuery = questionnairesQuery.limit(resultsPerPage); + } + // Gets questionnaires on previous page when firstQuestionnaireIdOnPage is provided without lastQuestionnaireIdOnPage + else if (firstQuestionnaireIdOnPage && !lastQuestionnaireIdOnPage) { + // Gets first questionnaire on current page based on firstQuestionnaireIdOnPage + const firstQuestionnaireOnPage = await db + .collection("questionnaires") + .doc(firstQuestionnaireIdOnPage) + .get(); + + // Gets previous questionnaires before firstQuestionnaireOnPage, limiting the number of questionnaires to `resultsPerPage` + questionnairesQuery = questionnairesQuery + .endBefore(firstQuestionnaireOnPage) + .limitToLast(resultsPerPage); + } + // Gets questionnaires on next page when lastQuestionnaireIdOnPage is provided without firstQuestionnaireIdOnPage + else if (lastQuestionnaireIdOnPage && !firstQuestionnaireIdOnPage) { + // Gets last questionnaire on current page based on lastQuestionnaireIdOnPage + const lastQuestionnaireOnPage = await db + .collection("questionnaires") + .doc(lastQuestionnaireIdOnPage) + .get(); + + // Gets next questionnaires after lastQuestionnaireOnPage, limiting the number of questionnaires to `resultsPerPage` + questionnairesQuery = questionnairesQuery + .startAfter(lastQuestionnaireOnPage) + .limit(resultsPerPage); + } + // Throws an error when both firstQuestionnaireIdOnPage and lastQuestionnaireIdOnPage are provided + else { + logger.error( + "Invalid input - both firstQuestionnaireIdOnPage and lastQuestionnaireIdOnPage have been provided (from listFilteredQuestionnaires)" + ); + } + + const questionnairesSnapshot = await questionnairesQuery.get(); + + if (questionnairesSnapshot.empty) { + logger.info("No questionnaires found (from listFilteredQuestionnaires)"); + return []; + } + + const questionnaires = questionnairesSnapshot.docs.map((doc) => ({ + ...doc.data(), + editors: doc.data().editors || [], + createdAt: doc.data().createdAt.toDate(), + updatedAt: doc.data().updatedAt.toDate(), + })); + return questionnaires || []; + } catch (error) { + logger.error( + error, + "Unable to retrieve questionnaires (from listFilteredQuestionnaires)" + ); + return; + } +}; + const deleteQuestionnaire = async (id) => { try { await db.collection("questionnaires").doc(id).delete(); @@ -656,6 +739,7 @@ module.exports = { saveQuestionnaire, deleteQuestionnaire, listQuestionnaires, + listFilteredQuestionnaires, getQuestionnaire, getQuestionnaireMetaById, getQuestionnaireByVersionId, diff --git a/eq-author-api/db/datastore/datastore-firestore.test.js b/eq-author-api/db/datastore/datastore-firestore.test.js index 9b92e34d18..1b8625e33d 100644 --- a/eq-author-api/db/datastore/datastore-firestore.test.js +++ b/eq-author-api/db/datastore/datastore-firestore.test.js @@ -258,6 +258,16 @@ describe("Firestore Datastore", () => { }); expect(updatedAt !== savedQuestionnaire.updatedAt).toBeTruthy(); }); + + it("Should not update the 'createdAt' property", async () => { + const createdAt = questionnaire.createdAt; + const savedQuestionnaire = await saveQuestionnaire({ + id: "123", + title: "Updated questionnaire title", + ...questionnaire, + }); + expect(createdAt === savedQuestionnaire.createdAt).toBeTruthy(); + }); }); describe("Getting a list of questionnaires", () => { diff --git a/eq-author-api/db/datastore/datastore-mongodb.js b/eq-author-api/db/datastore/datastore-mongodb.js index 0d05bbc69a..7297802a82 100644 --- a/eq-author-api/db/datastore/datastore-mongodb.js +++ b/eq-author-api/db/datastore/datastore-mongodb.js @@ -290,6 +290,366 @@ const listQuestionnaires = async () => { } }; +const getMatchQuery = async (input = {}, ctx) => { + try { + const { + searchByTitleOrShortCode = "", + owner = "", + createdOnOrAfter, + createdOnOrBefore, + access, + myQuestionnaires, + } = input; + + const { id: userId } = ctx.user; + if (createdOnOrBefore) { + createdOnOrBefore.setHours(23, 59, 59, 999); // Sets `createdOnOrBefore` time to 23:59:59.999 to include all questionnaires created on that day + } + + const matchQuery = { + $and: [ + // Searches for questionnaires with `title` or `shortTitle` (short code) containing the search term + { + $or: [ + { title: { $regex: searchByTitleOrShortCode, $options: "i" } }, + { shortTitle: { $regex: searchByTitleOrShortCode, $options: "i" } }, + ], + }, + // Searches for questionnaires with owner name OR email containing the search term - email also handles owner name being null + { + $or: [ + { "owner.name": { $regex: owner, $options: "i" } }, + { "owner.email": { $regex: owner, $options: "i" } }, + ], + }, + ], + }; + + // If both `createdOnOrAfter` and `createdOnOrBefore` are provided, searches for questionnaires created between `createdOnOrAfter` and `createdOnOrBefore` inclusive + if (createdOnOrAfter && createdOnOrBefore) { + matchQuery.createdAt = { + $gte: createdOnOrAfter, // gte: Greater than or equal to + $lte: createdOnOrBefore, // lte: Less than or equal to + }; + } + // If `createdOnOrAfter` is provided without `createdOnOrBefore`, searches for questionnaires created on or after `createdOnOrAfter` + else if (createdOnOrAfter) { + matchQuery.createdAt = { $gte: createdOnOrAfter }; // gte: Greater than or equal to + } + // If `createdOnOrBefore` is provided without `createdOnOrAfter`, searches for questionnaires created on or before `createdOnOrBefore` + else if (createdOnOrBefore) { + matchQuery.createdAt = { $lte: createdOnOrBefore }; // lte: Less than or equal to + } + + switch (access) { + // Searches for all questionnaires that are public, the user is an editor of, or the user created (all questionnaires the user has access to) + case "All": + matchQuery.$and = matchQuery.$and || []; + matchQuery.$and.push({ + $or: [ + { isPublic: true }, + { editors: { $in: [userId] } }, + { createdBy: userId }, + ], + }); + + break; + // Searches for all questionnaires the user can edit (all questionnaires the user is an editor of or the user created) + case "Editor": + matchQuery.$and = matchQuery.$and || []; + matchQuery.$and.push({ + $or: [{ editors: { $in: [userId] } }, { createdBy: userId }], + }); + + break; + // Searches for all questionnaires the user can view but not edit (all public questionnaires the user is not an editor of and did not create) + case "ViewOnly": + matchQuery.$and = matchQuery.$and || []; + matchQuery.$and.push( + { + editors: { $nin: [userId] }, + }, + { createdBy: { $ne: userId } }, + { isPublic: true } + ); + + break; + // Searches for all non-public questionnaires the user can edit (all questionnaires the user is an editor of or the user created that are not public) + case "PrivateQuestionnaires": + matchQuery.$and = matchQuery.$and || []; + matchQuery.$and.push({ + $or: [{ editors: { $in: [userId] } }, { createdBy: userId }], + isPublic: false, + }); + + break; + } + + // TODO: When "My questionnaires" feature is implemented, implement code to filter questionnaires based on questionnaires marked as "My questionnaires" + if (myQuestionnaires) { + if (!matchQuery.$and) { + matchQuery.$and = []; + } + matchQuery.$and.push({ + $or: [{ editors: { $in: [userId] } }, { createdBy: userId }], + }); + } + + return matchQuery; + } catch (error) { + logger.error( + { error: error.stack, input }, + "Unable to get match query for filtering questionnaires (from getMatchQuery)" + ); + } +}; + +const listFilteredQuestionnaires = async (input = {}, ctx) => { + try { + const { + resultsPerPage = 10, + firstQuestionnaireIdOnPage, + lastQuestionnaireIdOnPage, + sortBy = "createdDateDesc", + } = input; + + // Gets the questionnaires collection + const questionnairesCollection = dbo.collection("questionnaires"); + let questionnairesQuery; + + const matchQuery = await getMatchQuery(input, ctx); + + // Gets questionnaires on first page when firstQuestionnaireIdOnPage and lastQuestionnaireIdOnPage are not provided + if (!firstQuestionnaireIdOnPage && !lastQuestionnaireIdOnPage) { + questionnairesQuery = questionnairesCollection.aggregate([ + { + // From the `users` collection, gets the owner (based on `createdBy`) of each questionnaire by performing a join to match questionnaire `createdBy` with user `id` + $lookup: { + from: "users", + localField: "createdBy", + foreignField: "id", + as: "owner", + }, + }, + { + $match: matchQuery, + }, + { + $sort: { createdAt: sortBy === "createdDateDesc" ? -1 : 1 }, // Sorts by either most recently created first or earliest created first based on `sortBy` + }, + { + $limit: resultsPerPage, + }, + ]); + } + // Gets questionnaires on previous page when firstQuestionnaireIdOnPage is provided without lastQuestionnaireIdOnPage + else if (firstQuestionnaireIdOnPage && !lastQuestionnaireIdOnPage) { + // Gets first questionnaire on current page based on firstQuestionnaireIdOnPage + const firstQuestionnaireOnPage = await questionnairesCollection.findOne({ + id: firstQuestionnaireIdOnPage, + }); + + /* + Gets questionnaires on previous page based on firstQuestionnaireOnPage + Only finds questionnaires that meet the search conditions (e.g. owner name matching `owner` search field) + Uses `gt` (greater than) or `lt` (less than) to find questionnaires created after or before firstQuestionnaireOnPage (based on `sortBy`) meeting the conditions, + sorts from earliest created or most recently created first (based on `sortBy`), and limits to `resultsPerPage` number of questionnaires + */ + questionnairesQuery = questionnairesCollection.aggregate([ + // From the `users` collection, gets the owner (based on `createdBy`) of each questionnaire by performing a join to match questionnaire `createdBy` with user `id` + { + $lookup: { + from: "users", + localField: "createdBy", + foreignField: "id", + as: "owner", + }, + }, + { + $match: { + // Searches for questionnaires created after or before firstQuestionnaireOnPage (based on `sortBy`) AND meeting all the search conditions from `matchQuery` + $and: [ + { + createdAt: + sortBy === "createdDateDesc" + ? { $gt: firstQuestionnaireOnPage.createdAt } + : { $lt: firstQuestionnaireOnPage.createdAt }, + }, + matchQuery, + ], + }, + }, + { + /* + Sorts by either earliest created first or most recently created first based on `sortBy` + (previous page, so to get the previous questionnaires, sorts by earliest created first when `sortBy` is `createdDateDesc` + as otherwise previous page's questionnaires would be the most recently created ones) + */ + $sort: { createdAt: sortBy === "createdDateDesc" ? 1 : -1 }, + }, + { + $limit: resultsPerPage, + }, + ]); + } + // Gets questionnaires on next page when lastQuestionnaireIdOnPage is provided without firstQuestionnaireIdOnPage + else if (!firstQuestionnaireIdOnPage && lastQuestionnaireIdOnPage) { + // Gets last questionnaire on current page based on lastQuestionnaireIdOnPage + const lastQuestionnaireOnPage = await questionnairesCollection.findOne({ + id: lastQuestionnaireIdOnPage, + }); + + /* + Gets questionnaires on next page based on lastQuestionnaireOnPage + Only finds questionnaires that meet the search conditions (e.g. owner name matching `owner` search field) + Uses `lt` (less than) or `gt` (greater than) to find questionnaires created before or after lastQuestionnaireOnPage (based on `sortBy`) meeting the conditions, + sorts from most recently created or earliest created first (based on `sortBy`), and limits to `resultsPerPage` number of questionnaires + */ + questionnairesQuery = questionnairesCollection.aggregate([ + // From the `users` collection, gets the owner (based on `createdBy`) of each questionnaire by performing a join to match questionnaire `createdBy` with user `id` + { + $lookup: { + from: "users", + localField: "createdBy", + foreignField: "id", + as: "owner", + }, + }, + { + $match: { + // Searches for questionnaires created before or after lastQuestionnaireOnPage (based on `sortBy`) AND meeting all the search conditions from `matchQuery` + $and: [ + { + createdAt: + sortBy === "createdDateDesc" + ? { $lt: lastQuestionnaireOnPage.createdAt } + : { $gt: lastQuestionnaireOnPage.createdAt }, + }, + matchQuery, + ], + }, + }, + { + $sort: { createdAt: sortBy === "createdDateDesc" ? -1 : 1 }, // Sorts by either most recently created first or earliest created first based on `sortBy` + }, + { + $limit: resultsPerPage, + }, + ]); + } else { + logger.error( + { input }, + "Invalid input - received both firstQuestionnaireIdOnPage and lastQuestionnaireIdOnPage, expected only one of these values or neither (from listFilteredQuestionnaires)" + ); + return; + } + + const questionnaires = await questionnairesQuery.toArray(); + + if (questionnaires.length === 0) { + logger.debug( + `No questionnaires found with input: ${JSON.stringify( + input + )} (from listFilteredQuestionnaires)` + ); + return []; + } + + // Adds empty `editors` to each questionnaire if it does not already have `editors`, otherwise uses existing `editors` + let transformedQuestionnaires = questionnaires.map((questionnaire) => ({ + ...questionnaire, + editors: questionnaire.editors || [], + })); + + /* + Sorts questionnaires by most recently created first if firstQuestionnaireIdOnPage is provided without lastQuestionnaireIdOnPage + This condition's query previously sorted in reverse order to get the `resultsPerPage` number of questionnaires created after or before firstQuestionnaireOnPage + This ensures questionnaires are displayed in the correct order (based on `sortBy`) in this condition + */ + if (firstQuestionnaireIdOnPage && !lastQuestionnaireIdOnPage) { + if (sortBy === "createdDateDesc") { + transformedQuestionnaires = transformedQuestionnaires.sort((a, b) => + a.createdAt > b.createdAt ? -1 : 1 + ); + } else { + transformedQuestionnaires = transformedQuestionnaires.sort((a, b) => + a.createdAt > b.createdAt ? 1 : -1 + ); + } + } + + return transformedQuestionnaires; + } catch (error) { + logger.error( + { error: error.stack, input }, + "Unable to retrieve questionnaires (from listFilteredQuestionnaires)" + ); + return; + } +}; + +const getTotalFilteredQuestionnaires = async (input = {}, ctx) => { + try { + // Gets the questionnaires collection + const questionnairesCollection = dbo.collection("questionnaires"); + + const matchQuery = await getMatchQuery(input, ctx); + + // Gets the total number of questionnaires that meet the search conditions + const aggregationResult = await questionnairesCollection + .aggregate([ + { + $lookup: { + from: "users", + localField: "createdBy", + foreignField: "id", + as: "owner", + }, + }, + { + $match: matchQuery, + }, + { + $count: "totalFilteredQuestionnaires", + }, + ]) + .next(); + + // Sets default `totalFilteredQuestionnaires` to 0 if no questionnaires are returned - prevents error when destructuring `totalFilteredQuestionnaires` with no results + const { totalFilteredQuestionnaires = 0 } = aggregationResult || {}; + + return totalFilteredQuestionnaires; + } catch (error) { + logger.error( + { error: error.stack, input }, + "Unable to get total filtered questionnaires (from getTotalFilteredQuestionnaires)" + ); + return; + } +}; + +const getTotalPages = async (input = {}, ctx) => { + try { + const { resultsPerPage = 10 } = input; + + const totalFilteredQuestionnaires = await getTotalFilteredQuestionnaires( + input, + ctx + ); + + // Calculates the total number of pages by dividing the total number of filtered questionnaires by the number of results per page, and rounding up + const totalPages = Math.ceil(totalFilteredQuestionnaires / resultsPerPage); + + return totalPages; + } catch (error) { + logger.error( + { error: error.stack, input }, + "Unable to get total pages (from getTotalPages)" + ); + return; + } +}; + const createComments = async (questionnaireId) => { try { if (!questionnaireId) { @@ -486,6 +846,9 @@ module.exports = { saveQuestionnaire, deleteQuestionnaire, listQuestionnaires, + listFilteredQuestionnaires, + getTotalFilteredQuestionnaires, + getTotalPages, getQuestionnaire, getQuestionnaireMetaById, createComments, diff --git a/eq-author-api/db/datastore/datastore-mongodb.test.js b/eq-author-api/db/datastore/datastore-mongodb.test.js index 3de432fc7d..eb23f7c130 100644 --- a/eq-author-api/db/datastore/datastore-mongodb.test.js +++ b/eq-author-api/db/datastore/datastore-mongodb.test.js @@ -1,22 +1,39 @@ -const mockQuestionnnaire = require("./mock-questionnaire"); +const mockQuestionnaire = require("./mock-questionnaire"); const { noteCreationEvent } = require("../../utils/questionnaireEvents"); const { v4: uuidv4 } = require("uuid"); +const mockLoggerDebug = jest.fn(); +const mockLoggerInfo = jest.fn(); +const mockLoggerError = jest.fn(); + describe("MongoDB Datastore", () => { let questionnaire, user, firstUser, mockComment, ctx; let mongoDB; jest.isolateModules(() => { mongoDB = require("./datastore-mongodb"); }); + + jest.mock("../../utils/logger", () => ({ + logger: { + debug: mockLoggerDebug, + info: mockLoggerInfo, + error: mockLoggerError, + }, + })); + beforeAll(() => { jest.resetModules(); }); + afterEach(() => { + jest.clearAllMocks(); + }); + beforeEach(() => { - questionnaire = mockQuestionnnaire(); + questionnaire = mockQuestionnaire({}); ctx = { user: { - id: 123, + id: "user-1", }, }; user = { @@ -48,78 +65,100 @@ describe("MongoDB Datastore", () => { }); describe("Error handling for failed DB connection", () => { - it("Should throw error on connect to db", async () => { + it("should throw error on connect to db", async () => { expect(() => mongoDB.connectDB("BrokenConnectionString") ).rejects.toThrow(); }); - it("Should not throw error on listQuestionnaires", async () => { + it("should not throw error on listQuestionnaires", async () => { expect(() => mongoDB.listQuestionnaires()).not.toThrow(); }); - it("Should not throw error on createQuestionnaire", async () => { + it("should log error message without throwing error on listFilteredQuestionnaires", async () => { + expect(() => mongoDB.listFilteredQuestionnaires({}, ctx)).not.toThrow(); + expect(mockLoggerError).toHaveBeenCalledWith( + { + error: expect.any(String), + input: {}, + }, + "Unable to retrieve questionnaires (from listFilteredQuestionnaires)" + ); + }); + + it("should log error message without throwing error on getTotalPages", async () => { + expect(() => mongoDB.getTotalPages({}, ctx)).not.toThrow(); + expect(mockLoggerError).toHaveBeenCalledWith( + { + error: expect.any(String), + input: {}, + }, + "Unable to get total filtered questionnaires (from getTotalFilteredQuestionnaires)" // The error from `getTotalFilteredQuestionnaires` is triggered by `getTotalPages` + ); + }); + + it("should not throw error on createQuestionnaire", async () => { expect(() => mongoDB.createQuestionnaire(questionnaire, ctx) ).not.toThrow(); }); - it("Should not throw error on getQuestionnaire", async () => { + it("should not throw error on getQuestionnaire", async () => { expect(() => mongoDB.getQuestionnaire("567")).not.toThrow(); }); - it("Should not throw error on getQuestionnaireMetaById ", async () => { + it("should not throw error on getQuestionnaireMetaById ", async () => { expect(() => mongoDB.getQuestionnaireMetaById("567")).not.toThrow(); }); - it("Should not throw error on saveQuestionnaire", async () => { + it("should not throw error on saveQuestionnaire", async () => { expect(() => mongoDB.saveQuestionnaire(questionnaire)).not.toThrow(); }); - it("Should not throw error on saveMetadata", async () => { + it("should not throw error on saveMetadata", async () => { questionnaire.id = "567"; expect(() => mongoDB.saveMetadata(questionnaire)).not.toThrow(); }); - it("Should not throw error on deleteQuestionnaire", async () => { + it("should not throw error on deleteQuestionnaire", async () => { expect(() => mongoDB.deleteQuestionnaire("567")).not.toThrow(); }); - it("Should not throw error on listUsers", async () => { + it("should not throw error on listUsers", async () => { expect(() => mongoDB.listUsers()).not.toThrow(); }); - it("Should not throw error on createUser", async () => { + it("should not throw error on createUser", async () => { expect(() => mongoDB.createUser(user)).not.toThrow(); }); - it("Should not throw error on getUserByExternalId", async () => { + it("should not throw error on getUserByExternalId", async () => { expect(() => mongoDB.deleteQuestionnaire("567")).not.toThrow(); }); - it("Should not throw error on getUserById", async () => { + it("should not throw error on getUserById", async () => { expect(() => mongoDB.getUserById("567")).not.toThrow(); }); - it("Should not throw error on updateUser", async () => { + it("should not throw error on updateUser", async () => { expect(() => mongoDB.updateUser(user)).not.toThrow(); }); - it("Should not throw error on createComments", async () => { + it("should not throw error on createComments", async () => { expect(() => mongoDB.createComments("567")).not.toThrow(); }); - it("Should not throw error on saveComments", async () => { + it("should not throw error on saveComments", async () => { expect(() => mongoDB.saveComments({ questionnaireId: "567" }) ).not.toThrow(); }); - it("Should not throw error on getCommentsForQuestionnaire", async () => { + it("should not throw error on getCommentsForQuestionnaire", async () => { expect(() => mongoDB.getCommentsForQuestionnaire("567")).not.toThrow(); }); - it("Should not throw error on createHistoryEvent", async () => { + it("should not throw error on createHistoryEvent", async () => { expect(() => mongoDB.createHistoryEvent("567", {})).not.toThrow(); }); }); @@ -130,7 +169,7 @@ describe("MongoDB Datastore", () => { }); describe("Getting a list of questionnaires when empty", () => { - it("Should return an empty array if no questionnaires are found", async () => { + it("should return an empty array if no questionnaires are found", async () => { const listOfQuestionnaires = await mongoDB.listQuestionnaires(); expect(listOfQuestionnaires.length).toBe(0); expect(Array.isArray(listOfQuestionnaires)).toBeTruthy(); @@ -138,7 +177,7 @@ describe("MongoDB Datastore", () => { }); describe("Creating a questionnaire", () => { - it("Should give the questionnaire an ID if one is not given", async () => { + it("should give the questionnaire an ID if one is not given", async () => { const uuidRegex = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i; expect(questionnaire.id).toBeFalsy(); @@ -150,7 +189,7 @@ describe("MongoDB Datastore", () => { expect(questionnaireFromDb.id).toMatch(uuidRegex); }); - it("Should leave the questionnaire ID as is if one is given", async () => { + it("should leave the questionnaire ID as is if one is given", async () => { expect(questionnaire.id).toBeFalsy(); questionnaire.id = "123"; expect(questionnaire.id).toBeTruthy(); @@ -163,23 +202,23 @@ describe("MongoDB Datastore", () => { }); describe("Getting the latest questionnaire version", () => { - it("Should should handle when an ID is not provided", () => { + it("should should handle when an ID is not provided", () => { expect(() => mongoDB.getQuestionnaire()).not.toThrow(); }); - it("Should return null when it cannot find the questionnaire", async () => { + it("should return null when it cannot find the questionnaire", async () => { const questionnaireFromDb = await mongoDB.getQuestionnaire("567"); expect(questionnaireFromDb).toBeNull(); expect(mongoDB.getQuestionnaire("567")).resolves.toBeNull(); }); - it("Should transform Timestamps into JS Date objects", async () => { + it("should transform Timestamps into JS Date objects", async () => { const questionnaireFromDb = await mongoDB.getQuestionnaire("123"); expect(questionnaireFromDb.createdAt instanceof Date).toBeTruthy(); expect(questionnaireFromDb.updatedAt instanceof Date).toBeTruthy(); }); - it("Should get a questionnaire with missing section, metadata and editors", async () => { + it("should get a questionnaire with missing section, metadata and editors", async () => { delete questionnaire.sections; delete questionnaire.metadata; delete questionnaire.editors; @@ -191,11 +230,11 @@ describe("MongoDB Datastore", () => { }); describe("Getting the base questionnaire", () => { - it("Should handle when an ID is not provided", () => { + it("should handle when an ID is not provided", () => { expect(() => mongoDB.getQuestionnaireMetaById()).not.toThrow(); }); - it("Should return null when it cannot find the questionnaire", async () => { + it("should return null when it cannot find the questionnaire", async () => { const baseQuestionnaireFromDb = await mongoDB.getQuestionnaireMetaById( "567" ); @@ -203,7 +242,7 @@ describe("MongoDB Datastore", () => { expect(mongoDB.getQuestionnaire("567")).resolves.toBeNull(); }); - it("Should transform Timestamps into JS Data objects", async () => { + it("should transform Timestamps into JS Data objects", async () => { const baseQuestionnaireFromDb = await mongoDB.getQuestionnaireMetaById( "123" ); @@ -217,11 +256,11 @@ describe("MongoDB Datastore", () => { }); describe("Saving a questionnaire", () => { - it("Should handle when an ID cannot be found within the given questionnaire", () => { + it("should handle when an ID cannot be found within the given questionnaire", () => { expect(() => mongoDB.saveQuestionnaire(questionnaire)).not.toThrow(); }); - it("Should update the 'updatedAt' property", async () => { + it("should update the 'updatedAt' property", async () => { const updatedAt = new Date(); const savedQuestionnaire = await mongoDB.saveQuestionnaire({ id: "123", @@ -233,7 +272,7 @@ describe("MongoDB Datastore", () => { }); describe("Getting a list of questionnaires", () => { - it("Should transform Timestamps into JS Date objects", async () => { + it("should transform Timestamps into JS Date objects", async () => { const listOfQuestionnaires = await mongoDB.listQuestionnaires(); expect(listOfQuestionnaires[0].updatedAt instanceof Date).toBeTruthy(); @@ -242,13 +281,13 @@ describe("MongoDB Datastore", () => { }); describe("Deleting a questionnaire", () => { - it("Should handle when an ID has not been given", () => { + it("should handle when an ID has not been given", () => { expect(() => mongoDB.deleteQuestionnaire()).not.toThrow(); }); }); describe("Getting a list of users", () => { - it("Should return an empty array if no users are found", async () => { + it("should return an empty array if no users are found", async () => { const listOfUsers = await mongoDB.listUsers(); expect(listOfUsers.length).toBe(0); expect(Array.isArray(listOfUsers)).toBeTruthy(); @@ -256,14 +295,14 @@ describe("MongoDB Datastore", () => { }); describe("Creating a user", () => { - it("Should create a user with a provided id", async () => { + it("should create a user with a provided id", async () => { const userFromDb = await mongoDB.createUser(firstUser); expect(userFromDb.id).toBeTruthy(); expect(userFromDb.id).toMatch("999-999"); expect(userFromDb.updatedAt instanceof Date).toBeTruthy(); }); - it("Should give the user an ID if one is not given", async () => { + it("should give the user an ID if one is not given", async () => { const uuidRegex = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i; const userFromDb = await mongoDB.createUser(user); @@ -271,30 +310,30 @@ describe("MongoDB Datastore", () => { expect(userFromDb.id).toMatch(uuidRegex); }); - it("Should use the email as the users name if one is not given", async () => { + it("should use the email as the users name if one is not given", async () => { delete user.name; const userFromDb = await mongoDB.createUser(user); expect(userFromDb.name).toBeTruthy(); expect(userFromDb.name).toMatch(userFromDb.email); }); - it("Should handle any errors that may occur", () => { + it("should handle any errors that may occur", () => { delete user.email; expect(() => mongoDB.createUser(user)).not.toThrow(); }); }); describe("Getting a user by their external ID", () => { - it("Should handle when an ID is not provided", () => { + it("should handle when an ID is not provided", () => { expect(() => mongoDB.getUserByExternalId()).not.toThrow(); }); - it("Should return nothing if the user cannot be found", async () => { + it("should return nothing if the user cannot be found", async () => { const user = await mongoDB.getUserByExternalId("123"); expect(user).toBeUndefined(); }); - it("Should return the user from the externalId", async () => { + it("should return the user from the externalId", async () => { const userFromDb = await mongoDB.getUserByExternalId( firstUser.externalId ); @@ -303,31 +342,31 @@ describe("MongoDB Datastore", () => { }); describe("Getting a user by their ID", () => { - it("Should handle when an ID is not provided", () => { + it("should handle when an ID is not provided", () => { expect(() => mongoDB.getUserById()).not.toThrow(); }); - it("Should return nothing if the user cannot be found", async () => { + it("should return nothing if the user cannot be found", async () => { const user = await mongoDB.getUserById("123"); expect(user).toBeUndefined(); }); - it("Should return user from the ID", async () => { + it("should return user from the ID", async () => { const userFromDb = await mongoDB.getUserById(firstUser.id); expect(userFromDb.id).toBe(firstUser.id); }); }); describe("Getting a list of users 2", () => { - it("Should use the Firestore document ID as the ID for each user", async () => { + it("should use the Firestore document ID as the ID for each user", async () => { const usersFromDb = await mongoDB.listUsers(); expect(usersFromDb[0].id).toBe(firstUser.id); }); }); describe("Updating a user", () => { - it("Should handle not finding an ID within the given user object", () => { + it("should handle not finding an ID within the given user object", () => { expect(() => mongoDB.updateUser(user)).not.toThrow(); }); - it("Should return the updated user object", async () => { + it("should return the updated user object", async () => { const changedUser = { ...user, name: "Harry James Potter", @@ -340,6 +379,682 @@ describe("MongoDB Datastore", () => { }); }); + describe("Getting a list of filtered questionnaires", () => { + beforeAll(async () => { + await mongoDB.createUser({ + id: "user-1", + email: "user1@example.com", + name: "Joe Bloggs", + externalId: "user-1", + picture: "", + }); + + await mongoDB.createUser({ + id: "user-2", + email: "user2@example.com", + name: "Jane Smith", + externalId: "user-2", + picture: "", + }); + + await mongoDB.createUser({ + id: "test-user", + email: "test-user@example.com", + name: "Test User", + externalId: "test-user", + picture: "", + }); + + await mongoDB.createUser({ + id: "user-4", + email: "null@example.com", + name: null, // `name` is null to test questionnaires created by users with null `name` are returned + externalId: "user-4", + picture: "", + }); + + await mongoDB.createQuestionnaire( + mockQuestionnaire({ + title: "Test questionnaire 1", + ownerId: "user-1", + shortTitle: "Alias 1", + createdAt: new Date(2021, 2, 5, 5, 0, 0, 0), + }), + ctx + ); + await mongoDB.createQuestionnaire( + mockQuestionnaire({ + title: "Test questionnaire 2", + ownerId: "user-1", + createdAt: new Date(2021, 2, 10, 5, 0, 0, 0), + }), + ctx + ); + await mongoDB.createQuestionnaire( + mockQuestionnaire({ + title: "Test questionnaire 3", + ownerId: "user-2", + editors: ["user-1"], + createdAt: new Date(2021, 2, 15, 5, 0, 0, 0), + }), + ctx + ); + await mongoDB.createQuestionnaire( + mockQuestionnaire({ + title: "Test questionnaire 4", + ownerId: "user-2", + createdAt: new Date(2021, 2, 20, 5, 0, 0, 0), + }), + ctx + ); + // ** "Test questionnaire 5" is not included in several test assertions as it is not public and `ctx.user` is not owner/editor + await mongoDB.createQuestionnaire( + mockQuestionnaire({ + title: "Test questionnaire 5", + ownerId: "user-2", + createdAt: new Date(2021, 2, 25, 5, 0, 0, 0), + isPublic: false, + }), + ctx + ); + await mongoDB.createQuestionnaire( + mockQuestionnaire({ + title: "Test questionnaire 6", + ownerId: "user-1", + createdAt: new Date(2021, 2, 30, 5, 0, 0, 0), + isPublic: false, + }), + ctx + ); + await mongoDB.createQuestionnaire( + mockQuestionnaire({ + title: "Test questionnaire 7", + ownerId: "user-4", + createdAt: new Date(2021, 4, 10, 5, 0, 0, 0), + }), + ctx + ); + }); + + it("should return questionnaires with title containing the `searchByTitleOrShortCode` search term", async () => { + const listOfQuestionnaires = await mongoDB.listFilteredQuestionnaires( + { + searchByTitleOrShortCode: "Test questionnaire", + owner: "", + access: "All", + resultsPerPage: 10, + }, + ctx + ); + + expect(listOfQuestionnaires.length).toBe(6); + // "Test questionnaire 7" is first as default sort is newest to oldest + expect(listOfQuestionnaires[0].title).toEqual("Test questionnaire 7"); + expect(listOfQuestionnaires[1].title).toEqual("Test questionnaire 6"); + expect(listOfQuestionnaires[2].title).toEqual("Test questionnaire 4"); + expect(listOfQuestionnaires[3].title).toEqual("Test questionnaire 3"); + expect(listOfQuestionnaires[4].title).toEqual("Test questionnaire 2"); + expect(listOfQuestionnaires[5].title).toEqual("Test questionnaire 1"); + }); + + it("should return questionnaires with shortTitle containing the `searchByTitleOrShortCode` search term", async () => { + const listOfQuestionnaires = await mongoDB.listFilteredQuestionnaires( + { + searchByTitleOrShortCode: "Alias", + owner: "", + access: "All", + resultsPerPage: 10, + }, + ctx + ); + + expect(listOfQuestionnaires.length).toBe(1); + expect(listOfQuestionnaires[0].title).toEqual("Test questionnaire 1"); // "Test questionnaire 1" has `shortTitle` "Alias 1" - this `shortTitle` contains the search term + }); + + it("should return questionnaires with owner name containing the `owner` search term", async () => { + const listOfQuestionnaires = await mongoDB.listFilteredQuestionnaires( + { + searchByTitleOrShortCode: "", + owner: "Jane", + access: "All", + resultsPerPage: 10, + }, + ctx + ); + + expect(listOfQuestionnaires.length).toBe(2); + expect(listOfQuestionnaires[0].title).toEqual("Test questionnaire 4"); + expect(listOfQuestionnaires[1].title).toEqual("Test questionnaire 3"); + }); + + it("should return questionnaires with owner email containing the `owner` search term", async () => { + const listOfQuestionnaires = await mongoDB.listFilteredQuestionnaires( + { + searchByTitleOrShortCode: "", + owner: "user2@example.com", + access: "All", + resultsPerPage: 10, + }, + ctx + ); + + expect(listOfQuestionnaires.length).toBe(2); + expect(listOfQuestionnaires[0].title).toEqual("Test questionnaire 4"); + expect(listOfQuestionnaires[1].title).toEqual("Test questionnaire 3"); + }); + + it("should return questionnaires created on or after the searched date", async () => { + const listOfQuestionnaires = await mongoDB.listFilteredQuestionnaires( + { + searchByTitleOrShortCode: "", + owner: "", + createdOnOrAfter: new Date(2021, 2, 10), + access: "All", + resultsPerPage: 10, + }, + ctx + ); + + expect(listOfQuestionnaires.length).toBe(8); + /* + Questionnaires with titles "Default questionnaire title" are created in previous tests. + These appear first when sorted by newest to oldest as their `createdAt` dates are most recent. + */ + expect(listOfQuestionnaires[0].title).toEqual( + "Default questionnaire title" + ); + expect(listOfQuestionnaires[1].title).toEqual( + "Default questionnaire title" + ); + expect(listOfQuestionnaires[2].title).toEqual( + "Default questionnaire title" + ); + expect(listOfQuestionnaires[3].title).toEqual("Test questionnaire 7"); + expect(listOfQuestionnaires[4].title).toEqual("Test questionnaire 6"); + expect(listOfQuestionnaires[5].title).toEqual("Test questionnaire 4"); + expect(listOfQuestionnaires[6].title).toEqual("Test questionnaire 3"); + expect(listOfQuestionnaires[7].title).toEqual("Test questionnaire 2"); + }); + + it("should return questionnaires created on or before the searched date", async () => { + const listOfQuestionnaires = await mongoDB.listFilteredQuestionnaires( + { + searchByTitleOrShortCode: "", + owner: "", + createdOnOrBefore: new Date(2021, 2, 10), + access: "All", + resultsPerPage: 10, + }, + ctx + ); + + expect(listOfQuestionnaires.length).toBe(2); + expect(listOfQuestionnaires[0].title).toEqual("Test questionnaire 2"); + expect(listOfQuestionnaires[1].title).toEqual("Test questionnaire 1"); + }); + + it("should return questionnaires created between the searched dates", async () => { + const listOfQuestionnaires = await mongoDB.listFilteredQuestionnaires( + { + searchByTitleOrShortCode: "", + owner: "", + createdOnOrAfter: new Date(2021, 2, 10), + createdOnOrBefore: new Date(2021, 2, 20), + access: "All", + resultsPerPage: 10, + }, + ctx + ); + + expect(listOfQuestionnaires.length).toBe(3); + expect(listOfQuestionnaires[0].title).toEqual("Test questionnaire 4"); + expect(listOfQuestionnaires[1].title).toEqual("Test questionnaire 3"); + expect(listOfQuestionnaires[2].title).toEqual("Test questionnaire 2"); + }); + + it("should return relevant questionnaires when searching by access `All`", async () => { + const listOfQuestionnaires = await mongoDB.listFilteredQuestionnaires( + { + searchByTitleOrShortCode: "", + owner: "", + access: "All", + resultsPerPage: 10, + }, + ctx + ); + + expect(listOfQuestionnaires.length).toBe(9); + /* + Questionnaires with titles "Default questionnaire title" are created in previous tests. + These appear first when sorted by newest to oldest as their `createdAt` dates are most recent. + */ + expect(listOfQuestionnaires[0].title).toEqual( + "Default questionnaire title" + ); + expect(listOfQuestionnaires[1].title).toEqual( + "Default questionnaire title" + ); + expect(listOfQuestionnaires[2].title).toEqual( + "Default questionnaire title" + ); + expect(listOfQuestionnaires[3].title).toEqual("Test questionnaire 7"); + expect(listOfQuestionnaires[4].title).toEqual("Test questionnaire 6"); + expect(listOfQuestionnaires[5].title).toEqual("Test questionnaire 4"); + expect(listOfQuestionnaires[6].title).toEqual("Test questionnaire 3"); + expect(listOfQuestionnaires[7].title).toEqual("Test questionnaire 2"); + expect(listOfQuestionnaires[8].title).toEqual("Test questionnaire 1"); + }); + + it("should return relevant questionnaires when searching by access `Editor`", async () => { + const listOfQuestionnaires = await mongoDB.listFilteredQuestionnaires( + { + searchByTitleOrShortCode: "", + owner: "", + access: "Editor", + resultsPerPage: 10, + }, + ctx + ); + + // Expects all questionnaires where `ctx.user` is the owner (`ctx.user` created the questionnaire) or an editor + expect(listOfQuestionnaires.length).toBe(4); + expect(listOfQuestionnaires[0].title).toEqual("Test questionnaire 6"); // "user-1" created the questionnaire + expect(listOfQuestionnaires[1].title).toEqual("Test questionnaire 3"); // "user-1" is an editor + expect(listOfQuestionnaires[2].title).toEqual("Test questionnaire 2"); // "user-1" created the questionnaire + expect(listOfQuestionnaires[3].title).toEqual("Test questionnaire 1"); // "user-1" created the questionnaire + }); + + it("should return relevant questionnaires when searching by access `ViewOnly`", async () => { + const listOfQuestionnaires = await mongoDB.listFilteredQuestionnaires( + { + searchByTitleOrShortCode: "", + owner: "", + access: "ViewOnly", + resultsPerPage: 10, + }, + ctx + ); + + expect(listOfQuestionnaires.length).toBe(5); + /* + Questionnaires with titles "Default questionnaire title" are created in previous tests. + These appear first when sorted by newest to oldest as their `createdAt` dates are most recent. + */ + expect(listOfQuestionnaires[0].title).toEqual( + "Default questionnaire title" + ); + expect(listOfQuestionnaires[1].title).toEqual( + "Default questionnaire title" + ); + expect(listOfQuestionnaires[2].title).toEqual( + "Default questionnaire title" + ); + expect(listOfQuestionnaires[3].title).toEqual("Test questionnaire 7"); + expect(listOfQuestionnaires[4].title).toEqual("Test questionnaire 4"); + }); + + it("should return relevant questionnaires when searching by access `PrivateQuestionnaires`", async () => { + const listOfQuestionnaires = await mongoDB.listFilteredQuestionnaires( + { + searchByTitleOrShortCode: "", + owner: "", + access: "PrivateQuestionnaires", + resultsPerPage: 10, + }, + ctx + ); + + expect(listOfQuestionnaires.length).toBe(1); + expect(listOfQuestionnaires[0].title).toEqual("Test questionnaire 6"); + }); + + it("should return relevant questionnaires when `myQuestionnaires` is true", async () => { + const listOfQuestionnaires = await mongoDB.listFilteredQuestionnaires( + { + searchByTitleOrShortCode: "", + owner: "", + access: "All", + resultsPerPage: 10, + myQuestionnaires: true, + }, + ctx + ); + + expect(listOfQuestionnaires.length).toBe(4); + expect(listOfQuestionnaires[0].title).toEqual("Test questionnaire 6"); + expect(listOfQuestionnaires[1].title).toEqual("Test questionnaire 3"); + expect(listOfQuestionnaires[2].title).toEqual("Test questionnaire 2"); + expect(listOfQuestionnaires[3].title).toEqual("Test questionnaire 1"); + }); + + it("should return questionnaires on previous page when `firstQuestionnaireIdOnPage` is provided without `lastQuestionnaireIdOnPage`", async () => { + // Gets questionnaires with "All" access to get a questionnaire ID to use as `firstQuestionnaireIdOnPage` + const allQuestionnaires = await mongoDB.listFilteredQuestionnaires( + { + searchByTitleOrShortCode: "", + owner: "", + access: "All", + resultsPerPage: 10, + }, + ctx + ); + + const listOfPreviousPageQuestionnaires = + await mongoDB.listFilteredQuestionnaires( + { + searchByTitleOrShortCode: "", + owner: "", + access: "All", + resultsPerPage: 2, // Limits to 2 questionnaires per page to test a small number of questionnaires on previous page + firstQuestionnaireIdOnPage: allQuestionnaires[6].id, + }, + ctx + ); + + expect(listOfPreviousPageQuestionnaires.length).toBe(2); + // The two questionnaires before the first questionnaire on the page (based on firstQuestionnaireIdOnPage) + expect(listOfPreviousPageQuestionnaires[0].title).toEqual( + "Test questionnaire 6" + ); + expect(listOfPreviousPageQuestionnaires[1].title).toEqual( + "Test questionnaire 4" + ); + }); + + it("should return questionnaires on next page when `lastQuestionnaireIdOnPage` is provided without `firstQuestionnaireIdOnPage`", async () => { + // Gets questionnaires with "All" access to get a questionnaire ID to use as `lastQuestionnaireIdOnPage` + const allQuestionnaires = await mongoDB.listFilteredQuestionnaires( + { + searchByTitleOrShortCode: "", + owner: "", + access: "All", + resultsPerPage: 10, + }, + ctx + ); + + const listOfNextPageQuestionnaires = + await mongoDB.listFilteredQuestionnaires( + { + searchByTitleOrShortCode: "", + owner: "", + access: "All", + resultsPerPage: 2, // Limits to 2 questionnaires per page to test a small number of questionnaires on next page + lastQuestionnaireIdOnPage: allQuestionnaires[3].id, + }, + ctx + ); + + expect(listOfNextPageQuestionnaires.length).toBe(2); + // The two questionnaires after the last questionnaire on the page (based on lastQuestionnaireIdOnPage) + expect(listOfNextPageQuestionnaires[0].title).toEqual( + "Test questionnaire 6" + ); + expect(listOfNextPageQuestionnaires[1].title).toEqual( + "Test questionnaire 4" + ); + }); + + it("should log an error message when both `firstQuestionnaireIdOnPage` and `lastQuestionnaireIdOnPage` are provided", async () => { + const listFilteredQuestionnairesInput = { + searchByTitleOrShortCode: "", + owner: "", + access: "All", + resultsPerPage: 10, + firstQuestionnaireIdOnPage: "123", + lastQuestionnaireIdOnPage: "456", + }; + + await mongoDB.listFilteredQuestionnaires( + listFilteredQuestionnairesInput, + ctx + ); + + expect(mockLoggerError).toHaveBeenCalledTimes(1); + expect(mockLoggerError).toHaveBeenCalledWith( + { + input: listFilteredQuestionnairesInput, + }, + "Invalid input - received both firstQuestionnaireIdOnPage and lastQuestionnaireIdOnPage, expected only one of these values or neither (from listFilteredQuestionnaires)" + ); + }); + + it("should log a debug message when no questionnaires are found", async () => { + const listFilteredQuestionnairesInput = { + searchByTitleOrShortCode: "Lorem ipsum", // Search term contained in no questionnaires + owner: "", + access: "All", + resultsPerPage: 10, + }; + + // `listOfQuestionnaires` should be an empty array as no questionnaires contain the search term + const listOfQuestionnaires = await mongoDB.listFilteredQuestionnaires( + listFilteredQuestionnairesInput, + ctx + ); + + expect(mockLoggerDebug).toHaveBeenCalledTimes(1); + expect(mockLoggerDebug).toHaveBeenCalledWith( + `No questionnaires found with input: ${JSON.stringify( + listFilteredQuestionnairesInput + )} (from listFilteredQuestionnaires)` + ); + expect(listOfQuestionnaires).toEqual([]); + }); + + it("should sort questionnaires on first page from oldest to newest when `sortBy` is `createdDateAsc`", async () => { + const listOfQuestionnaires = await mongoDB.listFilteredQuestionnaires( + { + searchByTitleOrShortCode: "", + owner: "", + access: "All", + resultsPerPage: 10, + sortBy: "createdDateAsc", + }, + ctx + ); + + expect(listOfQuestionnaires.length).toBe(9); + expect(listOfQuestionnaires[0].title).toEqual("Test questionnaire 1"); + expect(listOfQuestionnaires[1].title).toEqual("Test questionnaire 2"); + expect(listOfQuestionnaires[2].title).toEqual("Test questionnaire 3"); + expect(listOfQuestionnaires[3].title).toEqual("Test questionnaire 4"); + expect(listOfQuestionnaires[4].title).toEqual("Test questionnaire 6"); + expect(listOfQuestionnaires[5].title).toEqual("Test questionnaire 7"); + /* + Questionnaires with titles "Default questionnaire title" are created in previous tests. + These appear last when sorted by oldest to newest as their `createdAt` dates are most recent. + */ + expect(listOfQuestionnaires[6].title).toEqual( + "Default questionnaire title" + ); + expect(listOfQuestionnaires[7].title).toEqual( + "Default questionnaire title" + ); + expect(listOfQuestionnaires[8].title).toEqual( + "Default questionnaire title" + ); + }); + + it("should sort questionnaires on previous page from oldest to newest when `sortBy` is `createdDateAsc`", async () => { + // Gets questionnaires with "All" access to get a questionnaire ID to use as `firstQuestionnaireIdOnPage` + const allQuestionnaires = await mongoDB.listFilteredQuestionnaires( + { + searchByTitleOrShortCode: "", + owner: "", + access: "All", + resultsPerPage: 10, + sortBy: "createdDateAsc", + }, + ctx + ); + + const listOfPreviousPageQuestionnaires = + await mongoDB.listFilteredQuestionnaires( + { + searchByTitleOrShortCode: "", + owner: "", + access: "All", + resultsPerPage: 2, + firstQuestionnaireIdOnPage: allQuestionnaires[4].id, + sortBy: "createdDateAsc", + }, + ctx + ); + + expect(listOfPreviousPageQuestionnaires.length).toBe(2); + // The two questionnaires before the first questionnaire on the page (based on firstQuestionnaireIdOnPage) + expect(listOfPreviousPageQuestionnaires[0].title).toEqual( + "Test questionnaire 3" + ); + expect(listOfPreviousPageQuestionnaires[1].title).toEqual( + "Test questionnaire 4" + ); + }); + + it("should sort questionnaires on next page from oldest to newest when `sortBy` is `createdDateAsc`", async () => { + // Gets questionnaires with "All" access to get a questionnaire ID to use as `lastQuestionnaireIdOnPage` + const allQuestionnaires = await mongoDB.listFilteredQuestionnaires( + { + searchByTitleOrShortCode: "", + owner: "", + access: "All", + resultsPerPage: 10, + sortBy: "createdDateAsc", + }, + ctx + ); + + const listOfNextPageQuestionnaires = + await mongoDB.listFilteredQuestionnaires( + { + searchByTitleOrShortCode: "", + owner: "", + access: "All", + resultsPerPage: 2, + lastQuestionnaireIdOnPage: allQuestionnaires[1].id, + sortBy: "createdDateAsc", + }, + ctx + ); + + expect(listOfNextPageQuestionnaires.length).toBe(2); + // The two questionnaires after the last questionnaire on the page (based on lastQuestionnaireIdOnPage) + expect(listOfNextPageQuestionnaires[0].title).toEqual( + "Test questionnaire 3" + ); + expect(listOfNextPageQuestionnaires[1].title).toEqual( + "Test questionnaire 4" + ); + }); + + it("should return relevant questionnaires when searching with multiple filters", async () => { + const listOfQuestionnaires = await mongoDB.listFilteredQuestionnaires( + { + searchByTitleOrShortCode: "Test questionnaire", + owner: "Joe", + access: "Editor", + createdOnOrBefore: new Date(2021, 2, 15), + createdOnOrAfter: new Date(2021, 2, 5), + resultsPerPage: 10, + sortBy: "createdDateAsc", + }, + ctx + ); + + expect(listOfQuestionnaires.length).toBe(2); + // These questionnaires meet all of the filter conditions + expect(listOfQuestionnaires[0].title).toEqual("Test questionnaire 1"); + expect(listOfQuestionnaires[1].title).toEqual("Test questionnaire 2"); + }); + }); + + describe("Getting total filtered questionnaires", () => { + it("should get the total number of questionnaires with no filters applied", async () => { + const totalFilteredQuestionnaires = + await mongoDB.getTotalFilteredQuestionnaires( + { + searchByTitleOrShortCode: "", + owner: "", + access: "All", + }, + ctx + ); + + expect(totalFilteredQuestionnaires).toBe(9); // 9 questionnaires created in previous tests + }); + + it("should get the total number of questionnaires with filters applied", async () => { + const totalFilteredQuestionnaires = + await mongoDB.getTotalFilteredQuestionnaires( + { + searchByTitleOrShortCode: "Test questionnaire", + owner: "Joe", + access: "Editor", + createdOnOrBefore: new Date(2021, 2, 15), + createdOnOrAfter: new Date(2021, 2, 5), + sortBy: "createdDateAsc", + }, + ctx + ); + + expect(totalFilteredQuestionnaires).toBe(2); + }); + }); + + describe("Getting total page count", () => { + it("should get the total number of pages based on the number of questionnaires and results per page", async () => { + const totalPageCount = await mongoDB.getTotalPages( + { + resultsPerPage: 2, // As 9 questionnaires should be returned (from previously created questionnaires), uses 2 questionnaires per page to test total page count is rounded up + searchByTitleOrShortCode: "", + owner: "", + access: "All", + }, + ctx + ); + + expect(totalPageCount).toBe(5); // (9 questionnaires) / (2 results per page) gives 5 total pages after rounding up + }); + + it("should return 0 when no questionnaires are found", async () => { + const totalPageCount = await mongoDB.getTotalPages( + { + resultsPerPage: 10, + searchByTitleOrShortCode: "Lorem ipsum", // Search term contained in no questionnaires + owner: "", + access: "All", + }, + ctx + ); + + expect(mockLoggerError).not.toHaveBeenCalled(); + expect(totalPageCount).toBe(0); + }); + + it("should log an error message on exception", async () => { + await mongoDB.getTotalPages(); // No arguments to trigger exception + + // Two calls as `getMatchQuery` also throws an error due to no context object + expect(mockLoggerError).toHaveBeenCalledTimes(2); + expect(mockLoggerError).toHaveBeenCalledWith( + { + input: {}, + error: expect.any(String), + }, + "Unable to get match query for filtering questionnaires (from getMatchQuery)" + ); + expect(mockLoggerError).toHaveBeenCalledWith( + { + input: {}, + error: expect.any(String), + }, + "Unable to get total filtered questionnaires (from getTotalFilteredQuestionnaires)" // The error from `getTotalFilteredQuestionnaires` is triggered by `getTotalPages` + ); + }); + }); + describe("Creating a history event", () => { let mockHistoryEvent; beforeEach(() => { @@ -352,15 +1067,15 @@ describe("MongoDB Datastore", () => { "He defeated the dark lord!" ); }); - it("Should handle when a qid has not been given", () => { + it("should handle when a qid has not been given", () => { expect(() => mongoDB.createHistoryEvent(null, mockHistoryEvent) ).not.toThrow(); }); - it("Should handle when an event has not been given", () => { + it("should handle when an event has not been given", () => { expect(() => mongoDB.createHistoryEvent("123", null)).not.toThrow(); }); - it("Should put the new history event at the front of the list", async () => { + it("should put the new history event at the front of the list", async () => { const questionnaireHistory = await mongoDB.createHistoryEvent( "123", mockHistoryEvent @@ -371,11 +1086,11 @@ describe("MongoDB Datastore", () => { }); describe("Saving a base questionnaire", () => { - it("Should handle when an ID cannot be found within the given base questionnaire", async () => { + it("should handle when an ID cannot be found within the given base questionnaire", async () => { await expect(mongoDB.saveMetadata({})).rejects.toThrow(); }); - it("Should update the 'updatedAt' property", async () => { + it("should update the 'updatedAt' property", async () => { const baseQuestionnaire = await mongoDB.getQuestionnaireMetaById("123"); baseQuestionnaire.updatedAt = new Date(); const updatedBaseQuestionnaire = await mongoDB.saveMetadata( @@ -389,10 +1104,10 @@ describe("MongoDB Datastore", () => { }); describe("Creating default comments", () => { - it("Should handle when a questionnaireId has not been given", () => { + it("should handle when a questionnaireId has not been given", () => { expect(() => mongoDB.createComments()).not.toThrow(); }); - it("Should return a default comments object", async () => { + it("should return a default comments object", async () => { const commentsFromDb = await mongoDB.createComments("123"); expect(commentsFromDb).toMatchObject({ comments: {}, @@ -402,12 +1117,12 @@ describe("MongoDB Datastore", () => { }); describe("Saving a comment", () => { - it("Should handle a questionnaireId not being found within the given comments object", () => { + it("should handle a questionnaireId not being found within the given comments object", () => { expect(() => mongoDB.saveComments({ comments: [mockComment] }) ).not.toThrow(); }); - it("Should return the questionnaire comments object", async () => { + it("should return the questionnaire comments object", async () => { const mockCommentObj = { "123-456-789": [mockComment], }; @@ -421,10 +1136,10 @@ describe("MongoDB Datastore", () => { }); describe("Getting the comments for a questionnaire", () => { - it("Should handle when a questionnareId has not been given", () => { + it("should handle when a questionnareId has not been given", () => { expect(() => mongoDB.getCommentsForQuestionnaire()).not.toThrow(); }); - it("Should transform Firestore Timestamps into JS Date objects", async () => { + it("should transform Firestore Timestamps into JS Date objects", async () => { const listOfComments = await mongoDB.getCommentsForQuestionnaire("123"); expect( listOfComments.comments["123-456-789"][0].createdTime instanceof Date diff --git a/eq-author-api/db/datastore/mock-questionnaire.js b/eq-author-api/db/datastore/mock-questionnaire.js index cefbae0be5..7099d850e8 100644 --- a/eq-author-api/db/datastore/mock-questionnaire.js +++ b/eq-author-api/db/datastore/mock-questionnaire.js @@ -1,8 +1,16 @@ const { v4: uuidv4 } = require("uuid"); -const mockQuestionnaire = () => { +const mockQuestionnaire = ({ + title, + ownerId, + createdAt, + isPublic, + editors, + shortTitle, +}) => { const questionnaire = { - title: "Working from home", + title: title || "Default questionnaire title", + shortTitle, theme: "business", legalBasis: "Voluntary", navigation: false, @@ -33,9 +41,11 @@ const mockQuestionnaire = () => { summary: false, version: 13, surveyVersion: 1, - editors: [], - isPublic: true, + editors: editors || [], + isPublic: isPublic ?? true, publishStatus: "Unpublished", + createdBy: ownerId || "test-user", + createdAt, }; return questionnaire; }; diff --git a/eq-author-api/schema/resolvers/base.js b/eq-author-api/schema/resolvers/base.js index c77225062d..e694571a53 100644 --- a/eq-author-api/schema/resolvers/base.js +++ b/eq-author-api/schema/resolvers/base.js @@ -1636,8 +1636,7 @@ const Resolvers = { .then(async (res) => { if (res.status === 200) { const responseJson = res.data; - - publishResult.cirId = responseJson.id; + publishResult.cirId = responseJson.guid; publishResult.cirVersion = responseJson.ci_version; publishResult.success = true; } else { diff --git a/eq-author-api/schema/resolvers/importing.js b/eq-author-api/schema/resolvers/importing.js index d599f3b1e1..a357044525 100644 --- a/eq-author-api/schema/resolvers/importing.js +++ b/eq-author-api/schema/resolvers/importing.js @@ -1,6 +1,7 @@ const { getPagesByIds, getFolderById, + getFoldersByIds, getSectionById, getSectionByFolderId, stripQCodes, @@ -98,6 +99,70 @@ module.exports = { return section; } ), + importFolders: createMutation( + async (_, { input: { questionnaireId, folderIds, position } }, ctx) => { + const { sectionId, index: insertionIndex } = position; + + if (!sectionId) { + throw new UserInputError("Target section ID must be provided."); + } + + const sourceQuestionnaire = await getQuestionnaire(questionnaireId); + if (!sourceQuestionnaire) { + throw new UserInputError( + `Questionnaire with ID ${questionnaireId} does not exist.` + ); + } + + const sourceFolders = getFoldersByIds( + { questionnaire: sourceQuestionnaire }, + folderIds + ); + + if (sourceFolders.length !== folderIds.length) { + throw new UserInputError( + `Not all folder IDs in [${folderIds}] exist in source questionnaire ${questionnaireId}.` + ); + } + + sourceFolders.forEach((folder) => { + remapAllNestedIds(folder); + removeExtraSpaces(folder); + folder.skipConditions = null; + if (folder.listId) { + folder.listId = ""; + } + folder.pages.forEach((page) => { + page.routing = null; + page.skipConditions = null; + if (page.answers !== undefined) { + page.answers.forEach((answer) => { + return stripQCodes(answer); + }); + } + + if (page.answers?.length === 1) { + if (page.answers[0].repeatingLabelAndInputListId) { + page.answers[0].repeatingLabelAndInputListId = ""; + } + } + }); + }); + + const section = getSectionById(ctx, sectionId); + + if (!section) { + throw new UserInputError( + `Section with ID ${sectionId} doesn't exist in target questionnaire.` + ); + } + + section.folders.splice(insertionIndex, 0, ...sourceFolders); + setDataVersion(ctx); + + return section; + } + ), importSections: createMutation( async (_, { input: { questionnaireId, sectionIds, position } }, ctx) => { const { sectionId, index: insertionIndex } = position; diff --git a/eq-author-api/schema/resolvers/index.js b/eq-author-api/schema/resolvers/index.js index b41c34e181..e09458ef1b 100644 --- a/eq-author-api/schema/resolvers/index.js +++ b/eq-author-api/schema/resolvers/index.js @@ -4,6 +4,7 @@ const binaryExpression2 = require("./logic/binaryExpression2"); const page = require("./pages"); const questionnaireIntroduction = require("./questionnaireIntroduction"); const importing = require("./importing"); +const questionnaires = require("./questionnaires"); module.exports = [ base, @@ -12,4 +13,5 @@ module.exports = [ ...page, ...questionnaireIntroduction, importing, + questionnaires, ]; diff --git a/eq-author-api/schema/resolvers/logic/binaryExpression2/index.js b/eq-author-api/schema/resolvers/logic/binaryExpression2/index.js index 53c331fcbf..127f1dc64f 100644 --- a/eq-author-api/schema/resolvers/logic/binaryExpression2/index.js +++ b/eq-author-api/schema/resolvers/logic/binaryExpression2/index.js @@ -37,6 +37,7 @@ const isLeftSideAnswerTypeCompatible = ( [answerTypes.CHECKBOX]: "SelectedOptions", [answerTypes.DATE]: "DateValue", [answerTypes.SELECT]: "SelectedOptions", + [answerTypes.MUTUALLY_EXCLUSIVE]: "SelectedOptions", }; if (secondaryCondition) { @@ -91,9 +92,12 @@ Resolvers.LeftSide2 = { __resolveType: ({ type, sideType }) => { if (sideType === "Answer") { if ( - [answerTypes.RADIO, answerTypes.CHECKBOX, answerTypes.SELECT].includes( - type - ) + [ + answerTypes.RADIO, + answerTypes.CHECKBOX, + answerTypes.SELECT, + answerTypes.MUTUALLY_EXCLUSIVE, + ].includes(type) ) { return "MultipleChoiceAnswer"; } diff --git a/eq-author-api/schema/resolvers/questionnaires/index.js b/eq-author-api/schema/resolvers/questionnaires/index.js new file mode 100644 index 0000000000..67fd6ac272 --- /dev/null +++ b/eq-author-api/schema/resolvers/questionnaires/index.js @@ -0,0 +1,32 @@ +const { + listFilteredQuestionnaires, + getTotalFilteredQuestionnaires, + getTotalPages, +} = require("../../../db/datastore"); + +const Resolvers = { + Query: { + filteredQuestionnaires: async (_, { input }, ctx) => { + const questionnaires = await listFilteredQuestionnaires(input, ctx); + + return questionnaires; + }, + + totalFilteredQuestionnaires: async (_, { input }, ctx) => { + const totalFilteredQuestionnaires = await getTotalFilteredQuestionnaires( + input, + ctx + ); + + return totalFilteredQuestionnaires; + }, + + totalPages: async (_, { input }, ctx) => { + const totalPages = await getTotalPages(input, ctx); + + return totalPages; + }, + }, +}; + +module.exports = Resolvers; diff --git a/eq-author-api/schema/resolvers/questionnaires/index.test.js b/eq-author-api/schema/resolvers/questionnaires/index.test.js new file mode 100644 index 0000000000..a8a0bf8228 --- /dev/null +++ b/eq-author-api/schema/resolvers/questionnaires/index.test.js @@ -0,0 +1,176 @@ +const { + createQuestionnaire, +} = require("../../../tests/utils/contextBuilder/questionnaire"); +const { + queryFilteredQuestionnaires, + queryTotalFilteredQuestionnaires, + queryTotalPages, +} = require("../../../tests/utils/contextBuilder/questionnaires"); + +const { buildContext } = require("../../../tests/utils/contextBuilder"); + +describe("questionnaires", () => { + let ctx; + + beforeAll(async () => { + ctx = await buildContext(); + + await createQuestionnaire(ctx, { + title: "Test Questionnaire 1", + theme: "business", + surveyId: "", + }); + await createQuestionnaire(ctx, { + title: "Test Questionnaire 2", + theme: "business", + surveyId: "", + }); + await createQuestionnaire(ctx, { + title: "Test Questionnaire 3", + theme: "business", + surveyId: "", + }); + await createQuestionnaire(ctx, { + title: "Test Questionnaire 10", + theme: "business", + surveyId: "", + }); + await createQuestionnaire(ctx, { + title: "Test Questionnaire 11", + theme: "business", + surveyId: "", + }); + }); + + describe("filteredQuestionnaires", () => { + it("should return all questionnaires when input is not provided", async () => { + const user = { + id: "user-1", + }; + + const filteredQuestionnaires = await queryFilteredQuestionnaires(user); + + // Sorted from newest created first to oldest created last as `listFilteredQuestionnaires` is sorted in this order by default + expect(filteredQuestionnaires).toEqual([ + expect.objectContaining({ + title: "Test Questionnaire 11", + }), + expect.objectContaining({ + title: "Test Questionnaire 10", + }), + expect.objectContaining({ + title: "Test Questionnaire 3", + }), + expect.objectContaining({ + title: "Test Questionnaire 2", + }), + expect.objectContaining({ + title: "Test Questionnaire 1", + }), + ]); + }); + + it("should filter questionnaires when input is provided", async () => { + const user = { + id: "user-1", + }; + + const input = { + searchByTitleOrShortCode: "Test Questionnaire 1", + owner: "", + access: "All", + }; + + const filteredQuestionnaires = await queryFilteredQuestionnaires( + user, + input + ); + + expect(filteredQuestionnaires).toEqual([ + // `filteredQuestionnaires` should contain `Test Questionnaire 11` and `Test Questionnaire 10` as these contain the string `Test Questionnaire 1` + expect.objectContaining({ + title: "Test Questionnaire 11", + }), + expect.objectContaining({ + title: "Test Questionnaire 10", + }), + expect.objectContaining({ + title: "Test Questionnaire 1", + }), + ]); + // `filteredQuestionnaires` should not contain `Test Questionnaire 2` and `Test Questionnaire 3` as these do not contain the string `Test Questionnaire 1` + expect(filteredQuestionnaires).not.toContainEqual( + expect.objectContaining({ + title: "Test Questionnaire 2", + }) + ); + expect(filteredQuestionnaires).not.toContainEqual( + expect.objectContaining({ + title: "Test Questionnaire 3", + }) + ); + }); + }); + + describe("totalFilteredQuestionnaires", () => { + it("should return total questionnaires when input is not provided", async () => { + const user = { + id: "user-1", + }; + + const totalFilteredQuestionnaires = + await queryTotalFilteredQuestionnaires(user); + + expect(totalFilteredQuestionnaires).toEqual(5); + }); + + it("should return total filtered questionnaires", async () => { + const user = { + id: "user-1", + }; + + const input = { + searchByTitleOrShortCode: "Test Questionnaire 1", + owner: "", + access: "All", + }; + + const totalFilteredQuestionnaires = + await queryTotalFilteredQuestionnaires(user, input); + + // `totalFilteredQuestionnaires` should be 3 as there are 3 questionnaires containing the string `Test Questionnaire 1` + expect(totalFilteredQuestionnaires).toEqual(3); + }); + }); + + describe("totalPages", () => { + it("should return total pages for all questionnaires when input is not provided", async () => { + const user = { + id: "user-1", + }; + + const totalPages = await queryTotalPages(user); + + // `totalPages` should be 1 as default resultsPerPage is 10 + expect(totalPages).toEqual(1); + }); + + it("should return total pages", async () => { + const user = { + id: "user-1", + }; + + const input = { + resultsPerPage: 2, + searchByTitleOrShortCode: "Test Questionnaire", + owner: "", + access: "All", + }; + + const totalPages = await queryTotalPages(user, input); + + // `totalPages` should be 3: (5 questionnaires) / (2 resultsPerPage) gives 3 total pages after rounding up + expect(totalPages).toEqual(3); + }); + }); +}); diff --git a/eq-author-api/schema/resolvers/utils/folderGetters.js b/eq-author-api/schema/resolvers/utils/folderGetters.js index 27c685d4e8..a917a3ec50 100644 --- a/eq-author-api/schema/resolvers/utils/folderGetters.js +++ b/eq-author-api/schema/resolvers/utils/folderGetters.js @@ -17,10 +17,14 @@ const getFolderByAnswerId = (ctx, id) => pages && some(pages, ({ answers }) => answers && some(answers, { id })) ); +const getFoldersByIds = (ctx, ids) => + getFolders(ctx).filter(({ id }) => ids.includes(id)); + module.exports = { getFolders, getFoldersBySectionId, getFolderById, getFolderByPageId, getFolderByAnswerId, + getFoldersByIds, }; diff --git a/eq-author-api/schema/tests/importing.test.js b/eq-author-api/schema/tests/importing.test.js index f1b9cdb529..d07c727cde 100644 --- a/eq-author-api/schema/tests/importing.test.js +++ b/eq-author-api/schema/tests/importing.test.js @@ -1,9 +1,17 @@ const { buildContext } = require("../../tests/utils/contextBuilder"); -const { getPages, getSections } = require("../resolvers/utils"); +const { getPages, getFolders, getSections } = require("../resolvers/utils"); + const { importQuestions, + importFolders, importSections, } = require("../../tests/utils/contextBuilder/importing"); +const { + updateFolder, +} = require("../../tests/utils/contextBuilder/folder/updateFolder"); +const { + updateAnswer, +} = require("../../tests/utils/contextBuilder/answer/updateAnswer"); describe("Importing questions", () => { describe("Error conditions", () => { @@ -191,6 +199,317 @@ describe("Importing questions", () => { }); }); +describe("Importing folders", () => { + describe("Error conditions", () => { + const defaultInput = { + questionnaireId: "questionnaire-id", + folderIds: ["folder-1", "folder-2", "folder-3"], + position: { + index: 0, + sectionId: "section-1", + }, + }; + + it("should throw error if sectionId is not provided", async () => { + expect( + importFolders(await buildContext({}), { + questionnaireId: "questionnaire-id", + folderIds: ["folder-1", "folder-2", "folder-3"], + position: { + index: 0, + sectionId: null, + }, + }) + ).rejects.toThrow("Target section ID must be provided"); + }); + + it("should throw error if source questionnaireID doesn't exist", async () => { + expect( + importFolders(await buildContext({}), defaultInput) + ).rejects.toThrow(/Questionnaire with ID .+ does not exist/); + }); + + it("should throw error if not all folders are present in source questionnaire", async () => { + const { questionnaire: sourceQuestionnaire } = await buildContext({}); + const ctx = await buildContext({}); + + expect( + importFolders(ctx, { + ...defaultInput, + questionnaireId: sourceQuestionnaire.id, + }) + ).rejects.toThrow(/Not all folder IDs .+ exist in source questionnaire/); + }); + + it("should throw error if target section doesn't exist", async () => { + const { questionnaire: sourceQuestionnaire } = await buildContext({ + sections: [{ folders: [{ pages: [{}, {}] }] }], + }); + const ctx = await buildContext({}); + const folderIds = getFolders({ questionnaire: sourceQuestionnaire }).map( + ({ id }) => id + ); + + expect( + importFolders(ctx, { + questionnaireId: sourceQuestionnaire.id, + folderIds, + position: { + index: 0, + sectionId: "undefined-section", + }, + }) + ).rejects.toThrow( + /Section with ID .+ doesn't exist in target questionnaire/ + ); + }); + }); + + describe("Success conditions", () => { + const setup = async ( + sourceStructure = { + sections: [ + { + folders: [ + { + pages: [{ title: "Page 1" }, { title: "Page 2" }], + }, + ], + }, + ], + } + ) => { + const { questionnaire: sourceQuestionnaire } = await buildContext( + sourceStructure + ); + + const folderIds = getFolders({ questionnaire: sourceQuestionnaire }).map( + ({ id }) => id + ); + + const ctx = await buildContext({ + sections: [{ folders: [{ pages: [{}, {}] }] }], + }); + + return { ctx, folderIds, sourceQuestionnaire }; + }; + + it("should import folders into a section", async () => { + const { ctx, folderIds, sourceQuestionnaire } = await setup(); + const destinationSection = ctx.questionnaire.sections[0]; + expect(destinationSection.folders).toHaveLength(1); + + await importFolders(ctx, { + questionnaireId: sourceQuestionnaire.id, + folderIds, + position: { + index: 0, + sectionId: destinationSection.id, + }, + }); + + expect(destinationSection.folders).toHaveLength(2); + expect(destinationSection.folders[0].pages[0]).toMatchObject({ + ...sourceQuestionnaire.sections[0].folders[0].pages[0], + id: expect.any(String), + }); + expect(destinationSection.folders[0].pages[1]).toMatchObject({ + ...sourceQuestionnaire.sections[0].folders[0].pages[1], + id: expect.any(String), + }); + }); + + it("should remove qCodes from imported content", async () => { + const { ctx, folderIds, sourceQuestionnaire } = await setup({ + sections: [ + { + folders: [ + { + pages: [ + { + answers: [ + { + type: "Checkbox", + qCode: "answer-qCode", + options: [ + { + qCode: "option-qCode", + }, + ], + }, + ], + }, + ], + }, + ], + }, + ], + }); + const destinationSection = ctx.questionnaire.sections[0]; + + await importFolders(ctx, { + questionnaireId: sourceQuestionnaire.id, + folderIds, + position: { + index: 0, + sectionId: destinationSection.id, + }, + }); + + expect(destinationSection.folders[0].pages[0]).toMatchObject({ + ...sourceQuestionnaire.sections[0].folders[0].pages[0], + id: expect.any(String), + answers: [ + { + ...sourceQuestionnaire.sections[0].folders[0].pages[0].answers[0], + id: expect.any(String), + qCode: null, + questionPageId: destinationSection.folders[0].pages[0].id, + options: [ + { + ...sourceQuestionnaire.sections[0].folders[0].pages[0] + .answers[0].options[0], + id: expect.any(String), + qCode: null, + }, + ], + }, + ], + }); + }); + + it("should set folder listId to empty string when importing a folder with listId", async () => { + const sourceStructure = { + sections: [ + { + folders: [ + { + pages: [{ title: "Page 1" }, { title: "Page 2" }], + }, + ], + }, + ], + }; + + const { ctx } = await setup(sourceStructure); + const destinationSection = ctx.questionnaire.sections[0]; + const sourceQuestionnaireCtx = await buildContext(sourceStructure); + const { questionnaire: sourceQuestionnaire } = sourceQuestionnaireCtx; + + await updateFolder(sourceQuestionnaireCtx, { + folderId: sourceQuestionnaire.sections[0].folders[0].id, + listId: "list-1", + }); + + await importFolders(ctx, { + questionnaireId: sourceQuestionnaire.id, + folderIds: [sourceQuestionnaire.sections[0].folders[0].id], + position: { + index: 0, + sectionId: destinationSection.id, + }, + }); + + expect(sourceQuestionnaire.sections[0].folders[0].listId).not.toEqual(""); + expect(sourceQuestionnaire.sections[0].folders[0].listId).toEqual( + "list-1" + ); + + expect(destinationSection.folders[0]).toMatchObject({ + ...sourceQuestionnaire.sections[0].folders[0], + id: expect.any(String), + folderId: expect.any(String), + listId: "", + pages: [ + { + ...sourceQuestionnaire.sections[0].folders[0].pages[0], + id: expect.any(String), + }, + { + ...sourceQuestionnaire.sections[0].folders[0].pages[1], + id: expect.any(String), + }, + ], + }); + }); + + it("should set repeatingLabelAndInputListId to empty string when importing a folder containing a repeating answer", async () => { + const sourceStructure = { + sections: [ + { + folders: [ + { + pages: [ + { + title: "Page 1", + answers: [ + { + label: "Answer 1", + repeatingLabelAndInput: true, + type: "TextField", + }, + ], + }, + { title: "Page 2" }, + ], + }, + ], + }, + ], + }; + + const { ctx } = await setup(sourceStructure); + const destinationSection = ctx.questionnaire.sections[0]; + + const sourceQuestionnaireCtx = await buildContext(sourceStructure); + const { questionnaire: sourceQuestionnaire } = sourceQuestionnaireCtx; + + const sourceQuestionnaireFirstPage = + sourceQuestionnaire.sections[0].folders[0].pages[0]; + + await updateAnswer(sourceQuestionnaireCtx, { + id: sourceQuestionnaireFirstPage.answers[0].id, + repeatingLabelAndInput: true, + repeatingLabelAndInputListId: "list-1", + }); + + expect( + sourceQuestionnaireFirstPage.answers[0].repeatingLabelAndInputListId + ).toEqual("list-1"); + expect( + sourceQuestionnaireFirstPage.answers[0].repeatingLabelAndInput + ).toEqual(true); + + await importFolders(ctx, { + questionnaireId: sourceQuestionnaire.id, + folderIds: [sourceQuestionnaire.sections[0].folders[0].id], + position: { + index: 0, + sectionId: destinationSection.id, + }, + }); + + expect(destinationSection.folders[0].pages[0]).toMatchObject({ + ...sourceQuestionnaireFirstPage, + id: expect.any(String), + answers: [ + { + ...sourceQuestionnaireFirstPage.answers[0], + id: expect.any(String), + questionPageId: expect.any(String), + repeatingLabelAndInput: true, + repeatingLabelAndInputListId: "", + }, + ], + totalValidation: { + ...sourceQuestionnaireFirstPage.totalValidation, + id: expect.any(String), + }, + }); + }); + }); +}); + describe("Importing sections", () => { describe("Error conditions", () => { const defaultInput = { diff --git a/eq-author-api/schema/tests/publishSchema.test.js b/eq-author-api/schema/tests/publishSchema.test.js index b76e3d7a2c..bca4d74219 100644 --- a/eq-author-api/schema/tests/publishSchema.test.js +++ b/eq-author-api/schema/tests/publishSchema.test.js @@ -15,7 +15,7 @@ fetch.mockImplementation(() => Promise.resolve({ status: 200, json: () => ({ - id: "cir-id-1", + guid: "cir-id-1", // eslint-disable-next-line camelcase ci_version: "1", }), diff --git a/eq-author-api/schema/tests/routing.test.js b/eq-author-api/schema/tests/routing.test.js index 34f10ea4e4..6eaa5d0f53 100644 --- a/eq-author-api/schema/tests/routing.test.js +++ b/eq-author-api/schema/tests/routing.test.js @@ -1,5 +1,10 @@ const { buildContext } = require("../../tests/utils/contextBuilder"); -const { RADIO, NUMBER, DATE } = require("../../constants/answerTypes"); +const { + RADIO, + NUMBER, + DATE, + MUTUALLY_EXCLUSIVE, +} = require("../../constants/answerTypes"); const executeQuery = require("../../tests/utils/executeQuery"); const { @@ -21,6 +26,7 @@ const { ERR_ANSWER_NOT_SELECTED, ERR_RIGHTSIDE_NO_VALUE, ERR_RIGHTSIDE_NO_CONDITION, + ERR_LOGICAL_AND, } = require("../../constants/validationErrorCodes"); const { @@ -59,6 +65,10 @@ describe("routing", () => { { type: NUMBER, }, + { + id: "mutually-exclusive-answer", + type: MUTUALLY_EXCLUSIVE, + }, ], routing: {}, }, @@ -276,6 +286,105 @@ describe("routing", () => { expect(errors[0].errorCode).toBe(ERR_ANSWER_NOT_SELECTED); }); + it("should have validation errors when expression group contains mutually exclusive answer and answer from same page", async () => { + config.sections[0].folders[0].pages[0].routing = { + rules: [{ expressionGroup: {} }], + }; + + const ctx = await buildContext(config); + const { questionnaire } = ctx; + + const firstPage = questionnaire.sections[0].folders[0].pages[0]; + const expressionGroup = firstPage.routing.rules[0].expressionGroup; + const expressions = expressionGroup.expressions; + + await executeQuery( + createBinaryExpressionMutation, + { + input: { + expressionGroupId: expressionGroup.id, + }, + }, + ctx + ); + + await executeQuery( + updateExpressionGroupMutation, + { + input: { + id: expressionGroup.id, + operator: "And", + }, + }, + ctx + ); + + await executeQuery( + updateLeftSideMutation, + { + input: { + expressionId: expressions[0].id, + answerId: firstPage.answers[0].id, + }, + }, + ctx + ); + + await executeQuery( + updateLeftSideMutation, + { + input: { + expressionId: expressions[1].id, + answerId: firstPage.answers[1].id, + }, + }, + ctx + ); + + await executeQuery( + updateBinaryExpressionMutation, + { + input: { + id: expressions[0].id, + condition: "Equal", + }, + }, + ctx + ); + + await executeQuery( + updateRightSideMutation, + { + input: { + expressionId: expressions[0].id, + customValue: { + number: 5, + }, + }, + }, + ctx + ); + + await executeQuery( + updateRightSideMutation, + { + input: { + expressionId: expressions[1].id, + selectedOptions: [firstPage.answers[1].options[0].id], + }, + }, + ctx + ); + + const result = await queryPage(ctx, firstPage.id); + + const errors = + result.routing.rules[0].expressionGroup.validationErrorInfo.errors; + expect(errors).toHaveLength(2); + expect(errors[0].errorCode).toBe(ERR_LOGICAL_AND); + expect(errors[1].errorCode).toBe(ERR_LOGICAL_AND); + }); + it("does not have validation errors if there are none", async () => { config.sections[0].folders[0].pages[0].routing = { rules: [{ expressionGroup: {} }], diff --git a/eq-author-api/schema/typeDefs.js b/eq-author-api/schema/typeDefs.js index f77ab93712..62ffce5eae 100644 --- a/eq-author-api/schema/typeDefs.js +++ b/eq-author-api/schema/typeDefs.js @@ -46,6 +46,13 @@ enum Permission { Write } +enum Access { + All + Editor + ViewOnly + PrivateQuestionnaires +} + type Questionnaire { id: ID! title: String @@ -895,6 +902,7 @@ type PublishHistoryEvent { type Query { questionnaires(input: QuestionnairesInput): [Questionnaire] + filteredQuestionnaires(input: FilteredQuestionnairesInput): [Questionnaire] questionnaire(input: QueryInput!): Questionnaire history(input: QueryInput!): [History] section(input: QueryInput!): Section @@ -921,6 +929,8 @@ type Query { listNames: [ListName] collectionListNames: [ListName] supplementaryDataListNames: [ListName] + totalFilteredQuestionnaires(input: TotalFilteredQuestionnairesInput): Int + totalPages(input: TotalPagesInput): Int } input CommonFilters { @@ -935,6 +945,38 @@ input QuestionnairesInput { filter: QuestionnairesFilter } +input FilteredQuestionnairesInput { + resultsPerPage: Int + firstQuestionnaireIdOnPage: ID + lastQuestionnaireIdOnPage: ID + searchByTitleOrShortCode: String + owner: String + createdOnOrAfter: DateTime + createdOnOrBefore: DateTime + access: Access! + myQuestionnaires: Boolean + sortBy: String +} + +input TotalFilteredQuestionnairesInput { + searchByTitleOrShortCode: String + owner: String + createdOnOrAfter: DateTime + createdOnOrBefore: DateTime + access: Access! + myQuestionnaires: Boolean +} + +input TotalPagesInput { + resultsPerPage: Int + searchByTitleOrShortCode: String + owner: String + createdOnOrAfter: DateTime + createdOnOrBefore: DateTime + access: Access! + myQuestionnaires: Boolean +} + input QueryInput { id: ID questionnaireId: ID @@ -985,6 +1027,12 @@ input ImportQuestionsInput { position: Position! } +input ImportFoldersInput { + questionnaireId: ID! + folderIds: [ID!]! + position: Position! +} + input ImportSectionsInput { questionnaireId: ID! sectionIds: [ID!]! @@ -999,6 +1047,7 @@ type Mutation { setQuestionnaireLocked(input: SetQuestionnaireLockedInput!): Questionnaire importQuestions(input: ImportQuestionsInput!): Section + importFolders(input: ImportFoldersInput!): Section importSections(input: ImportSectionsInput!): [Section] createHistoryNote(input: createHistoryNoteInput!): [History!]! @@ -1290,6 +1339,7 @@ input CreateQuestionnaireInput { type: QuestionnaireType shortTitle: String isPublic: Boolean + editors: [ID!] } input UpdateQuestionnaireInput { diff --git a/eq-author-api/src/businessLogic/answerTypeToCondition.test.js b/eq-author-api/src/businessLogic/answerTypeToCondition.test.js index 26367f6e74..99ca546f61 100644 --- a/eq-author-api/src/businessLogic/answerTypeToCondition.test.js +++ b/eq-author-api/src/businessLogic/answerTypeToCondition.test.js @@ -16,6 +16,7 @@ const VALID_TYPES = [ answerTypes.CHECKBOX, answerTypes.DATE, answerTypes.SELECT, + answerTypes.MUTUALLY_EXCLUSIVE, ]; describe("AnswerTypeToCondition", () => { @@ -29,7 +30,7 @@ describe("AnswerTypeToCondition", () => { }); describe("getDefault()", () => { - it("should return equal for all apart from radio", () => { + it("should return correct default condition for all valid answer types", () => { const expectedDefaults = { [answerTypes.NUMBER]: conditions.SELECT, [answerTypes.PERCENTAGE]: conditions.SELECT, @@ -39,6 +40,7 @@ describe("AnswerTypeToCondition", () => { [answerTypes.CHECKBOX]: conditions.ALL_OF, [answerTypes.DATE]: conditions.SELECT, [answerTypes.SELECT]: conditions.ONE_OF, + [answerTypes.MUTUALLY_EXCLUSIVE]: conditions.ONE_OF, }; VALID_TYPES.forEach((type) => { expect(getDefault(type)).toEqual(expectedDefaults[type]); diff --git a/eq-author-api/src/businessLogic/answerTypeToConditions.js b/eq-author-api/src/businessLogic/answerTypeToConditions.js index b5195f0ecc..51bdcf0aef 100644 --- a/eq-author-api/src/businessLogic/answerTypeToConditions.js +++ b/eq-author-api/src/businessLogic/answerTypeToConditions.js @@ -31,6 +31,7 @@ const answerConditions = { conditions.COUNT_OF, ], [answerTypes.SELECT]: [conditions.ONE_OF, conditions.UNANSWERED], + [answerTypes.MUTUALLY_EXCLUSIVE]: [conditions.ONE_OF, conditions.UNANSWERED], }; const isAnswerTypeSupported = (answerType) => diff --git a/eq-author-api/src/validation/customKeywords/calculatedSummaryMinAnswers.js b/eq-author-api/src/validation/customKeywords/calculatedSummaryMinAnswers.js index 19e724ea69..a8afce76f9 100644 --- a/eq-author-api/src/validation/customKeywords/calculatedSummaryMinAnswers.js +++ b/eq-author-api/src/validation/customKeywords/calculatedSummaryMinAnswers.js @@ -1,7 +1,7 @@ const createValidationError = require("../createValidationError"); const { ERR_NO_ANSWERS } = require("../../../constants/validationErrorCodes"); -const { getPages, getFolders } = require("../../../schema/resolvers/utils"); +const { getFolderByAnswerId } = require("../../../schema/resolvers/utils"); module.exports = (ajv) => ajv.addKeyword({ @@ -20,41 +20,26 @@ module.exports = (ajv) => ) { if (parentData.summaryAnswers.length > 1) { return true; - } else { - const folders = getFolders({ questionnaire }); - const pages = getPages({ questionnaire }); - - const allAnswers = pages.reduce( - (acc, page) => (page.answers ? [...acc, ...page.answers] : acc), - [] - ); + } - const selectedAnswers = allAnswers.filter((answer) => - parentData.summaryAnswers.includes(answer.id) + if (parentData.summaryAnswers.length === 1) { + let selectedFolder = getFolderByAnswerId( + { questionnaire }, + parentData.summaryAnswers[0] ); - - let selectedFolder; - folders.forEach((folder) => { - folder.pages.forEach((page) => { - if (page.id === selectedAnswers[0]?.questionPageId) { - selectedFolder = folder; - } - }); - }); - - if (parentData.summaryAnswers.length === 1 && selectedFolder?.listId) { + if (selectedFolder?.listId) { return true; - } else { - isValid.errors = [ - createValidationError( - instancePath, - fieldName, - ERR_NO_ANSWERS, - questionnaire - ), - ]; - return false; } } + + isValid.errors = [ + createValidationError( + instancePath, + fieldName, + ERR_NO_ANSWERS, + questionnaire + ), + ]; + return false; }, }); diff --git a/eq-author-api/src/validation/customKeywords/index.js b/eq-author-api/src/validation/customKeywords/index.js index 4c325b9007..ec77ba8e42 100644 --- a/eq-author-api/src/validation/customKeywords/index.js +++ b/eq-author-api/src/validation/customKeywords/index.js @@ -5,6 +5,7 @@ module.exports = (ajv) => { require("./calculatedSummaryPosition")(ajv); require("./validateLatestAfterEarliest")(ajv); require("./validateDuration")(ajv); + require("./validateSurveyId")(ajv); require("./validateMultipleChoiceCondition")(ajv); require("./validateExpression")(ajv); require("./validateRoutingDestination")(ajv); diff --git a/eq-author-api/src/validation/customKeywords/validateRoutingLogicalAND.js b/eq-author-api/src/validation/customKeywords/validateRoutingLogicalAND.js index 6ac57084c4..1d639de540 100644 --- a/eq-author-api/src/validation/customKeywords/validateRoutingLogicalAND.js +++ b/eq-author-api/src/validation/customKeywords/validateRoutingLogicalAND.js @@ -1,11 +1,16 @@ const { groupBy } = require("lodash"); -const { getAnswerById } = require("../../../schema/resolvers/utils"); +const { + getAnswerById, + getPageByAnswerId, +} = require("../../../schema/resolvers/utils"); + const { CURRENCY, NUMBER, PERCENTAGE, UNIT, CHECKBOX, + MUTUALLY_EXCLUSIVE, } = require("../../../constants/answerTypes"); const createValidationError = require("../createValidationError"); const { ERR_LOGICAL_AND } = require("../../../constants/validationErrorCodes"); @@ -21,6 +26,7 @@ module.exports = (ajv) => { _parentSchema, { rootData: questionnaire, instancePath } ) { + const allExpressions = expressions; const invalidAnswerIds = new Set(); const expressionsByAnswerId = groupBy(expressions, "left.answerId"); const potentialConflicts = Object.entries(expressionsByAnswerId).filter( @@ -38,8 +44,51 @@ module.exports = (ajv) => { return addError(answerId); } - // Bail out if answer isn't numerical or checkbox - remaining code validates number-type answers const answer = getAnswerById({ questionnaire }, answerId); + const page = getPageByAnswerId({ questionnaire }, answerId); + const allExpressionAnswerIds = allExpressions.map( + (expression) => expression.left.answerId + ); + const pageIdsForExpressionAnswers = allExpressionAnswerIds.map( + (answerId) => getPageByAnswerId({ questionnaire }, answerId)?.id + ); + + /* + Creates an array of all pageIds that are found in pageIdsForExpressionAnswers more than once + If the index of the looped id is not equal to the looping index then the id is a duplicate + */ + const duplicatedPageIds = pageIdsForExpressionAnswers.filter( + (id, index) => { + return pageIdsForExpressionAnswers.indexOf(id) !== index; + } + ); + + // If there are multiple answers from the same page + if (duplicatedPageIds.length > 0) { + /* + Checks if any of the expressions' answers conflict with a mutually exclusive answer + Conflicts (returns true) if all of the following are met: + - The looping expression's answer in the expression group is a mutually exclusive answer + - The same expression's answer is on a page that has other answers from the same page in the logic rule + - The same expression's answer is on the same page as the current answer being validated + */ + const conflictsWithMutuallyExclusive = allExpressions.some( + (expression) => + getAnswerById({ questionnaire }, expression.left.answerId) + ?.type === MUTUALLY_EXCLUSIVE && + duplicatedPageIds.includes( + getPageByAnswerId({ questionnaire }, expression.left.answerId) + ?.id + ) && + getPageByAnswerId({ questionnaire }, expression.left.answerId) + ?.id === page.id + ); + + if (conflictsWithMutuallyExclusive) { + return addError(answerId); + } + } + // Bail out if answer isn't numerical or checkbox - remaining code validates number-type answers if ( !answer || ![CURRENCY, NUMBER, UNIT, PERCENTAGE, CHECKBOX].includes(answer.type) diff --git a/eq-author-api/src/validation/customKeywords/validateSurveyId.js b/eq-author-api/src/validation/customKeywords/validateSurveyId.js new file mode 100644 index 0000000000..c3853d86ad --- /dev/null +++ b/eq-author-api/src/validation/customKeywords/validateSurveyId.js @@ -0,0 +1,42 @@ +const createValidationError = require("../createValidationError"); +const { + ERR_SURVEY_ID_MISMATCH, +} = require("../../../constants/validationErrorCodes"); + +module.exports = (ajv) => + ajv.addKeyword({ + keyword: "validateSurveyId", + validate: function isValid( + _schema, + questionnaireSurveyId, // gives the data entered into the survey ID field + _parentSchema, + { + instancePath, // gives the path /surveyId + rootData: questionnaire, // gives the whole questionnaire object + parentDataProperty: fieldName, // gives the field name surveyId + } + ) { + // Get the supplementary data from the questionnaire object + const supplementaryData = questionnaire.supplementaryData; + + // If supplementaryData exists and contains a surveyId, and supplementaryData's surveyId doesn't match the questionnaire's surveyId, throw a validation error + if ( + supplementaryData && + supplementaryData.surveyId && + questionnaireSurveyId !== supplementaryData.surveyId + ) { + isValid.errors = [ + createValidationError( + instancePath, + fieldName, + ERR_SURVEY_ID_MISMATCH, + questionnaire, + ERR_SURVEY_ID_MISMATCH + ), + ]; + return false; + } + + return true; + }, + }); diff --git a/eq-author-api/src/validation/index.js b/eq-author-api/src/validation/index.js index fcc44578a8..f389c927e2 100644 --- a/eq-author-api/src/validation/index.js +++ b/eq-author-api/src/validation/index.js @@ -55,7 +55,7 @@ module.exports = (questionnaire) => { for (const err of validate.errors) { if (err.keyword === "errorMessage") { - const key = `${err.instancePath} ${err.message}`; + const key = `${err.instancePath} ${err.message} ${err.field}`; if (uniqueErrorMessages[key]) { continue; diff --git a/eq-author-api/src/validation/schemas/questionnaire.json b/eq-author-api/src/validation/schemas/questionnaire.json index 77a6bde3d1..85b46ca871 100644 --- a/eq-author-api/src/validation/schemas/questionnaire.json +++ b/eq-author-api/src/validation/schemas/questionnaire.json @@ -26,18 +26,25 @@ "$ref": "submission.json" }, "surveyId": { - "if": { - "type": "string", - "minLength": 1 - }, - "then": { - "type": "string", - "pattern": "^\\d{3}$", - "errorMessage": "ERR_INVALID" - }, - "else": { - "$ref": "definitions.json#/definitions/populatedString" - } + "allOf": [ + { + "if": { + "type": "string", + "minLength": 1 + }, + "then": { + "type": "string", + "pattern": "^\\d{3}$", + "errorMessage": "ERR_INVALID" + }, + "else": { + "$ref": "definitions.json#/definitions/populatedString" + } + }, + { + "validateSurveyId": true + } + ] }, "formType": { "if": { diff --git a/eq-author-api/tests/utils/contextBuilder/answer/updateAnswer.js b/eq-author-api/tests/utils/contextBuilder/answer/updateAnswer.js index dea76702dd..ecc1f90731 100644 --- a/eq-author-api/tests/utils/contextBuilder/answer/updateAnswer.js +++ b/eq-author-api/tests/utils/contextBuilder/answer/updateAnswer.js @@ -30,6 +30,8 @@ const updateAnswer = async (ctx, input) => { secondaryLabel properties qCode + repeatingLabelAndInput + repeatingLabelAndInputListId } `, input diff --git a/eq-author-api/tests/utils/contextBuilder/importing/importFolders.js b/eq-author-api/tests/utils/contextBuilder/importing/importFolders.js new file mode 100644 index 0000000000..123132ccf5 --- /dev/null +++ b/eq-author-api/tests/utils/contextBuilder/importing/importFolders.js @@ -0,0 +1,40 @@ +const executeQuery = require("../../executeQuery"); +const { filter } = require("graphql-anywhere"); +const gql = require("graphql-tag"); + +const mutation = ` +mutation ImportFolders($input: ImportFoldersInput!) { + importFolders(input: $input) { + id + } +} +`; + +const importFolders = async (ctx, input) => { + const result = await executeQuery( + mutation, + { + input: filter( + gql` + { + questionnaireId + folderIds + position { + sectionId + index + } + } + `, + input + ), + }, + ctx + ); + + return result.data.importFolders; +}; + +module.exports = { + mutation, + importFolders, +}; diff --git a/eq-author-api/tests/utils/contextBuilder/importing/index.js b/eq-author-api/tests/utils/contextBuilder/importing/index.js index 67807e0300..b632cedbc1 100644 --- a/eq-author-api/tests/utils/contextBuilder/importing/index.js +++ b/eq-author-api/tests/utils/contextBuilder/importing/index.js @@ -1,4 +1,5 @@ module.exports = { ...require("./importQuestions"), + ...require("./importFolders"), ...require("./importSections"), }; diff --git a/eq-author-api/tests/utils/contextBuilder/questionnaires/index.js b/eq-author-api/tests/utils/contextBuilder/questionnaires/index.js new file mode 100644 index 0000000000..76bbb7350f --- /dev/null +++ b/eq-author-api/tests/utils/contextBuilder/questionnaires/index.js @@ -0,0 +1,5 @@ +module.exports = { + ...require("./queryFilteredQuestionnaires"), + ...require("./queryTotalPages"), + ...require("./queryTotalFilteredQuestionnaires"), +}; diff --git a/eq-author-api/tests/utils/contextBuilder/questionnaires/queryFilteredQuestionnaires.js b/eq-author-api/tests/utils/contextBuilder/questionnaires/queryFilteredQuestionnaires.js new file mode 100644 index 0000000000..2b134d8532 --- /dev/null +++ b/eq-author-api/tests/utils/contextBuilder/questionnaires/queryFilteredQuestionnaires.js @@ -0,0 +1,37 @@ +const executeQuery = require("../../executeQuery"); + +const getFilteredQuestionnairesQuery = ` + query GetFilteredQuestionnaires($input: FilteredQuestionnairesInput) { + filteredQuestionnaires(input: $input) { + id + title + shortTitle + createdBy { + id + name + } + createdAt + editors { + id + } + permission + } + } +`; + +const queryFilteredQuestionnaires = async (user, input) => { + const result = await executeQuery( + getFilteredQuestionnairesQuery, + input ? { input } : undefined, // Passes `undefined` when `input` is falsy to test when `input` is not provided + { + user, + } + ); + + return result.data.filteredQuestionnaires; +}; + +module.exports = { + getFilteredQuestionnairesQuery, + queryFilteredQuestionnaires, +}; diff --git a/eq-author-api/tests/utils/contextBuilder/questionnaires/queryTotalFilteredQuestionnaires.js b/eq-author-api/tests/utils/contextBuilder/questionnaires/queryTotalFilteredQuestionnaires.js new file mode 100644 index 0000000000..af348dacb9 --- /dev/null +++ b/eq-author-api/tests/utils/contextBuilder/questionnaires/queryTotalFilteredQuestionnaires.js @@ -0,0 +1,24 @@ +const executeQuery = require("../../executeQuery"); + +const getTotalFilteredQuestionnairesQuery = ` + query GetTotalFilteredQuestionnaires($input: TotalFilteredQuestionnairesInput) { + totalFilteredQuestionnaires(input: $input) + } +`; + +const queryTotalFilteredQuestionnaires = async (user, input) => { + const result = await executeQuery( + getTotalFilteredQuestionnairesQuery, + input ? { input } : undefined, // Passes `undefined` when `input` is falsy to test when `input` is not provided + { + user, + } + ); + + return result.data.totalFilteredQuestionnaires; +}; + +module.exports = { + getTotalFilteredQuestionnairesQuery, + queryTotalFilteredQuestionnaires, +}; diff --git a/eq-author-api/tests/utils/contextBuilder/questionnaires/queryTotalPages.js b/eq-author-api/tests/utils/contextBuilder/questionnaires/queryTotalPages.js new file mode 100644 index 0000000000..c14e09a28a --- /dev/null +++ b/eq-author-api/tests/utils/contextBuilder/questionnaires/queryTotalPages.js @@ -0,0 +1,24 @@ +const executeQuery = require("../../executeQuery"); + +const getTotalPagesQuery = ` + query GetTotalPages($input: TotalPagesInput) { + totalPages(input: $input) + } +`; + +const queryTotalPages = async (user, input) => { + const result = await executeQuery( + getTotalPagesQuery, + input ? { input } : undefined, // Passes `undefined` when `input` is falsy to test when `input` is not provided + { + user, + } + ); + + return result.data.totalPages; +}; + +module.exports = { + getTotalPagesQuery, + queryTotalPages, +}; diff --git a/eq-author-api/utils/createQuestionnaireIntroduction.js b/eq-author-api/utils/createQuestionnaireIntroduction.js index dd3a3f5bfd..4ff9cc5371 100644 --- a/eq-author-api/utils/createQuestionnaireIntroduction.js +++ b/eq-author-api/utils/createQuestionnaireIntroduction.js @@ -23,7 +23,7 @@ module.exports = (metadata) => { additionalGuidancePanelSwitch: false, additionalGuidancePanel: "", description: - "", + "", legalBasis: NOTICE_1, // TODO: previewQuestions previewQuestions: false, diff --git a/eq-author/package.json b/eq-author/package.json index 2e3b4adf90..1562f6ab95 100644 --- a/eq-author/package.json +++ b/eq-author/package.json @@ -107,6 +107,7 @@ "draft-js-block-breakout-plugin": "latest", "draft-js-plugins-editor": "latest", "draft-js-raw-content-state": "latest", + "draft-js-import-html": "latest", "draftjs-filters": "^2.5.0", "firebase": "latest", "firebaseui": "latest", diff --git a/eq-author/src/App/MeContext.js b/eq-author/src/App/MeContext.js index 38a7c38e4f..58855fc070 100644 --- a/eq-author/src/App/MeContext.js +++ b/eq-author/src/App/MeContext.js @@ -90,9 +90,34 @@ const ContextProvider = ({ history, client, children }) => { useEffect(() => { // be aware that the return from auth.onAuthStateChanged will change on firebase ver 4.0 // https://firebase.google.com/docs/reference/js/v8/firebase.auth.Auth#onauthstatechanged + // This useEffect hook is responsible for handling the authentication state changes in Firebase. + // It listens for changes in the authentication state using the onAuthStateChanged method. + // When the authentication state changes, it updates the firebaseUser state and sets awaitingFirebase to false. auth.onAuthStateChanged((user) => { setFirebaseUser(user); setAwaitingFirebase(false); + // It also sets up a session timeout for the user if they are authenticated. + // If the user is not authenticated, the session timeout is cleared using clearTimeout. + let sessionTimeout = null; + if (user === null || user === undefined) { + sessionTimeout && clearTimeout(sessionTimeout); + sessionTimeout = null; + } else { + // If the user is authenticated, it retrieves the ID token result and calculates the session duration. + user.getIdTokenResult().then((idTokenResult) => { + const authTime = idTokenResult.claims.auth_time * 1000; + // The session duration is set to 7 days. + // The format of the session duration calculation is in milliseconds/seconds/minutes/hours/days. + const sessionDuration = 1000 * 60 * 60 * 24 * 7; // 604,800,000 milliseconds + const millisecondsUntilExpiration = + sessionDuration - (Date.now() - authTime); + // It then sets up a session timeout using setTimeout, which will automatically sign out the user after the session duration expires. + sessionTimeout = setTimeout( + () => auth.signOut(), + millisecondsUntilExpiration + ); + }); + } }); }, []); diff --git a/eq-author/src/App/QuestionnaireDesignPage/NavigationSidebar/index.js b/eq-author/src/App/QuestionnaireDesignPage/NavigationSidebar/index.js index f23738fe7e..443438faac 100644 --- a/eq-author/src/App/QuestionnaireDesignPage/NavigationSidebar/index.js +++ b/eq-author/src/App/QuestionnaireDesignPage/NavigationSidebar/index.js @@ -110,7 +110,9 @@ const NavigationSidebar = ({ questionnaire }) => { const [openSections, toggleSections] = useState(true); const [entity, setEntity] = useState({}); // Allows data for entity being dragged by user to be used in other droppables - const [movePage] = useMutation(MOVE_PAGE_MUTATION); + const [movePage] = useMutation(MOVE_PAGE_MUTATION, { + refetchQueries: ["GetPage"], + }); const [moveFolder] = useMutation(MOVE_FOLDER_MUTATION); const [moveSection] = useMutation(MOVE_SECTION_MUTATION); diff --git a/eq-author/src/App/QuestionnaireDesignPage/getQuestionnaireQuery.graphql b/eq-author/src/App/QuestionnaireDesignPage/getQuestionnaireQuery.graphql index 73a2b2f701..8bacacc678 100644 --- a/eq-author/src/App/QuestionnaireDesignPage/getQuestionnaireQuery.graphql +++ b/eq-author/src/App/QuestionnaireDesignPage/getQuestionnaireQuery.graphql @@ -365,6 +365,9 @@ fragment Answers on Answer { type properties advancedProperties + page { + id + } ... on BasicAnswer { secondaryQCode } diff --git a/eq-author/src/App/Submission/Preview/SubmissionPreview/index.js b/eq-author/src/App/Submission/Preview/SubmissionPreview/index.js index 38ceb5e6e0..7156de276a 100644 --- a/eq-author/src/App/Submission/Preview/SubmissionPreview/index.js +++ b/eq-author/src/App/Submission/Preview/SubmissionPreview/index.js @@ -3,15 +3,12 @@ import PropTypes from "prop-types"; import styled from "styled-components"; import { colors } from "constants/theme.js"; -import IconText from "components/IconText"; import PageTitle from "components/preview/elements/PageTitle"; import { Field, Label } from "components/Forms"; import Panel from "components-themed/panels"; import Feedback from "components-themed/Feedback"; import Error from "components/preview/Error"; -import { ReactComponent as WarningIcon } from "assets/icon-warning-round.svg"; - const Wrapper = styled.div` padding: 2em; font-size: 18px; @@ -19,7 +16,8 @@ const Wrapper = styled.div` const Section = styled.div` &:not(:first-child) { - margin-top: 1em; + margin-top: ${(props) => + props.marginTop ? `${props.marginTop}em` : `0.5em`}; } `; @@ -33,16 +31,8 @@ const TitleWrapper = styled.div` margin-top: -0.35em; `; -const WarningPanel = styled(IconText)` - svg { - height: 2em; - width: 2em; - } -`; - const WarningPanelText = styled.div` font-weight: bold; - margin-left: 0.5em; `; const PanelSection = styled.div` @@ -89,6 +79,8 @@ const BlueUnderlined = styled.span` font-weight: ${(props) => props.bold && `bold`}; `; +const Text = styled.p``; + const SubmissionEditor = ({ submission, questionnaireTitle }) => { const { furtherContent, viewPrintAnswers, feedback } = submission; @@ -144,22 +136,24 @@ const SubmissionEditor = ({ submission, questionnaireTitle }) => { title={getCopyOfAnswers} missingText={missingTitleText} /> - You can  - save or print your answers -  for your records. + We may contact you to query your answers. + + If you need a copy for your records,  + save or print your answers. +
- + {answersAvailableToView} - + {feedback && }
)} {feedback && ( -
+
{commentsImprovements} diff --git a/eq-author/src/App/dataSettings/SupplementaryDataPage/index.js b/eq-author/src/App/dataSettings/SupplementaryDataPage/index.js index 4c3f7cdba8..e1694d7832 100644 --- a/eq-author/src/App/dataSettings/SupplementaryDataPage/index.js +++ b/eq-author/src/App/dataSettings/SupplementaryDataPage/index.js @@ -220,16 +220,17 @@ const SupplementaryDataPage = () => { {!tableData && ( <> - Select a supplementary dataset to link to + Select a supplementary dataset schema to link to - Linking to a supplementary dataset will allow you - to pipe data that respondents have provided in - previous questionnaires into question titles or - percentage answer type labels. + Linking to a supplementary dataset schema will + allow you to pipe data that respondents have + provided in previous questionnaires into question + titles or percentage answer type labels. - Only one dataset can be linked per questionnaire. + Only one dataset schema can be linked per + questionnaire. Select a survey ID { user, mocks ); - expect(getByText("Select a supplementary dataset to link to")).toBeTruthy(); expect( - getByText("Only one dataset can be linked per questionnaire.") + getByText("Select a supplementary dataset schema to link to") + ).toBeTruthy(); + expect( + getByText("Only one dataset schema can be linked per questionnaire.") ).toBeTruthy(); }); @@ -166,7 +168,7 @@ describe("Supplementary dataset page", () => { const select = getByTestId("list-select"); fireEvent.change(select, { target: { value: "121" } }); await waitFor(() => { - expect(getByText("Datasets for survey ID 121")).toBeTruthy(); + expect(getByText("Dataset schemas for survey ID 121")).toBeTruthy(); expect(getByTestId("datasets-table")).toBeTruthy(); expect(findAllByText("Date created")).toBeTruthy(); expect(getAllByTestId("dataset-row")).toBeTruthy(); diff --git a/eq-author/src/App/dataSettings/SupplementaryDataPage/schemaVersionsTable.js b/eq-author/src/App/dataSettings/SupplementaryDataPage/schemaVersionsTable.js index 0cd8bb6a3e..ffd3f02e5a 100644 --- a/eq-author/src/App/dataSettings/SupplementaryDataPage/schemaVersionsTable.js +++ b/eq-author/src/App/dataSettings/SupplementaryDataPage/schemaVersionsTable.js @@ -68,7 +68,7 @@ const SchemaVersionTable = ({ surveyId, linkSupplementaryData }) => { return ( <> - Datasets for survey ID {surveyId} + Dataset schemas for survey ID {surveyId} @@ -77,7 +77,7 @@ const SchemaVersionTable = ({ surveyId, linkSupplementaryData }) => { Date created - Link dataset + Link dataset schema diff --git a/eq-author/src/App/importingContent/index.js b/eq-author/src/App/importingContent/index.js index ce31b7d29f..2124b33787 100644 --- a/eq-author/src/App/importingContent/index.js +++ b/eq-author/src/App/importingContent/index.js @@ -19,13 +19,16 @@ import { import GET_QUESTIONNAIRE_LIST from "graphql/getQuestionnaireList.graphql"; import GET_QUESTIONNAIRE from "graphql/getQuestionnaire.graphql"; import IMPORT_QUESTIONS from "graphql/importQuestions.graphql"; +import IMPORT_FOLDERS from "graphql/importFolders.graphql"; import IMPORT_SECTIONS from "graphql/importSections.graphql"; import QuestionnaireSelectModal from "components/modals/QuestionnaireSelectModal"; import ReviewQuestionsModal from "components/modals/ImportQuestionReviewModal"; +import ReviewFoldersModal from "components/modals/ImportFolderReviewModal"; import ReviewSectionsModal from "components/modals/ImportSectionReviewModal"; import SelectContentModal from "components/modals/ImportContentModal"; import QuestionPicker from "components/QuestionPicker"; +import FolderPicker from "components/FolderPicker"; import SectionPicker from "components/SectionPicker"; import ExtraSpaceConfirmationModal from "components-themed/Modal"; @@ -53,11 +56,15 @@ const ImportingContent = ({ const [selectingQuestionnaire, setSelectingQuestionnaire] = useState(true); const [reviewingQuestions, setReviewingQuestions] = useState(false); const [selectingQuestions, setSelectingQuestions] = useState(false); + const [reviewingFolders, setReviewingFolders] = useState(false); + const [selectingFolders, setSelectingFolders] = useState(false); const [reviewingSections, setReviewingSections] = useState(false); const [selectingSections, setSelectingSections] = useState(false); const [selectingContent, setSelectingContent] = useState(false); const [showQuestionExtraSpaceModal, setShowQuestionExtraSpaceModal] = useState(false); + const [showFolderExtraSpaceModal, setShowFolderExtraSpaceModal] = + useState(false); const [showSectionExtraSpaceModal, setShowSectionExtraSpaceModal] = useState(false); @@ -69,6 +76,8 @@ const ImportingContent = ({ const [questionnaireImportingFrom, setQuestionnaireImportingFrom] = useState(null); + const [foldersToImport, setFoldersToImport] = useState([]); + const [sectionsToImport, setSectionsToImport] = useState([]); const { @@ -84,6 +93,7 @@ const ImportingContent = ({ */ const [importQuestions] = useMutation(IMPORT_QUESTIONS); + const [importFolders] = useMutation(IMPORT_FOLDERS); const [importSections] = useMutation(IMPORT_SECTIONS); // Global @@ -96,6 +106,8 @@ const ImportingContent = ({ stopImporting(); setReviewingSections(false); setSelectingSections(false); + setSelectingFolders(false); + setReviewingFolders(false); setSelectingContent(false); setShowQuestionExtraSpaceModal(false); setShowSectionExtraSpaceModal(false); @@ -107,6 +119,7 @@ const ImportingContent = ({ setQuestionnaireImportingFrom(questionnaire); setSelectingQuestionnaire(false); setReviewingQuestions(false); + setReviewingFolders(false); setReviewingSections(false); setSelectingContent(true); }; @@ -118,6 +131,8 @@ const ImportingContent = ({ setReviewingQuestions(true); setReviewingSections(false); setSelectingSections(false); + setSelectingFolders(false); + setReviewingFolders(false); setSelectingContent(false); }; @@ -127,6 +142,8 @@ const ImportingContent = ({ setReviewingQuestions(true); setReviewingSections(false); setSelectingSections(false); + setSelectingFolders(false); + setReviewingFolders(false); setSelectingContent(false); }; @@ -137,6 +154,8 @@ const ImportingContent = ({ setSelectingQuestions(true); setReviewingSections(false); setSelectingSections(false); + setSelectingFolders(false); + setReviewingFolders(false); setSelectingContent(false); }; @@ -144,6 +163,54 @@ const ImportingContent = ({ setReviewingQuestions(false); setSelectingQuestionnaire(true); setQuestionsToImport([]); + setSelectingFolders(false); + setReviewingFolders(false); + setReviewingSections(false); + setSelectingSections(false); + setSelectingContent(false); + }; + + // Selecting folders to import + + const onFolderPickerCancel = () => { + setSelectingQuestions(false); + setReviewingQuestions(false); + setReviewingSections(false); + setSelectingSections(false); + setSelectingFolders(false); + setReviewingFolders(true); + setSelectingContent(false); + }; + + const onFolderPickerSubmit = (selection) => { + setFoldersToImport(selection); + setSelectingQuestions(false); + setReviewingQuestions(false); + setReviewingSections(false); + setSelectingSections(false); + setSelectingFolders(false); + setReviewingFolders(true); + setSelectingContent(false); + }; + + // Reviewing folders to import + + const onSelectFolders = () => { + setReviewingQuestions(false); + setSelectingQuestions(false); + setSelectingFolders(true); + setReviewingFolders(false); + setReviewingSections(false); + setSelectingSections(false); + setSelectingContent(false); + }; + + const onBackFromReviewingFolders = () => { + setFoldersToImport([]); + setReviewingQuestions(false); + setSelectingQuestionnaire(true); + setSelectingFolders(false); + setReviewingFolders(false); setReviewingSections(false); setSelectingSections(false); setSelectingContent(false); @@ -152,7 +219,11 @@ const ImportingContent = ({ const onRemoveAllSelectedContent = () => { if (reviewingQuestions) { setQuestionsToImport([]); - } else { + } + if (reviewingFolders) { + setFoldersToImport([]); + } + if (reviewingSections) { setSectionsToImport([]); } }; @@ -161,7 +232,12 @@ const ImportingContent = ({ if (reviewingQuestions) { const filteredQuestions = questionsToImport.filter((_, i) => i !== index); setQuestionsToImport(filteredQuestions); - } else { + } + if (reviewingFolders) { + const filteredFolders = foldersToImport.filter((_, i) => i !== index); + setFoldersToImport(filteredFolders); + } + if (reviewingSections) { const filteredSections = sectionsToImport.filter((_, i) => i !== index); setSectionsToImport(filteredSections); } @@ -203,6 +279,32 @@ const ImportingContent = ({ } }; + const isInsideRepeatingSection = () => { + switch (currentEntityName) { + case "section": { + const section = getSectionById(sourceQuestionnaire, currentEntityId); + return section.repeatingSection; + } + case "folder": { + const section = getSectionByFolderId( + sourceQuestionnaire, + currentEntityId + ); + return section.repeatingSection; + } + case "page": { + const section = getSectionByPageId( + sourceQuestionnaire, + currentEntityId + ); + return section.repeatingSection; + } + default: { + return false; + } + } + }; + const onReviewQuestionsSubmit = (selectedQuestions) => { const questionIds = selectedQuestions.map(({ id }) => id); let questionContainsExtraSpaces = false; @@ -218,6 +320,8 @@ const ImportingContent = ({ setSelectingQuestions(false); setReviewingSections(false); setSelectingSections(false); + setSelectingFolders(false); + setReviewingFolders(false); setSelectingContent(false); setShowQuestionExtraSpaceModal(true); } else { @@ -309,6 +413,91 @@ const ImportingContent = ({ } }; + const onReviewFoldersSubmit = (selectedFolders) => { + const folderIds = selectedFolders.map(({ id }) => id); + let folderContainsExtraSpaces = false; + + selectedFolders.forEach((selectedFolder) => { + if (containsExtraSpaces(selectedFolder)) { + folderContainsExtraSpaces = true; + } + }); + + if (folderContainsExtraSpaces && !showFolderExtraSpaceModal) { + setReviewingQuestions(false); + setSelectingQuestions(false); + setReviewingSections(false); + setSelectingSections(false); + setSelectingFolders(false); + setReviewingFolders(false); + setSelectingContent(false); + setShowFolderExtraSpaceModal(true); + } else { + let input = { + folderIds, + questionnaireId: questionnaireImportingFrom.id, + }; + + switch (currentEntityName) { + case "section": { + input.position = { + sectionId: currentEntityId, + index: 0, + }; + + break; + } + case "folder": { + const { id: sectionId } = getSectionByFolderId( + sourceQuestionnaire, + currentEntityId + ); + + const { position } = getFolderById( + sourceQuestionnaire, + currentEntityId + ); + + input.position = { + sectionId, + }; + + input.position.index = position + 1; + + break; + } + case "page": { + const { id: sectionId } = getSectionByPageId( + sourceQuestionnaire, + currentEntityId + ); + + input.position = { + sectionId, + }; + + const { position: folderPosition } = getFolderByPageId( + sourceQuestionnaire, + currentEntityId + ); + + input.position.index = folderPosition + 1; + + break; + } + default: { + throw new Error("Unknown entity"); + } + } + + importFolders({ + variables: { input }, + refetchQueries: ["GetQuestionnaire"], + }); + onGlobalCancel(); + } + }; + // Selecting sections to import const onSectionPickerCancel = () => { @@ -316,6 +505,8 @@ const ImportingContent = ({ setReviewingSections(true); setSelectingQuestions(false); setReviewingQuestions(false); + setSelectingFolders(false); + setReviewingFolders(false); setSelectingContent(false); }; @@ -325,6 +516,8 @@ const ImportingContent = ({ setReviewingSections(true); setSelectingQuestions(false); setReviewingQuestions(false); + setSelectingFolders(false); + setReviewingFolders(false); setSelectingContent(false); }; @@ -335,6 +528,8 @@ const ImportingContent = ({ setSelectingSections(true); setSelectingQuestions(false); setReviewingQuestions(false); + setSelectingFolders(false); + setReviewingFolders(false); setSelectingContent(false); }; @@ -344,6 +539,8 @@ const ImportingContent = ({ setReviewingSections(false); setSelectingQuestions(false); setReviewingQuestions(false); + setSelectingFolders(false); + setReviewingFolders(false); setSelectingContent(false); }; @@ -363,6 +560,8 @@ const ImportingContent = ({ setReviewingSections(false); setSelectingSections(false); setSelectingContent(false); + setSelectingFolders(false); + setReviewingFolders(false); setShowQuestionExtraSpaceModal(false); setShowSectionExtraSpaceModal(true); } else { @@ -459,6 +658,7 @@ const ImportingContent = ({ onCancel={onGlobalCancel} onBack={onBackFromReviewingQuestions} onSelectQuestions={onSelectQuestions} + onSelectFolders={onSelectFolders} onSelectSections={onSelectSections} /> )} @@ -471,6 +671,7 @@ const ImportingContent = ({ onConfirm={onReviewQuestionsSubmit} onBack={onBackFromReviewingQuestions} onSelectQuestions={onSelectQuestions} + onSelectFolders={onSelectFolders} onSelectSections={onSelectSections} onRemoveAll={onRemoveAllSelectedContent} onRemoveSingle={onRemoveSingleSelectedContent} @@ -500,7 +701,6 @@ const ImportingContent = ({ isOpen={selectingQuestions} sections={sections} startingSelectedQuestions={questionsToImport} - warningPanel="You cannot import folders but you can import any questions they contain." showSearch onClose={onGlobalCancel} onCancel={onQuestionPickerCancel} @@ -511,6 +711,75 @@ const ImportingContent = ({ }} )} + {selectingFolders && ( + + {({ loading, error, data }) => { + if (loading) { + return ; + } + + if (error || !data) { + return ; + } + + const { sections } = data.questionnaire; + const sectionsToDisplay = []; + + // Removes list collector folders from sections containing selectable folders when importing into a repeating section + if (isInsideRepeatingSection()) { + sections.forEach((section) => { + const foldersWithoutListCollectors = section.folders.filter( + (folder) => folder.listId == null + ); + sectionsToDisplay.push({ + ...section, + folders: foldersWithoutListCollectors, + }); + }); + } else { + sectionsToDisplay.push(...sections); + } + + return ( + + ); + }} + + )} + {reviewingFolders && ( + + )} {reviewingSections && ( )} + {showFolderExtraSpaceModal && ( + + onReviewFoldersSubmit(foldersToImport)} + onClose={onGlobalCancel} + > +

+ The selected content contains extra spaces at the start of lines + of text, between words, or at the end of lines of text. +

+

+ Extra spaces need to be removed before this content can be + imported. +

+
+
+ )} {showSectionExtraSpaceModal && ( ({ useMutation.mockImplementation(jest.fn(() => [jest.fn()])); -const destinationQuestionnaire = buildQuestionnaire({ answerCount: 1 }); +const destinationQuestionnaire = buildQuestionnaire({ + answerCount: 1, + sectionCount: 2, +}); const listCollectorFolder = buildListCollectorFolders()[0]; -destinationQuestionnaire.sections[0].folders[1] = listCollectorFolder; listCollectorFolder.position = 1; +destinationQuestionnaire.sections[0].folders[1] = listCollectorFolder; +destinationQuestionnaire.sections[1] = { + ...destinationQuestionnaire.sections[1], + repeatingSection: true, + repeatingSectionListId: "list-1", +}; const extraSpaceModalTitle = "Confirm the removal of extra spaces from selected content"; @@ -59,6 +67,7 @@ const sourceQuestionnaires = [ folders: [ { id: "folder-1", + displayName: "Folder 1", pages: [ { id: "page-1", @@ -117,6 +126,7 @@ const sourceQuestionnaires = [ folders: [ { id: "folder-2", + displayName: "Folder 2", pages: [ { id: "page-3", @@ -126,6 +136,7 @@ const sourceQuestionnaires = [ { id: "answer-3", type: "Number", + label: "Answer 3 ", }, ], }, @@ -152,6 +163,7 @@ const sourceQuestionnaires = [ folders: [ { id: "folder-3", + displayName: "Folder 3", pages: [ { id: "page-5", @@ -188,6 +200,7 @@ const sourceQuestionnaires = [ folders: [ { id: "folder-4", + displayName: "Folder 4", pages: [ { id: "page-7", @@ -212,6 +225,7 @@ const sourceQuestionnaires = [ folders: [ { id: "folder-5", + displayName: "Folder 5", pages: [ { id: "page-8", @@ -236,6 +250,7 @@ const sourceQuestionnaires = [ folders: [ { id: "folder-6", + displayName: "Folder 6", pages: [ { id: "page-9", @@ -250,6 +265,24 @@ const sourceQuestionnaires = [ }, ], }, + { + id: "folder-7", + displayName: "Folder 7", + listId: "list-1", + pages: [ + { + id: "page-10", + title: "Page 10", + pageType: "QuestionPage", + answers: [ + { + id: "answer-10", + type: "Number", + }, + ], + }, + ], + }, ], }, ], @@ -420,9 +453,7 @@ describe("Importing content", () => { expect(queryByText("Page 1")).not.toBeInTheDocument(); expect( - queryByText( - "*Select individual questions or entire sections to be imported, you cannot choose both*" - ) + queryByText("Select sections, folders or questions to import") ).toBeInTheDocument(); }); @@ -478,9 +509,7 @@ describe("Importing content", () => { expect(queryByText("Page 1")).not.toBeInTheDocument(); expect(queryByText("Page 2")).not.toBeInTheDocument(); expect( - getByText( - "*Select individual questions or entire sections to be imported, you cannot choose both*" - ) + getByText("Select sections, folders or questions to import") ).toBeInTheDocument(); }); @@ -1183,6 +1212,725 @@ describe("Importing content", () => { }); }); + describe("import folders", () => { + it("should open the 'Select the folder(s) to import' modal", () => { + const { getByTestId, getAllByTestId, getByText } = + renderImportingContent(); + fireEvent.click(getByText(/All/)); + const allRows = getAllByTestId("table-row"); + fireEvent.click(allRows[0]); + fireEvent.click(getByTestId("confirm-btn")); + + const foldersButton = getByTestId("content-modal-select-folders-button"); + + fireEvent.click(foldersButton); + + expect(getByText("Select the folder(s) to import")).toBeInTheDocument(); + // Tests all folders (folders 1-7) are in the document + for (let i = 1; i < 8; i++) { + expect(getByText(`Folder ${i}`)).toBeInTheDocument(); + } + }); + + it("should display a selected folder on the review modal", () => { + const { getByTestId, getAllByTestId, getByText } = + renderImportingContent(); + fireEvent.click(getByText(/All/)); + const allRows = getAllByTestId("table-row"); + fireEvent.click(allRows[0]); + fireEvent.click(getByTestId("confirm-btn")); + + const foldersButton = getByTestId("content-modal-select-folders-button"); + + fireEvent.click(foldersButton); + fireEvent.click(getByText("Folder 1")); + fireEvent.click(getByTestId("button-group").children[1]); + + expect(getByText("Folder 1")).toBeInTheDocument(); + expect( + getByText("Import content from Source questionnaire 1") + ).toBeInTheDocument(); + }); + + it("should cancel select folder modal", () => { + const { getByTestId, getAllByTestId, getByText, queryByText } = + renderImportingContent(); + fireEvent.click(getByText(/All/)); + const allRows = getAllByTestId("table-row"); + fireEvent.click(allRows[0]); + fireEvent.click(getByTestId("confirm-btn")); + + const foldersButton = getByTestId("content-modal-select-folders-button"); + + fireEvent.click(foldersButton); + fireEvent.click(getByText("Folder 1")); + + fireEvent.click(getByTestId("button-group").children[0]); + + expect(queryByText("Folder 1")).not.toBeInTheDocument(); + expect( + queryByText("Select sections, folders or questions to import") + ).toBeInTheDocument(); + }); + + it("should return to questionnaire selector modal on back button click from folder review modal", () => { + const { getByTestId, getAllByTestId, getByText, queryByText } = + renderImportingContent(); + fireEvent.click(getByText(/All/)); + const allRows = getAllByTestId("table-row"); + fireEvent.click(allRows[0]); + fireEvent.click(getByTestId("confirm-btn")); + + const foldersButton = getByTestId("content-modal-select-folders-button"); + + fireEvent.click(foldersButton); + fireEvent.click(getByText("Folder 1")); + fireEvent.click(getByTestId("button-group").children[1]); + + expect(queryByText("Folder 1")).toBeInTheDocument(); + expect( + getByText("Import content from Source questionnaire 1") + ).toBeInTheDocument(); + + const backButton = getByText("Back"); + fireEvent.click(backButton); + + expect(queryByText("Folder 1")).not.toBeInTheDocument(); + expect(getByTestId("questionnaire-select-modal")).toBeInTheDocument(); + expect( + queryByText("Select the source questionnaire") + ).toBeInTheDocument(); + expect(queryByText("Source questionnaire 1")).toBeInTheDocument(); + }); + + it("should remove all selected folders", () => { + const { getByTestId, getAllByTestId, getByText, queryByText } = + renderImportingContent(); + fireEvent.click(getByText(/All/)); + const allRows = getAllByTestId("table-row"); + fireEvent.click(allRows[0]); + fireEvent.click(getByTestId("confirm-btn")); + + const foldersButton = getByTestId("content-modal-select-folders-button"); + + fireEvent.click(foldersButton); + fireEvent.click(getByText("Folder 1")); + fireEvent.click(getByText("Folder 2")); + fireEvent.click(getByTestId("button-group").children[1]); + + expect(queryByText("Folder 1")).toBeInTheDocument(); + expect(queryByText("Folder 2")).toBeInTheDocument(); + + fireEvent.click(getByText("Remove all")); + + expect(queryByText("Folder 1")).not.toBeInTheDocument(); + expect(queryByText("Folder 2")).not.toBeInTheDocument(); + expect( + getByText("Select sections, folders or questions to import") + ).toBeInTheDocument(); + }); + + it("should remove a selected folder using the item's remove button", () => { + const { getByTestId, getAllByTestId, getByText, queryByText } = + renderImportingContent(); + fireEvent.click(getByText(/All/)); + const allRows = getAllByTestId("table-row"); + fireEvent.click(allRows[0]); + fireEvent.click(getByTestId("confirm-btn")); + + const foldersButton = getByTestId("content-modal-select-folders-button"); + + fireEvent.click(foldersButton); + fireEvent.click(getByText("Folder 1")); + fireEvent.click(getByText("Folder 2")); + fireEvent.click(getByTestId("button-group").children[1]); + + expect(getByText("Folder 1")).toBeInTheDocument(); + expect(getByText("Folder 2")).toBeInTheDocument(); + expect(getByText("Folders to import")).toBeInTheDocument(); + + fireEvent.click(getByTestId("folder-review-item-remove-button-folder-1")); + + expect(queryByText("Folder 1")).not.toBeInTheDocument(); + expect(getByText("Folder 2")).toBeInTheDocument(); + expect(getByText("Folder to import")).toBeInTheDocument(); + }); + + it("should select multiple folders", () => { + const { getByTestId, getAllByTestId, getByText } = + renderImportingContent(); + fireEvent.click(getByText(/All/)); + const allRows = getAllByTestId("table-row"); + fireEvent.click(allRows[0]); + fireEvent.click(getByTestId("confirm-btn")); + + const foldersButton = getByTestId("content-modal-select-folders-button"); + + fireEvent.click(foldersButton); + fireEvent.click(getByText("Folder 1")); + fireEvent.click(getByTestId("button-group").children[1]); + fireEvent.click(getByTestId("folder-review-select-folders-button")); + fireEvent.click(getByText("Folder 2")); + fireEvent.click(getByTestId("button-group").children[1]); + + expect(getByText("Folder 1")).toBeInTheDocument(); + expect(getByText("Folder 2")).toBeInTheDocument(); + expect(getByText("Folders to import")).toBeInTheDocument(); + }); + + it("should render empty fragment for folder list loading", () => { + const { queryByText, getByTestId, getByText, getAllByTestId } = + renderImportingContent(); + fireEvent.click(getByText(/All/)); + const allRows = getAllByTestId("table-row"); + fireEvent.click(allRows[0]); + fireEvent.click(getByTestId("confirm-btn")); + + const foldersButton = getByTestId("content-modal-select-folders-button"); + useQuery.mockImplementationOnce(() => ({ + loading: true, + })); + fireEvent.click(foldersButton); + + expect( + queryByText("Select the folder(s) to import") + ).not.toBeInTheDocument(); + }); + + it("should render empty fragment for folder list error", () => { + const { queryByText, getByTestId, getByText, getAllByTestId } = + renderImportingContent(); + fireEvent.click(getByText(/All/)); + const allRows = getAllByTestId("table-row"); + fireEvent.click(allRows[0]); + fireEvent.click(getByTestId("confirm-btn")); + + const foldersButton = getByTestId("content-modal-select-folders-button"); + useQuery.mockImplementationOnce(() => ({ + error: true, + })); + fireEvent.click(foldersButton); + + expect( + queryByText("Select the folder(s) to import") + ).not.toBeInTheDocument(); + }); + + it("should not display list collector folders when importing into a repeating section", () => { + useParams.mockImplementation(() => ({ + questionnaireId: destinationQuestionnaire.id, + entityName: "section", + entityId: destinationQuestionnaire.sections[1].id, + })); + + const { getByTestId, getAllByTestId, getByText, queryByText } = + renderImportingContent(); + fireEvent.click(getByText(/All/)); + const allRows = getAllByTestId("table-row"); + fireEvent.click(allRows[0]); + fireEvent.click(getByTestId("confirm-btn")); + + const foldersButton = getByTestId("content-modal-select-folders-button"); + + fireEvent.click(foldersButton); + + // Tests all basic folders (folders 1-6) are in the document + for (let i = 1; i < 7; i++) { + expect(getByText(`Folder ${i}`)).toBeInTheDocument(); + } + + // Tests list collector folder is not in the document + expect(queryByText("Folder 7")).not.toBeInTheDocument(); + }); + + describe("Confirm import folder", () => { + it("should import folder to destination questionnaire section", () => { + const mockImportFolders = jest.fn(); + useParams.mockImplementation(() => ({ + questionnaireId: destinationQuestionnaire.id, + entityName: "section", + entityId: destinationQuestionnaire.sections[0].id, + })); + + useMutation.mockImplementation(jest.fn(() => [mockImportFolders])); + const { getByTestId, getAllByTestId, getByText, queryByText } = + renderImportingContent(); + fireEvent.click(getByText(/All/)); + const allRows = getAllByTestId("table-row"); + fireEvent.click(allRows[0]); + fireEvent.click(getByTestId("confirm-btn")); + + const foldersButton = getByTestId( + "content-modal-select-folders-button" + ); + + fireEvent.click(foldersButton); + fireEvent.click(getByText("Folder 1")); + // Folder picker's "Select" button + fireEvent.click(getByTestId("button-group").children[1]); + // Folder review modal's "Import" button + fireEvent.click(getByTestId("button-group").children[0]); + + const sourceSection = sourceQuestionnaires[0].sections[0]; + const destinationSection = destinationQuestionnaire.sections[0]; + + // Test modal closes + expect( + queryByText("Import content from Source questionnaire 1") + ).not.toBeInTheDocument(); + + expect(mockImportFolders).toHaveBeenCalledTimes(1); + expect(mockImportFolders).toHaveBeenCalledWith({ + variables: { + input: { + folderIds: [sourceSection.folders[0].id], + questionnaireId: sourceQuestionnaires[0].id, + position: { + sectionId: destinationSection.id, + index: 0, + }, + }, + }, + refetchQueries: ["GetQuestionnaire"], + }); + }); + + it("should import folder to destination questionnaire folder", () => { + const mockImportFolders = jest.fn(); + useParams.mockImplementation(() => ({ + questionnaireId: destinationQuestionnaire.id, + entityName: "folder", + entityId: destinationQuestionnaire.sections[0].folders[0].id, + })); + + useMutation.mockImplementation(jest.fn(() => [mockImportFolders])); + const { getByTestId, getAllByTestId, getByText, queryByText } = + renderImportingContent(); + fireEvent.click(getByText(/All/)); + const allRows = getAllByTestId("table-row"); + fireEvent.click(allRows[0]); + fireEvent.click(getByTestId("confirm-btn")); + + const foldersButton = getByTestId( + "content-modal-select-folders-button" + ); + + fireEvent.click(foldersButton); + fireEvent.click(getByText("Folder 1")); + // Folder picker's "Select" button + fireEvent.click(getByTestId("button-group").children[1]); + // Folder review modal's "Import" button + fireEvent.click(getByTestId("button-group").children[0]); + + const sourceSection = sourceQuestionnaires[0].sections[0]; + const destinationSection = destinationQuestionnaire.sections[0]; + + // Test modal closes + expect( + queryByText("Import content from Source questionnaire 1") + ).not.toBeInTheDocument(); + + expect(mockImportFolders).toHaveBeenCalledTimes(1); + expect(mockImportFolders).toHaveBeenCalledWith({ + variables: { + input: { + folderIds: [sourceSection.folders[0].id], + questionnaireId: sourceQuestionnaires[0].id, + position: { + sectionId: destinationSection.id, + index: 1, + }, + }, + }, + refetchQueries: ["GetQuestionnaire"], + }); + }); + + it("should import folder to destination questionnaire page", () => { + const mockImportFolders = jest.fn(); + useParams.mockImplementation(() => ({ + questionnaireId: destinationQuestionnaire.id, + entityName: "page", + entityId: destinationQuestionnaire.sections[0].folders[0].pages[0].id, + })); + + useMutation.mockImplementation(jest.fn(() => [mockImportFolders])); + const { getByTestId, getAllByTestId, getByText, queryByText } = + renderImportingContent(); + fireEvent.click(getByText(/All/)); + const allRows = getAllByTestId("table-row"); + fireEvent.click(allRows[0]); + fireEvent.click(getByTestId("confirm-btn")); + + const foldersButton = getByTestId( + "content-modal-select-folders-button" + ); + + fireEvent.click(foldersButton); + fireEvent.click(getByText("Folder 1")); + // Folder picker's "Select" button + fireEvent.click(getByTestId("button-group").children[1]); + // Folder review modal's "Import" button + fireEvent.click(getByTestId("button-group").children[0]); + + const sourceSection = sourceQuestionnaires[0].sections[0]; + const destinationSection = destinationQuestionnaire.sections[0]; + + // Test modal closes + expect( + queryByText("Import content from Source questionnaire 1") + ).not.toBeInTheDocument(); + + expect(mockImportFolders).toHaveBeenCalledTimes(1); + expect(mockImportFolders).toHaveBeenCalledWith({ + variables: { + input: { + folderIds: [sourceSection.folders[0].id], + questionnaireId: sourceQuestionnaires[0].id, + position: { + sectionId: destinationSection.id, + index: 1, + }, + }, + }, + refetchQueries: ["GetQuestionnaire"], + }); + }); + + describe("Extra spaces", () => { + it("should display extra space confirmation modal before importing folders containing extra spaces", () => { + const mockImportFolders = jest.fn(); + useParams.mockImplementation(() => ({ + questionnaireId: destinationQuestionnaire.id, + entityName: "section", + entityId: destinationQuestionnaire.sections[0].id, + })); + + useMutation.mockImplementation(jest.fn(() => [mockImportFolders])); + const { getByTestId, getAllByTestId, getByText, queryByText } = + renderImportingContent(); + fireEvent.click(getByText(/All/)); + const allRows = getAllByTestId("table-row"); + fireEvent.click(allRows[0]); + fireEvent.click(getByTestId("confirm-btn")); + + const foldersButton = getByTestId( + "content-modal-select-folders-button" + ); + + fireEvent.click(foldersButton); + fireEvent.click(getByText("Folder 3")); + // Folder picker's "Select" button + fireEvent.click(getByTestId("button-group").children[1]); + + expect( + queryByText("Import content from Source questionnaire 1") + ).toBeInTheDocument(); + + // Folder review modal's "Import" button + fireEvent.click(getByTestId("button-group").children[0]); + + const sourceSection = sourceQuestionnaires[0].sections[2]; + const destinationSection = destinationQuestionnaire.sections[0]; + + // Test modal closes + expect( + queryByText("Import content from Source questionnaire 1") + ).not.toBeInTheDocument(); + + expect(mockImportFolders).toHaveBeenCalledTimes(0); + + // Extra space confirmation modal + + expect(queryByText(extraSpaceModalTitle)).toBeInTheDocument(); + const extraSpaceModalConfirmButton = + getByTestId("btn-modal-positive"); + + fireEvent.click(extraSpaceModalConfirmButton); + expect(mockImportFolders).toHaveBeenCalledTimes(1); + + expect(mockImportFolders).toHaveBeenCalledWith({ + variables: { + input: { + folderIds: [sourceSection.folders[0].id], + questionnaireId: sourceQuestionnaires[0].id, + position: { + sectionId: destinationSection.id, + index: 0, + }, + }, + }, + refetchQueries: ["GetQuestionnaire"], + }); + }); + + it("should display extra space confirmation modal before importing folders containing leading spaces", () => { + const mockImportFolders = jest.fn(); + useParams.mockImplementation(() => ({ + questionnaireId: destinationQuestionnaire.id, + entityName: "section", + entityId: destinationQuestionnaire.sections[0].id, + })); + + useMutation.mockImplementation(jest.fn(() => [mockImportFolders])); + const { getByTestId, getAllByTestId, getByText, queryByText } = + renderImportingContent(); + fireEvent.click(getByText(/All/)); + const allRows = getAllByTestId("table-row"); + fireEvent.click(allRows[0]); + fireEvent.click(getByTestId("confirm-btn")); + + const foldersButton = getByTestId( + "content-modal-select-folders-button" + ); + + fireEvent.click(foldersButton); + fireEvent.click(getByText("Folder 4")); + // Folder picker's "Select" button + fireEvent.click(getByTestId("button-group").children[1]); + + expect( + queryByText("Import content from Source questionnaire 1") + ).toBeInTheDocument(); + + // Folder review modal's "Import" button + fireEvent.click(getByTestId("button-group").children[0]); + + const sourceSection = sourceQuestionnaires[0].sections[3]; + const destinationSection = destinationQuestionnaire.sections[0]; + + // Test modal closes + expect( + queryByText("Import content from Source questionnaire 1") + ).not.toBeInTheDocument(); + + expect(mockImportFolders).toHaveBeenCalledTimes(0); + + // Extra space confirmation modal + + expect(queryByText(extraSpaceModalTitle)).toBeInTheDocument(); + const extraSpaceModalConfirmButton = + getByTestId("btn-modal-positive"); + + fireEvent.click(extraSpaceModalConfirmButton); + expect(mockImportFolders).toHaveBeenCalledTimes(1); + + expect(mockImportFolders).toHaveBeenCalledWith({ + variables: { + input: { + folderIds: [sourceSection.folders[0].id], + questionnaireId: sourceQuestionnaires[0].id, + position: { + sectionId: destinationSection.id, + index: 0, + }, + }, + }, + refetchQueries: ["GetQuestionnaire"], + }); + }); + + it("should display extra space confirmation modal before importing folders containing trailing spaces", () => { + const mockImportFolders = jest.fn(); + useParams.mockImplementation(() => ({ + questionnaireId: destinationQuestionnaire.id, + entityName: "section", + entityId: destinationQuestionnaire.sections[0].id, + })); + + useMutation.mockImplementation(jest.fn(() => [mockImportFolders])); + const { getByTestId, getAllByTestId, getByText, queryByText } = + renderImportingContent(); + fireEvent.click(getByText(/All/)); + const allRows = getAllByTestId("table-row"); + fireEvent.click(allRows[0]); + fireEvent.click(getByTestId("confirm-btn")); + + const foldersButton = getByTestId( + "content-modal-select-folders-button" + ); + + fireEvent.click(foldersButton); + fireEvent.click(getByText("Folder 2")); + // Folder picker's "Select" button + fireEvent.click(getByTestId("button-group").children[1]); + + expect( + queryByText("Import content from Source questionnaire 1") + ).toBeInTheDocument(); + + // Folder review modal's "Import" button + fireEvent.click(getByTestId("button-group").children[0]); + + const sourceSection = sourceQuestionnaires[0].sections[1]; + const destinationSection = destinationQuestionnaire.sections[0]; + + // Test modal closes + expect( + queryByText("Import content from Source questionnaire 1") + ).not.toBeInTheDocument(); + + expect(mockImportFolders).toHaveBeenCalledTimes(0); + + // Extra space confirmation modal + + expect(queryByText(extraSpaceModalTitle)).toBeInTheDocument(); + const extraSpaceModalConfirmButton = + getByTestId("btn-modal-positive"); + + fireEvent.click(extraSpaceModalConfirmButton); + expect(mockImportFolders).toHaveBeenCalledTimes(1); + + expect(mockImportFolders).toHaveBeenCalledWith({ + variables: { + input: { + folderIds: [sourceSection.folders[0].id], + questionnaireId: sourceQuestionnaires[0].id, + position: { + sectionId: destinationSection.id, + index: 0, + }, + }, + }, + refetchQueries: ["GetQuestionnaire"], + }); + }); + + it("should display extra space confirmation modal before importing folders containing tags with leading spaces", () => { + const mockImportFolders = jest.fn(); + useParams.mockImplementation(() => ({ + questionnaireId: destinationQuestionnaire.id, + entityName: "section", + entityId: destinationQuestionnaire.sections[0].id, + })); + + useMutation.mockImplementation(jest.fn(() => [mockImportFolders])); + const { getByTestId, getAllByTestId, getByText, queryByText } = + renderImportingContent(); + fireEvent.click(getByText(/All/)); + const allRows = getAllByTestId("table-row"); + fireEvent.click(allRows[0]); + fireEvent.click(getByTestId("confirm-btn")); + + const foldersButton = getByTestId( + "content-modal-select-folders-button" + ); + + fireEvent.click(foldersButton); + fireEvent.click(getByText("Folder 6")); + // Folder picker's "Select" button + fireEvent.click(getByTestId("button-group").children[1]); + + expect( + queryByText("Import content from Source questionnaire 1") + ).toBeInTheDocument(); + + // Folder review modal's "Import" button + fireEvent.click(getByTestId("button-group").children[0]); + + const sourceSection = sourceQuestionnaires[0].sections[5]; + const destinationSection = destinationQuestionnaire.sections[0]; + + // Test modal closes + expect( + queryByText("Import content from Source questionnaire 1") + ).not.toBeInTheDocument(); + + expect(mockImportFolders).toHaveBeenCalledTimes(0); + + // Extra space confirmation modal + + expect(queryByText(extraSpaceModalTitle)).toBeInTheDocument(); + const extraSpaceModalConfirmButton = + getByTestId("btn-modal-positive"); + + fireEvent.click(extraSpaceModalConfirmButton); + expect(mockImportFolders).toHaveBeenCalledTimes(1); + + expect(mockImportFolders).toHaveBeenCalledWith({ + variables: { + input: { + folderIds: [sourceSection.folders[0].id], + questionnaireId: sourceQuestionnaires[0].id, + position: { + sectionId: destinationSection.id, + index: 0, + }, + }, + }, + refetchQueries: ["GetQuestionnaire"], + }); + }); + + it("should display extra space confirmation modal before importing folders containing tags with trailing spaces", () => { + const mockImportFolders = jest.fn(); + useParams.mockImplementation(() => ({ + questionnaireId: destinationQuestionnaire.id, + entityName: "section", + entityId: destinationQuestionnaire.sections[0].id, + })); + + useMutation.mockImplementation(jest.fn(() => [mockImportFolders])); + const { getByTestId, getAllByTestId, getByText, queryByText } = + renderImportingContent(); + fireEvent.click(getByText(/All/)); + const allRows = getAllByTestId("table-row"); + fireEvent.click(allRows[0]); + fireEvent.click(getByTestId("confirm-btn")); + + const foldersButton = getByTestId( + "content-modal-select-folders-button" + ); + + fireEvent.click(foldersButton); + fireEvent.click(getByText("Folder 5")); + // Folder picker's "Select" button + fireEvent.click(getByTestId("button-group").children[1]); + + expect( + queryByText("Import content from Source questionnaire 1") + ).toBeInTheDocument(); + + // Folder review modal's "Import" button + fireEvent.click(getByTestId("button-group").children[0]); + + const sourceSection = sourceQuestionnaires[0].sections[4]; + const destinationSection = destinationQuestionnaire.sections[0]; + + // Test modal closes + expect( + queryByText("Import content from Source questionnaire 1") + ).not.toBeInTheDocument(); + + expect(mockImportFolders).toHaveBeenCalledTimes(0); + + // Extra space confirmation modal + + expect(queryByText(extraSpaceModalTitle)).toBeInTheDocument(); + const extraSpaceModalConfirmButton = + getByTestId("btn-modal-positive"); + + fireEvent.click(extraSpaceModalConfirmButton); + expect(mockImportFolders).toHaveBeenCalledTimes(1); + + expect(mockImportFolders).toHaveBeenCalledWith({ + variables: { + input: { + folderIds: [sourceSection.folders[0].id], + questionnaireId: sourceQuestionnaires[0].id, + position: { + sectionId: destinationSection.id, + index: 0, + }, + }, + }, + refetchQueries: ["GetQuestionnaire"], + }); + }); + }); + }); + }); + describe("import sections", () => { it("should open the 'Select the section(s) to import' modal", () => { const { getByTestId, getAllByTestId, getByText } = @@ -1240,9 +1988,7 @@ describe("Importing content", () => { expect(queryByText("Section 1")).not.toBeInTheDocument(); expect( - queryByText( - "*Select individual questions or entire sections to be imported, you cannot choose both*" - ) + queryByText("Select sections, folders or questions to import") ).toBeInTheDocument(); }); @@ -1298,9 +2044,7 @@ describe("Importing content", () => { expect(queryByText("Section 1")).not.toBeInTheDocument(); expect(queryByText("Section 2")).not.toBeInTheDocument(); expect( - getByText( - "*Select individual questions or entire sections to be imported, you cannot choose both*" - ) + getByText("Select sections, folders or questions to import") ).toBeInTheDocument(); }); diff --git a/eq-author/src/App/introduction/Preview/IntroductionPreview/index.js b/eq-author/src/App/introduction/Preview/IntroductionPreview/index.js index 948b0079d2..2faca929cc 100644 --- a/eq-author/src/App/introduction/Preview/IntroductionPreview/index.js +++ b/eq-author/src/App/introduction/Preview/IntroductionPreview/index.js @@ -89,12 +89,12 @@ const CollapsiblesContent = styled.div` padding: 0.2em 0 0.2em 1em; `; -const DummyLink = styled.text` +const DummyLink = styled.span` color: ${colors.blue}; text-decoration: underline; `; -const MissingText = styled.text` +const MissingText = styled.span` font-weight: bold; background-color: ${colors.errorSecondary}; text-align: center; diff --git a/eq-author/src/App/page/Design/ListCollectorPageEditor/index.js b/eq-author/src/App/page/Design/ListCollectorPageEditor/index.js index cc6f0b8517..02269d5968 100644 --- a/eq-author/src/App/page/Design/ListCollectorPageEditor/index.js +++ b/eq-author/src/App/page/Design/ListCollectorPageEditor/index.js @@ -782,6 +782,9 @@ UnwrappedListCollectorPageEditor.fragments = { folder { id position + ... on ListCollectorFolder { + listId + } } section { id 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 c3817f40f0..de428b8405 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 @@ -110,6 +110,9 @@ exports[`Answer Editor should render Checkbox 1`] = ` "label": "", }, ], + "page": Object { + "id": "1", + }, "properties": Object {}, "qCode": "", "secondaryLabel": "", @@ -240,6 +243,9 @@ exports[`Answer Editor should render Currency 1`] = ` "guidance": "", "id": "1", "label": "", + "page": Object { + "id": "1", + }, "properties": Object {}, "qCode": "", "secondaryLabel": "", @@ -371,6 +377,9 @@ exports[`Answer Editor should render Date 1`] = ` "guidance": "", "id": "1", "label": "", + "page": Object { + "id": "1", + }, "properties": Object {}, "qCode": "", "secondaryLabel": "", @@ -504,6 +513,9 @@ exports[`Answer Editor should render DateRange 1`] = ` "guidance": "", "id": "1", "label": "", + "page": Object { + "id": "1", + }, "properties": Object {}, "qCode": "", "secondaryLabel": "", @@ -633,6 +645,9 @@ exports[`Answer Editor should render Duration 1`] = ` "guidance": "", "id": "1", "label": "", + "page": Object { + "id": "1", + }, "properties": Object { "unit": "YearsMonths", }, @@ -775,6 +790,9 @@ exports[`Answer Editor should render Mutually Exclusive 1`] = ` "label": "", }, ], + "page": Object { + "id": "1", + }, "properties": Object {}, "qCode": "", "secondaryLabel": "", @@ -905,6 +923,9 @@ exports[`Answer Editor should render Number 1`] = ` "guidance": "", "id": "1", "label": "", + "page": Object { + "id": "1", + }, "properties": Object {}, "qCode": "", "secondaryLabel": "", @@ -1036,6 +1057,9 @@ exports[`Answer Editor should render Percentage 1`] = ` "guidance": "", "id": "1", "label": "", + "page": Object { + "id": "1", + }, "properties": Object {}, "qCode": "", "secondaryLabel": "", @@ -1174,6 +1198,9 @@ exports[`Answer Editor should render Radio 1`] = ` "label": "", }, ], + "page": Object { + "id": "1", + }, "properties": Object {}, "qCode": "", "secondaryLabel": "", @@ -1313,6 +1340,9 @@ exports[`Answer Editor should render Select 1`] = ` "label": "", }, ], + "page": Object { + "id": "1", + }, "properties": Object {}, "qCode": "", "secondaryLabel": "", @@ -1444,6 +1474,9 @@ exports[`Answer Editor should render TextArea 1`] = ` "guidance": "", "id": "1", "label": "", + "page": Object { + "id": "1", + }, "properties": Object {}, "qCode": "", "secondaryLabel": "", @@ -1574,6 +1607,9 @@ exports[`Answer Editor should render TextField 1`] = ` "guidance": "", "id": "1", "label": "", + "page": Object { + "id": "1", + }, "properties": Object {}, "qCode": "", "secondaryLabel": "", @@ -1705,6 +1741,9 @@ exports[`Answer Editor should render Unit 1`] = ` "guidance": "", "id": "1", "label": "", + "page": Object { + "id": "1", + }, "properties": Object { "unit": "Centimetres", }, 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 28e2e6ae8f..4330bfb7a3 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 @@ -139,7 +139,7 @@ class AnswerEditor extends React.Component { if (type === DATE_RANGE) { return ; } - // Only option left is Date as validation done in prop types + return ( { mockAnswer = { id: "1", title: "", + page: { id: "1" }, description: "", type: TEXTFIELD, guidance: "", diff --git a/eq-author/src/App/page/Design/QuestionPageEditor/QuestionProperties/AdditionalContentOptions.js b/eq-author/src/App/page/Design/QuestionPageEditor/QuestionProperties/AdditionalContentOptions.js index 1b413e2139..374d05a4fd 100644 --- a/eq-author/src/App/page/Design/QuestionPageEditor/QuestionProperties/AdditionalContentOptions.js +++ b/eq-author/src/App/page/Design/QuestionPageEditor/QuestionProperties/AdditionalContentOptions.js @@ -220,7 +220,7 @@ export const StatelessAdditionalInfo = ({ StatelessAdditionalInfo.propTypes = { onChange: PropTypes.func.isRequired, onUpdate: PropTypes.func.isRequired, - fetchAnswers: PropTypes.func.isRequired, + fetchAnswers: PropTypes.func, page: propType(pageFragment).isRequired, onChangeUpdate: PropTypes.func.isRequired, option: PropTypes.string.isRequired, diff --git a/eq-author/src/App/page/Design/QuestionPageEditor/index.js b/eq-author/src/App/page/Design/QuestionPageEditor/index.js index 2b95e35d54..6445575678 100644 --- a/eq-author/src/App/page/Design/QuestionPageEditor/index.js +++ b/eq-author/src/App/page/Design/QuestionPageEditor/index.js @@ -239,6 +239,9 @@ UnwrappedQuestionPageEditor.fragments = { folder { id position + ... on ListCollectorFolder { + listId + } } section { id diff --git a/eq-author/src/App/page/Design/answers/DateRange/__snapshots__/index.test.js.snap b/eq-author/src/App/page/Design/answers/DateRange/__snapshots__/index.test.js.snap index 8b6787e69c..f063ef2af0 100644 --- a/eq-author/src/App/page/Design/answers/DateRange/__snapshots__/index.test.js.snap +++ b/eq-author/src/App/page/Design/answers/DateRange/__snapshots__/index.test.js.snap @@ -13,6 +13,9 @@ exports[`DateRange should render 1`] = ` "guidance": "", "id": "1", "label": "", + "page": Object { + "id": "Page 1", + }, "properties": Object { "required": false, }, @@ -42,6 +45,9 @@ exports[`DateRange should render 1`] = ` "guidance": "", "id": "1", "label": "", + "page": Object { + "id": "Page 1", + }, "properties": Object { "required": false, }, @@ -71,6 +77,9 @@ exports[`DateRange should render 1`] = ` "guidance": "", "id": "1", "label": "", + "page": Object { + "id": "Page 1", + }, "properties": Object { "required": false, }, @@ -94,6 +103,9 @@ exports[`DateRange should render 1`] = ` "guidance": "", "id": "1", "label": "", + "page": Object { + "id": "Page 1", + }, "properties": Object { "required": false, }, @@ -118,6 +130,9 @@ exports[`DateRange should render 1`] = ` "guidance": "", "id": "1", "label": "", + "page": Object { + "id": "Page 1", + }, "properties": Object { "required": false, }, @@ -140,6 +155,9 @@ exports[`DateRange should render 1`] = ` "guidance": "", "id": "1", "label": "", + "page": Object { + "id": "Page 1", + }, "properties": Object { "required": false, }, diff --git a/eq-author/src/App/page/Design/answers/DateRange/index.test.js b/eq-author/src/App/page/Design/answers/DateRange/index.test.js index a3580c24d1..173fb8e175 100644 --- a/eq-author/src/App/page/Design/answers/DateRange/index.test.js +++ b/eq-author/src/App/page/Design/answers/DateRange/index.test.js @@ -22,6 +22,9 @@ describe("DateRange", () => { id: "1", label: "", type: "DateRange", + page: { + id: "Page 1", + }, description: "test", guidance: "", secondaryLabel: "", diff --git a/eq-author/src/App/page/Design/answers/DateSingle/__snapshots__/index.test.js.snap b/eq-author/src/App/page/Design/answers/DateSingle/__snapshots__/index.test.js.snap index cb76f7775b..41e270389c 100644 --- a/eq-author/src/App/page/Design/answers/DateSingle/__snapshots__/index.test.js.snap +++ b/eq-author/src/App/page/Design/answers/DateSingle/__snapshots__/index.test.js.snap @@ -17,6 +17,9 @@ exports[`Date should render 1`] = ` "mutuallyExclusive": false, }, ], + "page": Object { + "id": "Page 1", + }, "properties": Object {}, "qCode": "", "secondaryLabel": null, diff --git a/eq-author/src/App/page/Design/answers/DateSingle/index.test.js b/eq-author/src/App/page/Design/answers/DateSingle/index.test.js index 161fdf49b5..a6d6318353 100644 --- a/eq-author/src/App/page/Design/answers/DateSingle/index.test.js +++ b/eq-author/src/App/page/Design/answers/DateSingle/index.test.js @@ -24,6 +24,9 @@ describe("Date", () => { answer = { id: "ansID1", title: "Date title", + page: { + id: "Page 1", + }, description: "date description", label: "", type: "Date", diff --git a/eq-author/src/App/page/Design/index.js b/eq-author/src/App/page/Design/index.js index bb7a561239..094091a084 100644 --- a/eq-author/src/App/page/Design/index.js +++ b/eq-author/src/App/page/Design/index.js @@ -63,6 +63,10 @@ export const PAGE_QUERY = gql` listId } } + section { + id + repeatingSectionListId + } } } ${CalculatedSummaryPageEditor.fragments.CalculatedSummaryPage} diff --git a/eq-author/src/App/page/Logic/BinaryExpressionEditor/MultipleChoiceAnswerOptionsSelector.js b/eq-author/src/App/page/Logic/BinaryExpressionEditor/MultipleChoiceAnswerOptionsSelector.js index 2b73ffd5a1..ae0c39fae4 100644 --- a/eq-author/src/App/page/Logic/BinaryExpressionEditor/MultipleChoiceAnswerOptionsSelector.js +++ b/eq-author/src/App/page/Logic/BinaryExpressionEditor/MultipleChoiceAnswerOptionsSelector.js @@ -16,7 +16,7 @@ import { ERR_COUNT_OF_GREATER_THAN_AVAILABLE_OPTIONS, } from "constants/validationMessages"; import { colors } from "constants/theme"; -import { RADIO, SELECT } from "constants/answer-types"; +import { RADIO, SELECT, MUTUALLY_EXCLUSIVE } from "constants/answer-types"; import { Select } from "components/Forms"; import TextButton from "components/buttons/TextButton"; @@ -32,6 +32,8 @@ const answerConditions = { NOTANYOF: "NotAnyOf", }; +const exclusiveAnswers = [RADIO, SELECT, MUTUALLY_EXCLUSIVE]; + const MultipleChoiceAnswerOptions = styled.div` align-items: center; display: inline-flex; @@ -206,7 +208,7 @@ class MultipleChoiceAnswerOptionsSelector extends React.Component { return message ? {message} : null; }; - renderRadioOptionSelector(hasError) { + renderExclusiveOptionSelector(hasError) { const { expression } = this.props; const options = get(expression, "left.options", []); @@ -331,8 +333,8 @@ class MultipleChoiceAnswerOptionsSelector extends React.Component { const hasConditionError = errors.filter(({ field }) => field === "condition").length > 0; - if (answerType === RADIO || answerType === SELECT) { - return this.renderRadioOptionSelector(hasError); + if (exclusiveAnswers.includes(answerType)) { + return this.renderExclusiveOptionSelector(hasError); } else { return this.renderCheckboxOptionSelector(hasError, hasConditionError); } diff --git a/eq-author/src/App/page/Logic/BinaryExpressionEditor/RoutingAnswerContentPicker.js b/eq-author/src/App/page/Logic/BinaryExpressionEditor/RoutingAnswerContentPicker.js index 4eba90fc6b..f7b7a6240a 100644 --- a/eq-author/src/App/page/Logic/BinaryExpressionEditor/RoutingAnswerContentPicker.js +++ b/eq-author/src/App/page/Logic/BinaryExpressionEditor/RoutingAnswerContentPicker.js @@ -26,6 +26,8 @@ export const preprocessMetadata = (metadata) => const RoutingAnswerContentPicker = ({ includeSelf, selectedContentDisplayName, + expressionGroup, + selectedId, ...otherProps }) => { const { questionnaire } = useQuestionnaire(); @@ -38,8 +40,10 @@ const RoutingAnswerContentPicker = ({ id: pageId, includeTargetPage: includeSelf, preprocessAnswers, + expressionGroup, + selectedId, }), - [questionnaire, pageId, includeSelf] + [questionnaire, pageId, includeSelf, expressionGroup, selectedId] ); const filteredPreviousAnswers = previousAnswers.map((answer) => { @@ -78,6 +82,8 @@ RoutingAnswerContentPicker.propTypes = { PropTypes.object, PropTypes.string, ]), + selectedId: PropTypes.string, + expressionGroup: PropTypes.object, //eslint-disable-line }; export default RoutingAnswerContentPicker; diff --git a/eq-author/src/App/page/Logic/BinaryExpressionEditor/__snapshots__/index.test.js.snap b/eq-author/src/App/page/Logic/BinaryExpressionEditor/__snapshots__/index.test.js.snap index a2f212d948..4f0852468d 100644 --- a/eq-author/src/App/page/Logic/BinaryExpressionEditor/__snapshots__/index.test.js.snap +++ b/eq-author/src/App/page/Logic/BinaryExpressionEditor/__snapshots__/index.test.js.snap @@ -24,6 +24,36 @@ exports[`BinaryExpressionEditor should render consistently 1`] = ` > + props.isLastSummaryAnswerFromPage ? "1px solid #999" : "none"}; +`; + +const QuestionPageTitle = styled.div` + margin-bottom: 1rem; `; const SummaryLabel = styled.div` @@ -78,12 +96,74 @@ const SummaryTotalLabel = styled.div` `; const CalculatedSummaryPagePreview = ({ page }) => { + const { questionnaire } = useQuestionnaire(); useSetNavigationCallbacksForPage({ page: page, folder: page?.folder, section: page?.section, }); + const usedPageIds = []; + + const getAnswerIndexInPage = (answer) => { + const page = getPageByAnswerId(questionnaire, answer.id); + + // Finds the index of the answer argument within its page + return page?.answers?.findIndex( + (pageAnswer) => pageAnswer.id === answer.id + ); + }; + + /* + Sorts summaryAnswers by page and answers (keeps pages in the order they appear in summaryAnswers, and sorts answers by their order in their page) + Ensures answers are always displayed underneath their page, and in the order they appear in the page + .slice() creates a copy of page.summaryAnswers to prevent mutating the original page.summaryAnswers array + */ + const sortedSummaryAnswers = page.summaryAnswers.slice().sort((a, b) => { + const pageContainingAnswerA = getPageByAnswerId(questionnaire, a.id); + const pageContainingAnswerB = getPageByAnswerId(questionnaire, b.id); + + // If the answers are on the same page, sort the answers in the order they appear in the page + if (pageContainingAnswerA?.id === pageContainingAnswerB?.id) { + return getAnswerIndexInPage(a) - getAnswerIndexInPage(b); + } + + // Gets the first answer from page A that appears in summaryAnswers + const firstAnswerFromPageA = page.summaryAnswers.find( + (answer) => + getPageByAnswerId(questionnaire, answer.id)?.id === + pageContainingAnswerA?.id + ); + // Gets the first answer from page B that appears in summaryAnswers + const firstAnswerFromPageB = page.summaryAnswers.find( + (answer) => + getPageByAnswerId(questionnaire, answer.id)?.id === + pageContainingAnswerB?.id + ); + + // Sorts answers based on the order of their pages in summaryAnswers (keeps pages in the order they appear in summaryAnswers) + return ( + page.summaryAnswers.indexOf(firstAnswerFromPageA) - + page.summaryAnswers.indexOf(firstAnswerFromPageB) + ); + }); + + const getDuplicatedPageIds = () => { + const allPageIds = []; + + sortedSummaryAnswers.forEach((summaryAnswer) => { + const summaryAnswerPage = getPageByAnswerId( + questionnaire, + summaryAnswer.id + ); + allPageIds.push(summaryAnswerPage?.id); + }); + + return allPageIds.filter( + (pageId, index) => allPageIds.indexOf(pageId) !== index + ); + }; + return ( { - Please review your answers and confirm these are correct. - {page.summaryAnswers.length > 0 ? ( + {sortedSummaryAnswers.length > 0 ? ( - {page.summaryAnswers.map((answer) => ( - - - - - {answer.displayName} - - - - Value - - - Change - - - - ))} + {sortedSummaryAnswers.map((answer, index) => { + const answerPage = getPageByAnswerId(questionnaire, answer.id); + const hasAnswerPageIdBeenUsed = usedPageIds.includes( + answerPage?.id + ); + + if (!hasAnswerPageIdBeenUsed) { + usedPageIds.push(answerPage?.id); + } + + const duplicatedPageIds = getDuplicatedPageIds(); + const questionTitle = answerPage?.title.replace( + /

|<\/p>/g, + "" + ); + + const summaryAnswersOnSamePage = sortedSummaryAnswers.filter( + (summaryAnswer) => + getPageByAnswerId(questionnaire, summaryAnswer.id)?.id === + answerPage?.id + ); + const lastSummaryAnswerFromPage = + summaryAnswersOnSamePage[summaryAnswersOnSamePage.length - 1]; + + const isLastSummaryAnswerFromPage = + answer.id === lastSummaryAnswerFromPage.id; + + return ( + + {!hasAnswerPageIdBeenUsed && + duplicatedPageIds.includes(answerPage?.id) && ( + pageId === answerPage?.id + )}`} + questionTitle={questionTitle} + > + {questionTitle} + + )} + + + + + {answer.displayName} + + + + Value + + + Change + + + + ); + })} {page.totalTitle ? ( @@ -148,6 +270,7 @@ const CalculatedSummaryPagePreview = ({ page }) => { No answers selected )} + diff --git a/eq-author/src/App/page/Preview/CalculatedSummaryPreview.test.js b/eq-author/src/App/page/Preview/CalculatedSummaryPreview.test.js index 77cd6bfc80..03f9a9a283 100644 --- a/eq-author/src/App/page/Preview/CalculatedSummaryPreview.test.js +++ b/eq-author/src/App/page/Preview/CalculatedSummaryPreview.test.js @@ -4,10 +4,101 @@ import { render, flushPromises, act } from "tests/utils/rtl"; import commentsSubscription from "graphql/subscriptions/commentSubscription.graphql"; import { MeContext } from "App/MeContext"; + import { byTestAttr } from "tests/utils/selectors"; import CalculatedSummaryPreview from "./CalculatedSummaryPreview"; import { publishStatusSubscription } from "components/EditorLayout/Header"; +import QuestionnaireContext from "components/QuestionnaireContext"; + +jest.mock("@apollo/react-hooks", () => ({ + ...jest.requireActual("@apollo/react-hooks"), + useQuery: jest.fn(), + useMutation: jest.fn(() => [() => null]), +})); + +const questionnaire = { + id: "questionnaire-1", + sections: [ + { + id: "section-1", + folders: [ + { + id: "folder-1", + pages: [ + { + id: "3", + pageType: "QuestionPage", + title: "Page 1", + answers: [ + { + id: "answer-1", + label: "answer 1", + }, + { + id: "answer-2", + label: "answer 2", + }, + ], + }, + { + id: "6", + pageType: "QuestionPage", + title: "Page 2", + answers: [ + { + id: "answer-3", + label: "answer 3", + }, + { + id: "answer-4", + label: "answer 4", + }, + ], + }, + { + id: "9", + displayName: "Cal Sum", + position: 1, + title: "Calculated summary page 1", + totalTitle: "Total bills:", + pageDescription: "This page calculates the total bills", + alias: "Who am I?", + type: "Number", + answers: [], + comments: [], + + summaryAnswers: [ + { id: "answer-4", displayName: "Answer 4" }, + { id: "answer-2", displayName: "Answer 2" }, + { id: "answer-3", displayName: "Answer 3" }, + { id: "answer-1", displayName: "Answer 1" }, + ], + pageType: "CalculatedSummaryPage", + section: { + id: "1", + position: 0, + questionnaire: { + id: "1", + metadata: [], + }, + }, + validationErrorInfo: { + id: "test", + totalCount: 0, + errors: [], + }, + folder: { + id: "folder-1", + position: 0, + }, + }, + ], + }, + ], + }, + ], +}; describe("CalculatedSummaryPreview", () => { let page, me, mocks, questionnaireId; @@ -133,4 +224,31 @@ describe("CalculatedSummaryPreview", () => { ); expect(wrapper.find(byTestAttr("no-answers-selected"))).toBeTruthy(); }); + + it("should sort the summary answers based on the first selection and ensure that the display names and question titles match this order", () => { + const { getByTestId } = render( + + + + + , + , + { + route: `/q/${questionnaireId}/page/9/preview`, + urlParamMatcher: "/q/:questionnaireId/page/:pageId", + mocks, + } + ); + expect(getByTestId("question-title-0")).toHaveTextContent("Page 2"); + + expect(getByTestId("answer-item-0")).toHaveTextContent("Answer 3"); + expect(getByTestId("answer-item-1")).toHaveTextContent("Answer 4"); + + expect(getByTestId("question-title-1")).toHaveTextContent("Page 1"); + + expect(getByTestId("answer-item-2")).toHaveTextContent("Answer 1"); + expect(getByTestId("answer-item-3")).toHaveTextContent("Answer 2"); + }); }); diff --git a/eq-author/src/App/page/Preview/QuestionPagePreview.test.js b/eq-author/src/App/page/Preview/QuestionPagePreview.test.js index 831ed0529a..0fbf76a778 100644 --- a/eq-author/src/App/page/Preview/QuestionPagePreview.test.js +++ b/eq-author/src/App/page/Preview/QuestionPagePreview.test.js @@ -45,6 +45,9 @@ describe("QuestionPagePreview", () => { additionalInfoLabel: "

Additional Info Label

", additionalInfoContent: "

Additional Info Content

", additionalInfoEnabled: true, + confirmation: { + id: "confirmation-1", + }, validationErrorInfo: { totalCount: 0, errors: [] }, answers: [{ id: "1", type: TEXTFIELD }], comments: [], diff --git a/eq-author/src/App/page/Preview/index.test.js b/eq-author/src/App/page/Preview/index.test.js index 14d137d74f..ccc5c1e659 100644 --- a/eq-author/src/App/page/Preview/index.test.js +++ b/eq-author/src/App/page/Preview/index.test.js @@ -39,6 +39,9 @@ describe("page previews", () => { additionalInfoLabel: "

Additional Info Label

", additionalInfoContent: "

Additional Info Content

", additionalInfoEnabled: true, + confirmation: { + id: "confirmation-1", + }, validationErrorInfo: [], comments: [], answers: [], diff --git a/eq-author/src/App/qcodes/QCodesTable/index.js b/eq-author/src/App/qcodes/QCodesTable/index.js index ab57c0a305..caf84bed32 100644 --- a/eq-author/src/App/qcodes/QCodesTable/index.js +++ b/eq-author/src/App/qcodes/QCodesTable/index.js @@ -197,7 +197,7 @@ const Row = memo((props) => { data-test={`${id}${secondary ? "-secondary" : ""}${ listAnswerType === DRIVING ? "-driving" : "" }${listAnswerType === ANOTHER ? "-another" : ""}-test-input`} - value={qCode} + value={qCode || ""} // Ensure the input always has a value (empty string if qCode is null or undefined) onChange={(e) => setQcode(e.value)} onBlur={() => handleBlur(qCode)} hasError={Boolean(errorMessage)} @@ -271,7 +271,7 @@ export const QCodeTable = () => { (dataVersion === "3" || item.type !== "CheckboxOption") ) { return ( - <> + { {...item.additionalAnswer} errorMessage={getErrorMessage(item.additionalAnswer.qCode)} /> - + ); } else { return ( diff --git a/eq-author/src/App/questionConfirmation/Design/Editor.test.js b/eq-author/src/App/questionConfirmation/Design/Editor.test.js index ef538bd8a5..b5f792c91d 100644 --- a/eq-author/src/App/questionConfirmation/Design/Editor.test.js +++ b/eq-author/src/App/questionConfirmation/Design/Editor.test.js @@ -18,6 +18,7 @@ describe("Editor", () => { displayName: "My question", answers: [], }, + pageDescription: "Page description 1", positive: { id: "1", label: "Positive label", 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 ac6e5068f1..83672dd5be 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 @@ -27,6 +27,7 @@ exports[`Editor Editor Component should autoFocus the title when there is not on marginless={true} onChange={[MockFunction]} onUpdate={[MockFunction]} + pageDescription="Page description 1" /> @@ -108,6 +109,7 @@ exports[`Editor Editor Component should render 1`] = ` marginless={true} onChange={[MockFunction]} onUpdate={[MockFunction]} + pageDescription="Page description 1" /> diff --git a/eq-author/src/App/questionConfirmation/Design/__snapshots__/index.test.js.snap b/eq-author/src/App/questionConfirmation/Design/__snapshots__/index.test.js.snap index b6d9932b9a..74f6f7c9d5 100644 --- a/eq-author/src/App/questionConfirmation/Design/__snapshots__/index.test.js.snap +++ b/eq-author/src/App/questionConfirmation/Design/__snapshots__/index.test.js.snap @@ -45,6 +45,7 @@ exports[`QuestionConfirmationRoute should render 1`] = ` "displayName": "My question", "id": "1", }, + "pageDescription": "Page description 1", "positive": Object { "description": "Positive description", "id": "1", diff --git a/eq-author/src/App/questionConfirmation/Design/index.test.js b/eq-author/src/App/questionConfirmation/Design/index.test.js index 947632faf1..e312204a42 100644 --- a/eq-author/src/App/questionConfirmation/Design/index.test.js +++ b/eq-author/src/App/questionConfirmation/Design/index.test.js @@ -19,6 +19,7 @@ describe("QuestionConfirmationRoute", () => { id: "1", displayName: "My first displayname", title: "My first confirmation", + pageDescription: "Page description 1", qCode: "", page: { id: "1", diff --git a/eq-author/src/App/questionConfirmation/Preview/index.test.js b/eq-author/src/App/questionConfirmation/Preview/index.test.js index 7adb8bc62f..76145294bf 100644 --- a/eq-author/src/App/questionConfirmation/Preview/index.test.js +++ b/eq-author/src/App/questionConfirmation/Preview/index.test.js @@ -11,6 +11,7 @@ describe("Question Confirmation Preview", () => { id: "1", displayName: "Hello world", title: "

Hello world

", + pageDescription: "Page description 1", qCode: "", positive: { id: "1", diff --git a/eq-author/src/App/section/Design/SectionEditor/SectionEditor.test.js b/eq-author/src/App/section/Design/SectionEditor/SectionEditor.test.js index 2a8bae98bb..c4bb6f9237 100644 --- a/eq-author/src/App/section/Design/SectionEditor/SectionEditor.test.js +++ b/eq-author/src/App/section/Design/SectionEditor/SectionEditor.test.js @@ -30,13 +30,14 @@ describe("SectionEditor", () => { id: "section-1", title: "Section 1", alias: "alias", + introductionEnabled: true, introductionTitle: "Intro title", introductionContent: "Intro content", introductionPageDescription: "Intro description", requiredCompleted: true, showOnHub: true, sectionSummary: false, - sectionSummaryPageDescription: "Summary description", + sectionSummaryPageDescription: "Section summary 1", collapsibleSummary: false, repeatingSection: false, repeatingSectionListId: null, @@ -58,11 +59,14 @@ describe("SectionEditor", () => { id: "section-2", title: "Section 2", alias: "alias", + introductionEnabled: true, introductionTitle: "Intro title", introductionContent: "Intro content", + introductionPageDescription: "Intro description", requiredCompleted: true, showOnHub: true, sectionSummary: false, + sectionSummaryPageDescription: "Section summary 2", collapsibleSummary: false, repeatingSection: false, repeatingSectionListId: null, @@ -169,8 +173,11 @@ describe("SectionEditor", () => { showOnHub: false, sectionSummary: true, validationErrorInfo: { + id: "1", + totalCount: 1, errors: [ { + id: "1", type: "section", field: "title", errorCode: "ERR_REQUIRED_WHEN_SETTING", @@ -229,8 +236,11 @@ describe("SectionEditor", () => { ...section1, title: "", validationErrorInfo: { + id: "2", + totalCount: 1, errors: [ { + id: "2", type: "section", field: "title", errorCode: "ERR_REQUIRED_WHEN_SETTING", diff --git a/eq-author/src/App/section/Design/SectionEditor/SectionIntroduction/index.js b/eq-author/src/App/section/Design/SectionEditor/SectionIntroduction/index.js index d5fd0b3019..d1a0472f00 100644 --- a/eq-author/src/App/section/Design/SectionEditor/SectionIntroduction/index.js +++ b/eq-author/src/App/section/Design/SectionEditor/SectionIntroduction/index.js @@ -103,8 +103,8 @@ const SectionIntroduction = ({ SectionIntroduction.propTypes = { section: CustomPropTypes.section.isRequired, handleUpdate: PropTypes.func.isRequired, - introductionTitleErrorMessage: PropTypes.string, - introductionContentErrorMessage: PropTypes.string, + introductionTitleErrorMessage: PropTypes.array, //eslint-disable-line + introductionContentErrorMessage: PropTypes.array, //eslint-disable-line }; export default SectionIntroduction; 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 e47a4df53c..67b3b35dc6 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 @@ -61,6 +61,7 @@ exports[`SectionEditor should render 1`] = ` "comments": Array [], "id": "section-1", "introductionContent": "Intro content", + "introductionEnabled": true, "introductionPageDescription": "Intro description", "introductionTitle": "Intro title", "questionnaire": Object { @@ -73,7 +74,7 @@ exports[`SectionEditor should render 1`] = ` "repeatingSectionListId": null, "requiredCompleted": true, "sectionSummary": false, - "sectionSummaryPageDescription": "Summary description", + "sectionSummaryPageDescription": "Section summary 1", "showOnHub": true, "title": "Section 1", "validationErrorInfo": Object { @@ -89,7 +90,7 @@ exports[`SectionEditor should render 1`] = ` errors={Array []} id="section-1" sectionSummary={false} - sectionSummaryPageDescription="Summary description" + sectionSummaryPageDescription="Section summary 1" /> { id: "1", title: "", alias: "", + introductionEnabled: true, + introductionPageDescription: "Introduction page description 1", introductionTitle: "

title

", introductionContent: "

Content

", requiredCompleted: false, showOnHub: false, sectionSummary: false, + sectionSummaryPageDescription: "Section summary 1", collapsibleSummary: false, repeatingSection: false, repeatingSectionListId: null, diff --git a/eq-author/src/App/section/Preview/__snapshots__/index.test.js.snap b/eq-author/src/App/section/Preview/__snapshots__/index.test.js.snap index cea1171df3..69648d228a 100644 --- a/eq-author/src/App/section/Preview/__snapshots__/index.test.js.snap +++ b/eq-author/src/App/section/Preview/__snapshots__/index.test.js.snap @@ -40,6 +40,8 @@ exports[`PreviewSectionRoute should show the section intro preview when it is fi "comments": Array [], "id": "1", "introductionContent": "intro content", + "introductionEnabled": true, + "introductionPageDescription": "Introduction page description 1", "introductionTitle": "intro title", "questionnaire": Object { "hub": false, @@ -50,6 +52,7 @@ exports[`PreviewSectionRoute should show the section intro preview when it is fi "repeatingSectionListId": null, "requiredCompleted": false, "sectionSummary": false, + "sectionSummaryPageDescription": "Section summary 2", "showOnHub": false, "title": "", "validationErrorInfo": Object { diff --git a/eq-author/src/App/section/Preview/index.test.js b/eq-author/src/App/section/Preview/index.test.js index 9513dbbbd7..ef548c166f 100644 --- a/eq-author/src/App/section/Preview/index.test.js +++ b/eq-author/src/App/section/Preview/index.test.js @@ -31,9 +31,12 @@ describe("PreviewSectionRoute", () => { title: "", introductionTitle: "intro title", introductionContent: "intro content", + introductionEnabled: true, + introductionPageDescription: "Introduction page description 1", requiredCompleted: false, showOnHub: false, sectionSummary: false, + sectionSummaryPageDescription: "Section summary 2", collapsibleSummary: false, repeatingSection: false, repeatingSectionListId: null, @@ -70,9 +73,12 @@ describe("PreviewSectionRoute", () => { title: "", introductionTitle: "", introductionContent: "", + introductionEnabled: true, + introductionPageDescription: "Introduction page description 2", requiredCompleted: false, showOnHub: false, sectionSummary: false, + sectionSummaryPageDescription: "Section summary 2", collapsibleSummary: false, repeatingSection: false, repeatingSectionListId: null, diff --git a/eq-author/src/components/AnswerContent/RepeatLabelAndInput/index.test.js b/eq-author/src/components/AnswerContent/RepeatLabelAndInput/index.test.js index 9526b18b16..514f3ebfe4 100644 --- a/eq-author/src/components/AnswerContent/RepeatLabelAndInput/index.test.js +++ b/eq-author/src/components/AnswerContent/RepeatLabelAndInput/index.test.js @@ -1,6 +1,6 @@ import React from "react"; -import { render, fireEvent } from "tests/utils/rtl"; +import { render, fireEvent, waitFor } from "tests/utils/rtl"; import RepeatingLabelAndInput from "./index"; const handleUpdate = jest.fn(); @@ -25,8 +25,10 @@ const renderRepeatingLabelAndInput = (handleUpdate, answer) => ); describe("Repeating label and input", () => { - it("should render component", () => { - const { getByText } = renderRepeatingLabelAndInput(handleUpdate, answer); + it("should render component", async () => { + const { getByText } = await waitFor(() => + renderRepeatingLabelAndInput(handleUpdate, answer) + ); expect(getByText("Repeat label and input")).toBeTruthy(); }); @@ -35,7 +37,9 @@ describe("Repeating label and input", () => { answer.repeatingLabelAndInput = !answer.repeatingLabelAndInput; }); - const { getByTestId } = renderRepeatingLabelAndInput(handleUpdate, answer); + const { getByTestId } = await waitFor(() => + renderRepeatingLabelAndInput(handleUpdate, answer) + ); const toggle = getByTestId("repeat-label-and-input-toggle-input"); fireEvent.change(toggle, { target: { value: "On" } }); @@ -44,10 +48,12 @@ describe("Repeating label and input", () => { expect(handleUpdate).toHaveBeenCalled(); }); - it("should display collection lists", () => { + it("should display collection lists", async () => { answer.repeatingLabelAndInput = true; - const { getByText } = renderRepeatingLabelAndInput(handleUpdate, answer); + const { getByText } = await waitFor(() => + renderRepeatingLabelAndInput(handleUpdate, answer) + ); expect(getByText("Select a collection list")).toBeTruthy(); }); }); diff --git a/eq-author/src/components/AnswerTypeSelector/AnswerTypeButton.js b/eq-author/src/components/AnswerTypeSelector/AnswerTypeButton.js index 9f77cadfb9..dcb4a48180 100644 --- a/eq-author/src/components/AnswerTypeSelector/AnswerTypeButton.js +++ b/eq-author/src/components/AnswerTypeSelector/AnswerTypeButton.js @@ -46,6 +46,7 @@ export default class AnswerTypeButton extends React.Component { doNotShowDR: PropTypes.bool, mutuallyExclusiveEnabled: PropTypes.bool, radioEnabled: PropTypes.bool, + selectEnabled: PropTypes.bool, }; handleClick = () => { @@ -58,6 +59,7 @@ export default class AnswerTypeButton extends React.Component { doNotShowDR={this.props.doNotShowDR} mutuallyExclusiveEnabled={this.props.mutuallyExclusiveEnabled} radioEnabled={this.props.radioEnabled} + selectEnabled={this.props.selectEnabled} disabled={this.props.disabled} iconSrc={icons[this.props.type]} onClick={this.handleClick} diff --git a/eq-author/src/components/AnswerTypeSelector/AnswerTypeGrid.js b/eq-author/src/components/AnswerTypeSelector/AnswerTypeGrid.js index e52dce2104..1169985edd 100644 --- a/eq-author/src/components/AnswerTypeSelector/AnswerTypeGrid.js +++ b/eq-author/src/components/AnswerTypeSelector/AnswerTypeGrid.js @@ -73,6 +73,7 @@ class AnswerTypeGrid extends React.Component { doNotShowDR: PropTypes.bool, mutuallyExclusiveEnabled: PropTypes.bool, radioEnabled: PropTypes.bool, + selectEnabled: PropTypes.bool, }; handleSelect = (type) => { @@ -90,6 +91,7 @@ class AnswerTypeGrid extends React.Component { doNotShowDR, mutuallyExclusiveEnabled, radioEnabled, + selectEnabled, ...otherProps } = this.props; return ( @@ -111,6 +113,7 @@ class AnswerTypeGrid extends React.Component { doNotShowDR={doNotShowDR} mutuallyExclusiveEnabled={mutuallyExclusiveEnabled} radioEnabled={radioEnabled} + selectEnabled={selectEnabled} {...props} /> ); diff --git a/eq-author/src/components/AnswerTypeSelector/index.js b/eq-author/src/components/AnswerTypeSelector/index.js index 7fd65fea5b..02f463116d 100644 --- a/eq-author/src/components/AnswerTypeSelector/index.js +++ b/eq-author/src/components/AnswerTypeSelector/index.js @@ -7,7 +7,7 @@ import IconText from "components/IconText"; import Button from "components/buttons/Button"; import ValidationError from "components/ValidationError"; import { QUESTION_ANSWER_NOT_SELECTED } from "constants/validationMessages"; -import { RADIO, MUTUALLY_EXCLUSIVE } from "constants/answer-types"; +import { RADIO, MUTUALLY_EXCLUSIVE, SELECT } from "constants/answer-types"; import answersHaveAnswerType from "utils/answersHaveAnswerType"; @@ -49,7 +49,7 @@ const ErrorContext = styled.div` `} `; -const mutuallyExclusiveEnabled = (answers, hasRadioAnswer) => { +const mutuallyExclusiveEnabled = (answers, hasRadioAnswer, hasSelectAnswer) => { let allowMutuallyExclusive = false; // Mutually exclusive button will be disabled when page has no answers, page has a radio answer, or page already has mutually exclusive answer // Does not need to handle date range as "Add an answer" button is disabled when page has a date range answer @@ -57,6 +57,7 @@ const mutuallyExclusiveEnabled = (answers, hasRadioAnswer) => { answers.length === 0 || !answers || hasRadioAnswer || + hasSelectAnswer || answersHaveAnswerType(answers, MUTUALLY_EXCLUSIVE) || answers.length > 1 // TODO: (Mutually exclusive) When Runner supports multiple answers with mutually exclusive, answers.length > 1 can be removed ) { @@ -101,6 +102,7 @@ class AnswerTypeSelector extends React.Component { let hasDateRange = false; let hasOtherAnswerType = false; let hasRadioAnswer = false; + let hasSelectAnswer = false; let hasMutuallyExclusiveAnswer = false; const answers = Array.from(this.props.page.answers); @@ -118,6 +120,9 @@ class AnswerTypeSelector extends React.Component { if (answersHaveAnswerType(this.props.page.answers, RADIO)) { hasRadioAnswer = true; } + if (answersHaveAnswerType(this.props.page.answers, SELECT)) { + hasSelectAnswer = true; + } if (answersHaveAnswerType(this.props.page.answers, MUTUALLY_EXCLUSIVE)) { hasMutuallyExclusiveAnswer = true; } @@ -159,9 +164,11 @@ class AnswerTypeSelector extends React.Component { doNotShowDR={hasOtherAnswerType} mutuallyExclusiveEnabled={mutuallyExclusiveEnabled( this.props.page.answers, - hasRadioAnswer + hasRadioAnswer, + hasSelectAnswer )} radioEnabled={!hasMutuallyExclusiveAnswer} + selectEnabled={!hasMutuallyExclusiveAnswer} /> diff --git a/eq-author/src/components/AnswerTypeSelector/index.test.js b/eq-author/src/components/AnswerTypeSelector/index.test.js index 5d62efe76c..edd044917d 100644 --- a/eq-author/src/components/AnswerTypeSelector/index.test.js +++ b/eq-author/src/components/AnswerTypeSelector/index.test.js @@ -3,6 +3,7 @@ import { NUMBER, CURRENCY, RADIO, + SELECT, // MUTUALLY_EXCLUSIVE, } from "constants/answer-types"; @@ -14,7 +15,6 @@ import { } from "tests/utils/rtl"; import AnswerTypeSelector from "."; - describe("Answer Type Selector", () => { let props; beforeEach(() => { @@ -161,6 +161,17 @@ describe("Answer Type Selector", () => { ); }); + it("should disable mutually exclusive if there is a select answer", () => { + props.page.answers[0] = { type: SELECT }; + const { getByText, getByTestId } = render( + + ); + fireEvent.click(getByText(/Add another answer/)); + expect(getByTestId("btn-answer-type-mutuallyexclusive")).toHaveAttribute( + "disabled" + ); + }); + // TODO: (Mutually exclusive) When Runner supports multiple answers with mutually exclusive, the commented tests and MUTUALLY_EXCLUSIVE import can be uncommented // it("should disable radio if there is a mutually exclusive answer", () => { // props.page.answers = [{ type: NUMBER }, { type: MUTUALLY_EXCLUSIVE }]; diff --git a/eq-author/src/components/ContentPickerv3/Menu.js b/eq-author/src/components/ContentPickerv3/Menu.js index 29dd61ccd4..c11413a076 100644 --- a/eq-author/src/components/ContentPickerv3/Menu.js +++ b/eq-author/src/components/ContentPickerv3/Menu.js @@ -264,7 +264,7 @@ SubMenu.propTypes = { onSelected: PropTypes.func.isRequired, isSelected: PropTypes.func.isRequired, isDisabled: PropTypes.func, - isCalculatedSummary: PropTypes.boolean, + isCalculatedSummary: PropTypes.bool, }; const FlatSectionMenu = ({ data, isCalculatedSummary, ...otherProps }) => @@ -290,7 +290,7 @@ const FlatSectionMenu = ({ data, isCalculatedSummary, ...otherProps }) => FlatSectionMenu.propTypes = { data: PropTypes.arrayOf( PropTypes.shape({ - id: PropTypes.string.isRequired, + id: PropTypes.string, }) ), }; diff --git a/eq-author/src/components/ContentPickerv3/SupplementaryDataPicker.test.js b/eq-author/src/components/ContentPickerv3/SupplementaryDataPicker.test.js new file mode 100644 index 0000000000..3f3bcdc2f4 --- /dev/null +++ b/eq-author/src/components/ContentPickerv3/SupplementaryDataPicker.test.js @@ -0,0 +1,99 @@ +import React from "react"; +import { render, fireEvent } from "tests/utils/rtl"; +import { + ANSWER, + METADATA, + SUPPLEMENTARY_DATA, +} from "../ContentPickerSelectv3/content-types"; +import Theme from "contexts/themeContext"; + +import SupplementaryDataPicker from "./SupplementaryDataPicker"; + +describe("Supplementary Data Picker", () => { + let props; + const setContentType = jest.fn(); + + beforeEach(() => { + props = { + data: [ + { + surveyId: "221", + data: [ + { + schemaFields: [ + { + identifier: "employer_paye", + description: + "The tax office employer reference. This will be between 1 and 10 characters, which can be letters and numbers.", + type: "string", + example: "AB456", + selector: "reference", + id: "c5f64732-3bb2-40ba-8b0d-fc3b7e22c834", + }, + ], + id: "f0d7091a-44be-4c88-9d78-a807aa7509ec", + listName: "", + }, + { + schemaFields: [ + { + identifier: "local-units", + description: "Name of the local unit", + type: "string", + example: "STUBBS BUILDING PRODUCTS LTD", + selector: "name", + id: "673a30af-5197-4d2a-be0c-e5795a998491", + }, + { + identifier: "local-units", + description: "The “trading as” name for the local unit", + type: "string", + example: "STUBBS PRODUCTS", + selector: "trading_name", + id: "af2ff1a6-fc5d-419f-9538-0d052a5e6728", + }, + ], + id: "6e901afa-473a-4704-8bbd-de054569379c", + listName: "local-units", + }, + ], + sdsDateCreated: "2023-12-15T11:21:34Z", + sdsGuid: "621c954b-5523-4eda-a3eb-f18bebd20b8d", + sdsVersion: "1", + id: "b6c84aee-ea11-41e6-8be8-5715b066d297", + }, + ], + contentType: ANSWER, + contentTypes: [ANSWER, METADATA, SUPPLEMENTARY_DATA], + setContentType, + isSelected: jest.fn(), + onSelected: jest.fn(), + isSectionSelected: jest.fn(), + }; + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + const renderSupplementaryDataPicker = () => + render( + + + + ); + + it("should render the 'Answer from supplementary data' radio button", () => { + const { getByText } = renderSupplementaryDataPicker(); + expect(getByText("Answer from supplementary data")).toBeTruthy(); + }); + + it("should click the 'Answer from supplementary data' radio button", () => { + const { getByTestId } = renderSupplementaryDataPicker(); + const supplementaryDataRadio = getByTestId( + "content-type-selector-supplementaryData" + ); + fireEvent.click(supplementaryDataRadio); + expect(setContentType).toHaveBeenCalledWith(SUPPLEMENTARY_DATA); + }); +}); diff --git a/eq-author/src/components/FolderPicker/Item/index.js b/eq-author/src/components/FolderPicker/Item/index.js new file mode 100644 index 0000000000..747367c8a1 --- /dev/null +++ b/eq-author/src/components/FolderPicker/Item/index.js @@ -0,0 +1,186 @@ +import React from "react"; +import PropTypes from "prop-types"; +import styled from "styled-components"; +import { colors, focusStyle } from "constants/theme"; + +const Item = styled.div` + display: flex; + flex-direction: column; + justify-content: center; + padding-left: 1.5em; + border-bottom: 1px solid ${colors.lightGrey}; + cursor: pointer; + height: 3em; + &:hover { + background-color: ${colors.lighterGrey}; + } + + &:focus { + ${focusStyle} + } + + ${({ unselectable }) => + unselectable && + ` + cursor: default; + &:hover { + background-color: transparent; + } + + &:focus{ + outline: none; + box-shadow: none; + border-color: ${colors.lightGrey}; + } + `} + + ${({ variant }) => + variant === "heading" && + ` + height: 2em; + background-color: ${colors.lightMediumGrey}; + + cursor: default; + + &:hover { + background-color: ${colors.lightMediumGrey}; + } + `} + + ${(props) => + props["aria-selected"] && + !props.heading && + ` + background-color: ${colors.primary}; + + &:hover { + background: ${colors.mediumBlue}; + } + + ${Title} { + color: ${colors.white}; + } + + ${Subtitle} { + color: ${colors.white}; + } + `} +`; + +const ListItem = styled.li` + list-style: none; + margin: 0; + padding: 0; + + ol.sublist li *, + ul.sublist li * { + padding-left: 2.1rem; + } + + &${Item}:first-of-type .heading { + border-top: 1px solid ${colors.lightGrey}; + } +`; +const Heading = styled.h3` + font-size: 1em; + font-weight: bold; + color: ${colors.darkGrey}; + margin: 0; +`; + +const Title = styled.span` + font-size: 1em; + margin: 0; + padding: 0; + color: ${colors.black}; + display: flex; + align-items: center; + + svg { + width: 1.75em; + height: 1.75em; + fill: ${colors.darkGrey}; + margin-right: 1em; + margin-left: -0.2em; + } +`; + +const Subtitle = styled.span` + font-size: 0.9em; + margin: 0; + color: ${colors.darkGrey}; +`; + +const ContentBadge = styled.span` + font-size: 0.8em; + position: absolute; + padding: 0.3em 0.7em; + right: 1.5em; + background: ${colors.lightMediumGrey}; + border-radius: 1em; +`; + +const WrappedItem = ({ + id, + title, + subtitle, + isListCollector, + variant, + selected, + unselectable = false, + onClick, + children, +}) => { + const handleEnterUp = (key, onEnter) => { + if (key === "Enter") { + onEnter(); + } + }; + + return ( + + handleEnterUp(key, onClick)} + data-test={`folder-picker-item-${id}`} + > + {variant !== "heading" && subtitle && ( + + {subtitle} + + )} + {variant !== "heading" && ( + {title} + )} + {isListCollector && ( + + List collector + + )} + {variant === "heading" && ( + {title} + )} + + {children} + + ); +}; +WrappedItem.propTypes = { + id: PropTypes.string, + icon: PropTypes.node, + unselectable: PropTypes.bool, + title: PropTypes.string.isRequired, + subtitle: PropTypes.string, + isListCollector: PropTypes.bool, + variant: PropTypes.string, + selected: PropTypes.bool, + onClick: PropTypes.func, + children: PropTypes.node, +}; + +export default WrappedItem; diff --git a/eq-author/src/components/FolderPicker/Item/index.test.js b/eq-author/src/components/FolderPicker/Item/index.test.js new file mode 100644 index 0000000000..25cc55c6c1 --- /dev/null +++ b/eq-author/src/components/FolderPicker/Item/index.test.js @@ -0,0 +1,86 @@ +import React from "react"; +import { render, fireEvent } from "tests/utils/rtl"; + +import Item from "."; + +import { colors } from "constants/theme"; + +const renderFolderPickerItem = (props) => { + return render(); +}; + +describe("FolderPicker item", () => { + let props; + + beforeEach(() => { + props = { + id: "folder-1", + title: "Folder 1", + subtitle: "1", + }; + }); + + it("should display heading if variant is heading", () => { + const { getByTestId } = renderFolderPickerItem({ + id: "section-1", + title: "Section 1", + variant: "heading", + }); + + expect(getByTestId("folder-picker-item-section-1")).toHaveStyleRule( + "background-color", + colors.lightMediumGrey + ); + expect(getByTestId("folder-picker-heading-section-1")).toHaveTextContent( + "Section 1" + ); + }); + + it("should display title and subtitle when both are defined and variant is not heading", () => { + const { getByTestId } = renderFolderPickerItem({ + ...props, + }); + + expect(getByTestId("folder-picker-title-folder-1")).toHaveTextContent( + "Folder 1" + ); + expect(getByTestId("folder-picker-subtitle-folder-1")).toHaveTextContent( + "1" + ); + }); + + it("should display content badge for list collector folders", () => { + const { getByTestId } = renderFolderPickerItem({ + ...props, + isListCollector: true, + }); + + expect( + getByTestId("folder-picker-content-badge-folder-1") + ).toHaveTextContent("List collector"); + }); + + it("should call onClick when clicked", () => { + const onClick = jest.fn(); + const { getByTestId } = renderFolderPickerItem({ + ...props, + onClick, + }); + + fireEvent.click(getByTestId("folder-picker-item-folder-1")); + expect(onClick).toHaveBeenCalledTimes(1); + }); + + it("should call onClick when enter key is pressed", () => { + const onClick = jest.fn(); + const { getByTestId } = renderFolderPickerItem({ + ...props, + onClick, + }); + + fireEvent.keyUp(getByTestId("folder-picker-item-folder-1"), { + key: "Enter", + }); + expect(onClick).toHaveBeenCalledTimes(1); + }); +}); diff --git a/eq-author/src/components/FolderPicker/List/index.js b/eq-author/src/components/FolderPicker/List/index.js new file mode 100644 index 0000000000..babfb16aa7 --- /dev/null +++ b/eq-author/src/components/FolderPicker/List/index.js @@ -0,0 +1,23 @@ +import React from "react"; +import styled from "styled-components"; +import PropTypes from "prop-types"; + +const OrderedList = styled.ol` + padding: 0; + margin: 0; +`; + +const List = ({ children, className }) => { + return ( + + {children} + + ); +}; + +List.propTypes = { + children: PropTypes.node.isRequired, + className: PropTypes.string, +}; + +export default List; diff --git a/eq-author/src/components/FolderPicker/List/index.test.js b/eq-author/src/components/FolderPicker/List/index.test.js new file mode 100644 index 0000000000..56286f7d80 --- /dev/null +++ b/eq-author/src/components/FolderPicker/List/index.test.js @@ -0,0 +1,12 @@ +import React from "react"; +import { render } from "tests/utils/rtl"; + +import List from "."; + +describe("FolderPicker list", () => { + it("should render list", () => { + const { getByTestId } = render(Test); + + expect(getByTestId("folder-picker-list")).toBeInTheDocument(); + }); +}); diff --git a/eq-author/src/components/FolderPicker/SelectedFoldersContext/index.js b/eq-author/src/components/FolderPicker/SelectedFoldersContext/index.js new file mode 100644 index 0000000000..de81628b66 --- /dev/null +++ b/eq-author/src/components/FolderPicker/SelectedFoldersContext/index.js @@ -0,0 +1,6 @@ +import React from "react"; + +const SelectedFolders = React.createContext({}); + +export const SelectedFoldersProvider = SelectedFolders.Provider; +export default SelectedFolders; diff --git a/eq-author/src/components/FolderPicker/index.js b/eq-author/src/components/FolderPicker/index.js new file mode 100644 index 0000000000..b5f63e2347 --- /dev/null +++ b/eq-author/src/components/FolderPicker/index.js @@ -0,0 +1,242 @@ +import React, { useState, useEffect, useContext } from "react"; +import styled from "styled-components"; +import PropTypes from "prop-types"; + +import searchByFolderTitleOrShortCode from "../../utils/searchFunctions/searchByFolderTitleOrShortCode"; +import { getFolders } from "utils/questionnaireUtils"; + +import { colors } from "constants/theme"; + +import SelectedFolderContext, { + SelectedFoldersProvider, +} from "./SelectedFoldersContext"; + +import { ReactComponent as WarningIcon } from "assets/icon-warning-round.svg"; + +import Modal from "components/modals/Modal"; +import SearchBar from "components/SearchBar"; +import IconText from "components/IconText"; +import Button from "components/buttons/Button"; +import ButtonGroup from "components/buttons/ButtonGroup"; +import ScrollPane from "components/ScrollPane"; +import NoSearchResults from "components/NoSearchResults"; + +import Item from "./Item"; +import List from "./List"; + +const StyledModal = styled(Modal)` + .Modal { + padding: 0; + padding-top: 1em; + width: 45em; + } +`; + +const Header = styled.header` + margin: 0 1.5em; +`; + +const Main = styled.main` + overflow: hidden; + height: 25em; +`; + +const Footer = styled.footer` + padding: 1.5em; +`; + +const Title = styled.h2` + font-weight: bold; + font-size: 1.2em; + color: ${colors.darkGrey}; + margin-bottom: 0.75em; +`; + +const WarningPanel = styled(IconText)` + font-weight: bold; + font-size: 1.1em; + margin-bottom: 0.8em; + + svg { + width: 3em; + height: 3em; + margin-right: 0.5em; + } +`; + +const SearchBarWrapper = styled.div` + margin-bottom: 1.5em; +`; + +const isSelected = (items, target) => items.find(({ id }) => id === target.id); + +const Folder = ({ folder }) => { + const { id, title, alias, displayName, listId } = folder; + const { selectedFolders, updateSelectedFolders } = useContext( + SelectedFolderContext + ); + + const itemSelected = isSelected(selectedFolders, folder); + + const handleClick = () => { + if (itemSelected) { + const selectionWithoutThisFolder = selectedFolders.filter( + (selectedFolder) => selectedFolder.id !== folder.id + ); + updateSelectedFolders(selectionWithoutThisFolder); + } else { + updateSelectedFolders([...selectedFolders, folder]); + } + }; + + return ( + + ); +}; + +Folder.propTypes = { + folder: PropTypes.object, // eslint-disable-line +}; + +const Section = ({ section }) => { + const { id, displayName, folders } = section; + + const numOfFoldersInSection = getFolders({ sections: [section] }).length; + + if (numOfFoldersInSection > 0) { + return ( + + + {folders.map((folder) => { + return ; + })} + + + ); + } + + // Handles sections with no folders matching the search term + return ; +}; + +Section.propTypes = { + section: PropTypes.object, // eslint-disable-line +}; + +const FolderPicker = ({ + title, + sections, + showSearch, + isOpen, + warningMessage, + onClose, + onCancel, + onSubmit, + startingSelectedFolders = [], +}) => { + const [searchTerm, setSearchTerm] = useState(""); + const [filteredSections, updateFilteredSections] = useState([]); + const [selectedFolders, updateSelectedFolders] = useState([]); + + useEffect(() => { + updateFilteredSections( + searchByFolderTitleOrShortCode(sections, searchTerm) + ); + }, [sections, searchTerm]); + + useEffect(() => { + updateSelectedFolders(startingSelectedFolders); + }, [startingSelectedFolders]); + + const handleSubmit = (selection) => { + onSubmit(selection); + }; + + return ( + +
+ {title} + {warningMessage && ( + + {warningMessage} + + )} + {showSearch && ( + + setSearchTerm(value)} + placeholder="Search folders" + /> + + )} +
+
+ {searchTerm === "" || + (searchTerm !== "" && + // Checks if there are any sections with folders matching the search term + searchByFolderTitleOrShortCode(sections, searchTerm).some( + (section) => section.folders.length > 0 + )) ? ( + + + + {filteredSections.map((section) => ( +
+ ))} + + + + ) : ( + + )} +
+
+ + + + +
+
+ ); +}; + +FolderPicker.propTypes = { + title: PropTypes.string.isRequired, + sections: PropTypes.array.isRequired, // eslint-disable-line + startingSelectedFolders: PropTypes.array, // eslint-disable-line + showSearch: PropTypes.bool, + warningMessage: PropTypes.string, + isOpen: PropTypes.bool, + onClose: PropTypes.func.isRequired, + onCancel: PropTypes.func.isRequired, + onSubmit: PropTypes.func.isRequired, +}; + +export default FolderPicker; diff --git a/eq-author/src/components/FolderPicker/index.test.js b/eq-author/src/components/FolderPicker/index.test.js new file mode 100644 index 0000000000..12094d262d --- /dev/null +++ b/eq-author/src/components/FolderPicker/index.test.js @@ -0,0 +1,131 @@ +import React from "react"; +import { render, fireEvent } from "tests/utils/rtl"; + +import FolderPicker from "./"; + +const sections = [ + { + id: "section-1", + title: "Section 1", + displayName: "Section 1", + folders: [ + { + id: "folder-1", + alias: "Folder 1", + }, + { + id: "folder-2", + alias: "Folder 2", + }, + ], + }, + { + id: "section-2", + title: "Section 2", + displayName: "Section 2", + folders: [ + { + id: "list-collector-1", + title: "List collector 1", + listId: "list-1", + }, + { + id: "list-collector-2", + title: "List collector 2", + alias: "List 2", + listId: "list-2", + }, + { + id: "untitled-folder", + displayName: "Untitled folder", + }, + ], + }, +]; + +describe("FolderPicker", () => { + const defaultProps = { + title: "Select the folder(s) to import", + isOpen: true, + showSearch: true, + onSubmit: jest.fn(), + onClose: jest.fn(), + onCancel: jest.fn(), + startingSelectedFolders: [], + sections, + }; + + const renderFolderPicker = (args) => { + return render(); + }; + + it("should render", () => { + const { getByTestId } = renderFolderPicker(); + + expect(getByTestId("folder-picker-header")).toBeInTheDocument(); + }); + + it("should call onSubmit when select button is clicked", () => { + const { getByTestId, getByText } = renderFolderPicker(); + + fireEvent.click(getByText(/Folder 1/)); + + const selectButton = getByTestId("folder-picker-button-select"); + fireEvent.click(selectButton); + + expect(defaultProps.onSubmit).toHaveBeenCalledTimes(1); + }); + + it("should call onCancel when cancel button is clicked", () => { + const { getByTestId } = renderFolderPicker(); + + const cancelButton = getByTestId("folder-picker-button-cancel"); + fireEvent.click(cancelButton); + + expect(defaultProps.onCancel).toHaveBeenCalledTimes(1); + }); + + it("should render warning message", () => { + const { getByText } = renderFolderPicker({ + warningMessage: "Test message", + }); + + expect(getByText("Test message")).toBeInTheDocument(); + }); + + it("should update folder selected when clicked", () => { + const { getByTestId } = renderFolderPicker(); + + const folderItem = getByTestId("folder-picker-item-folder-1"); + fireEvent.click(folderItem); + + expect(folderItem.getAttribute("aria-selected")).toBe("true"); + + fireEvent.click(folderItem); + expect(folderItem.getAttribute("aria-selected")).toBe("false"); + }); + + describe("Search bar", () => { + it("should filter folders by search term", () => { + const { getByTestId, queryByText } = renderFolderPicker(); + + const searchBar = getByTestId("search-bar"); + fireEvent.change(searchBar, { target: { value: "Folder 1" } }); + + expect(queryByText("Folder 1")).toBeInTheDocument(); + expect(queryByText("Folder 2")).not.toBeInTheDocument(); + }); + + it("should display no results message when no folders match search term", () => { + const { getByTestId, getByText } = renderFolderPicker(); + + const searchBar = getByTestId("search-bar"); + fireEvent.change(searchBar, { target: { value: "Undefined folder" } }); + + expect( + getByText(/No results found for 'Undefined folder'/) + ).toBeInTheDocument(); + expect(getByText(/Please check the folder exists/)).toBeInTheDocument(); + }); + }); +}); diff --git a/eq-author/src/components/Forms/WrappingInput/index.js b/eq-author/src/components/Forms/WrappingInput/index.js index 0efd856966..ec85b679bf 100644 --- a/eq-author/src/components/Forms/WrappingInput/index.js +++ b/eq-author/src/components/Forms/WrappingInput/index.js @@ -27,7 +27,7 @@ const TextArea = styled(AutoResizeTextArea)` class WrappingInput extends React.Component { static propTypes = { - value: PropTypes.string.isRequired, + value: PropTypes.string, onChange: PropTypes.func.isRequired, onBlur: PropTypes.func.isRequired, onPaste: PropTypes.func, diff --git a/eq-author/src/components/IconGrid/IconGridButton.js b/eq-author/src/components/IconGrid/IconGridButton.js index 4229b47f1d..4975e503cb 100644 --- a/eq-author/src/components/IconGrid/IconGridButton.js +++ b/eq-author/src/components/IconGrid/IconGridButton.js @@ -52,12 +52,14 @@ const IconGridButton = ({ doNotShowDR, mutuallyExclusiveEnabled, radioEnabled, + selectEnabled, ...otherProps }) => { if ( (doNotShowDR && title === "Date range") || (!mutuallyExclusiveEnabled && title === "OR answer") || - (!radioEnabled && title === "Radio") + (!radioEnabled && title === "Radio") || + (!selectEnabled && title === "Select") ) { disabled = true; } @@ -87,6 +89,7 @@ IconGridButton.propTypes = { doNotShowDR: PropTypes.bool, mutuallyExclusiveEnabled: PropTypes.bool, radioEnabled: PropTypes.bool, + selectEnabled: PropTypes.bool, }; export default IconGridButton; diff --git a/eq-author/src/components/RichTextEditor/PipingMenu.js b/eq-author/src/components/RichTextEditor/PipingMenu.js index 189cc4d0e4..5d23e27fe2 100644 --- a/eq-author/src/components/RichTextEditor/PipingMenu.js +++ b/eq-author/src/components/RichTextEditor/PipingMenu.js @@ -126,19 +126,13 @@ const PipingMenu = ({ let allSupplementaryData = questionnaire?.supplementaryData?.data || []; - if ( - allSupplementaryData && - !( - pageType === "Introduction" && - questionnaire?.sections[0]?.repeatingSection - ) - ) { + if (allSupplementaryData && pageType !== "Introduction") { allSupplementaryData = allSupplementaryData.filter( (list) => list.listName === "" || list.id === listId ); } - const supplementaryData = allSupplementaryData.flatMap((list) => { + let supplementaryData = allSupplementaryData.flatMap((list) => { return list.schemaFields.map((schemaField) => { return { listName: list.listName, @@ -147,6 +141,10 @@ const PipingMenu = ({ }); }); + supplementaryData = supplementaryData.filter( + (list) => list.listName === "" || list.type !== "array" + ); + const handlePickerContent = (contentType) => { switch (contentType) { case METADATA: diff --git a/eq-author/src/components/RichTextEditor/PipingMenu.test.js b/eq-author/src/components/RichTextEditor/PipingMenu.test.js index f06d0d5697..30d3691c4e 100644 --- a/eq-author/src/components/RichTextEditor/PipingMenu.test.js +++ b/eq-author/src/components/RichTextEditor/PipingMenu.test.js @@ -8,6 +8,7 @@ import PipingMenu, { import { ANSWER, METADATA, + SUPPLEMENTARY_DATA, VARIABLES, } from "components/ContentPickerSelectv3/content-types"; @@ -34,12 +35,60 @@ const mockMetadata = [ }, ]; +const mockSupplementaryData = { + surveyId: "221", + data: [ + { + schemaFields: [ + { + identifier: "employer_paye", + description: + "The tax office employer reference. This will be between 1 and 10 characters, which can be letters and numbers.", + type: "string", + example: "AB456", + selector: "reference", + id: "c5f64732-3bb2-40ba-8b0d-fc3b7e22c834", + }, + ], + id: "f0d7091a-44be-4c88-9d78-a807aa7509ec", + listName: "", + }, + { + schemaFields: [ + { + identifier: "local-units", + description: "Name of the local unit", + type: "string", + example: "STUBBS BUILDING PRODUCTS LTD", + selector: "name", + id: "673a30af-5197-4d2a-be0c-e5795a998491", + }, + { + identifier: "local-units", + description: "The “trading as” name for the local unit", + type: "string", + example: "STUBBS PRODUCTS", + selector: "trading_name", + id: "af2ff1a6-fc5d-419f-9538-0d052a5e6728", + }, + ], + id: "6e901afa-473a-4704-8bbd-de054569379c", + listName: "local-units", + }, + ], + sdsDateCreated: "2023-12-15T11:21:34Z", + sdsGuid: "621c954b-5523-4eda-a3eb-f18bebd20b8d", + sdsVersion: "1", + id: "b6c84aee-ea11-41e6-8be8-5715b066d297", +}; + const mockQuestionnaire = buildQuestionnaire({ sectionCount: 1, folderCount: 1, pageCount: 2, }); mockQuestionnaire.metadata = mockMetadata; +mockQuestionnaire.supplementaryData = mockSupplementaryData; mockQuestionnaire.sections[0].folders[0].pages[0].answers = [ { id: "answer-1", @@ -63,7 +112,7 @@ describe("PipingMenu", () => { return shallow( @@ -101,11 +150,12 @@ describe("PipingMenu", () => { expect(wrapper.find(PIPING_BUTTON_VALUE).prop("disabled")).toBe(true); }); - it("should render as disabled when there is no answerData and metadataData", () => { + it("should render as disabled when there is no answerData, metadataData, and supplementaryData", () => { const wrapper = render({ questionnaire: { ...mockQuestionnaire, metadata: [], + supplementaryData: [], }, currentPageId: "1.1.1", }); diff --git a/eq-author/src/components/RichTextEditor/index.js b/eq-author/src/components/RichTextEditor/index.js index 47e16b64db..ff62438798 100644 --- a/eq-author/src/components/RichTextEditor/index.js +++ b/eq-author/src/components/RichTextEditor/index.js @@ -3,6 +3,7 @@ import PropTypes from "prop-types"; import styled, { css } from "styled-components"; import Editor from "draft-js-plugins-editor"; import { EditorState, RichUtils, Modifier, CompositeDecorator } from "draft-js"; +import { stateFromHTML } from "draft-js-import-html"; import "draft-js/dist/Draft.css"; import createBlockBreakoutPlugin from "draft-js-block-breakout-plugin"; @@ -27,14 +28,14 @@ import { sharedStyles } from "components/Forms/css"; import { Field, Label } from "components/Forms"; import ValidationError from "components/ValidationError"; -import PasteModal, { - preserveRichFormatting, -} from "components/modals/PasteModal"; +import PasteModal from "components/modals/PasteModal"; + +import { colors } from "../../constants/theme"; const styleMap = (controls) => { return { BOLD: { - backgroundColor: controls.highlight && "#cbe2c8", + backgroundColor: controls.highlight && colors.neonYellow, fontWeight: controls.bold && "bold", }, }; @@ -393,12 +394,12 @@ class RichTextEditor extends React.Component { state = { showPasteModal: false, text: "", multiline: false }; - handlePaste = (text) => { + handlePaste = (text, html) => { if (/\s{2,}/g.test(text)) { this.setState({ showPasteModal: true, multiline: false, - text: text, + text: html || text, }); } else { this.handleChange( @@ -416,12 +417,12 @@ class RichTextEditor extends React.Component { return "handled"; }; - handlePasteMultiline = (text) => { + handlePasteMultiline = (text, html) => { if (/\s{2,}/g.test(text)) { this.setState({ showPasteModal: true, multiline: true, - text: text, + text: html || text, }); return "handled"; } else { @@ -434,27 +435,76 @@ class RichTextEditor extends React.Component { const currentContent = editorState.getCurrentContent(); const currentSelection = editorState.getSelection(); - let modifiedText; + let newEditorState; + let processedText = text; + + // Simple HTML sanitization function + const sanitizeHtml = (html) => { + const doc = new DOMParser().parseFromString(html, "text/html"); + return doc.body.innerHTML; + }; + + // Sanitize the input HTML + const sanitizedHtml = sanitizeHtml(processedText); if (multiline) { - modifiedText = preserveRichFormatting(text); - } else { - modifiedText = text.replace(/\n/g, " ").trim().replace(/\s+/g, " "); - } + // Process the text to remove multiple spaces + const div = document.createElement("div"); + div.innerHTML = sanitizedHtml; + const walker = document.createTreeWalker( + div, + NodeFilter.SHOW_TEXT, + null, + false + ); + while (walker.nextNode()) { + walker.currentNode.nodeValue = walker.currentNode.nodeValue.replace( + /\s+/g, + " " + ); + } + processedText = div.innerHTML; - // Replace the selected text with the pasted content - const newContentState = Modifier.replaceText( - currentContent, - currentSelection, - modifiedText - ); + // Convert processed text from HTML to ContentState + const contentState = stateFromHTML(processedText); + const fragment = contentState.getBlockMap(); - // Create a new EditorState with the updated content - const newEditorState = EditorState.push( - editorState, - newContentState, - "insert-characters" - ); + // Replace the selected text with the pasted content + const newContentState = Modifier.replaceWithFragment( + currentContent, + currentSelection, + fragment + ); + + // Create a new EditorState with the updated content + newEditorState = EditorState.push( + editorState, + newContentState, + "insert-characters" + ); + } else { + // For single line pastes, replace multiple spaces with a single space + processedText = processedText + .replace(/\n/g, " ") + .replace(/\s+/g, " ") + .trim(); + const contentState = stateFromHTML(processedText); + const fragment = contentState.getBlockMap(); + + // Replace the selected text with the pasted content + const newContentState = Modifier.replaceWithFragment( + currentContent, + currentSelection, + fragment + ); + + // Create a new EditorState with the updated content + newEditorState = EditorState.push( + editorState, + newContentState, + "insert-characters" + ); + } // Set the new editor state and close the paste modal this.setState({ diff --git a/eq-author/src/components/modals/ImportContentModal/index.js b/eq-author/src/components/modals/ImportContentModal/index.js index 4cda609df9..e2aac3ce37 100644 --- a/eq-author/src/components/modals/ImportContentModal/index.js +++ b/eq-author/src/components/modals/ImportContentModal/index.js @@ -37,6 +37,7 @@ const ImportQuestionReviewModal = ({ onCancel, onBack, onSelectQuestions, + onSelectFolders, onSelectSections, }) => ( - Question logic, piping and Qcodes will not be imported. Any extra + Question logic, piping and Q codes will not be imported. Any extra spaces in lines of text will be removed. @@ -60,23 +61,28 @@ const ImportQuestionReviewModal = ({ - *Select individual questions or entire sections to be imported, you - cannot choose both* + Select sections, folders or questions to import - + + @@ -87,6 +93,7 @@ ImportQuestionReviewModal.propTypes = { onCancel: PropTypes.func.isRequired, onBack: PropTypes.func.isRequired, onSelectQuestions: PropTypes.func.isRequired, + onSelectFolders: PropTypes.func.isRequired, onSelectSections: PropTypes.func.isRequired, questionnaire: PropTypes.shape({ title: PropTypes.string.isRequired, diff --git a/eq-author/src/components/modals/ImportContentModal/index.test.js b/eq-author/src/components/modals/ImportContentModal/index.test.js index a9d6d35f2a..4d22725816 100644 --- a/eq-author/src/components/modals/ImportContentModal/index.test.js +++ b/eq-author/src/components/modals/ImportContentModal/index.test.js @@ -9,6 +9,7 @@ const mockQuestionnaire = { }; const mockOnSelectQuestions = jest.fn(); +const mockOnSelectFolders = jest.fn(); const mockOnSelectSections = jest.fn(); const mockOnRemoveSingle = jest.fn(); const mockOnRemoveAll = jest.fn(); @@ -21,6 +22,7 @@ const setup = (props) => questionnaire={mockQuestionnaire} isOpen onSelectQuestions={mockOnSelectQuestions} + onSelectFolders={mockOnSelectFolders} onSelectSections={mockOnSelectSections} startingSelectedQuestions={[]} onConfirm={jest.fn()} diff --git a/eq-author/src/components/modals/ImportFolderReviewModal/index.js b/eq-author/src/components/modals/ImportFolderReviewModal/index.js new file mode 100644 index 0000000000..ece9bebea8 --- /dev/null +++ b/eq-author/src/components/modals/ImportFolderReviewModal/index.js @@ -0,0 +1,212 @@ +import React from "react"; +import PropTypes from "prop-types"; +import styled from "styled-components"; +import { colors, radius, focusStyle, getTextHoverStyle } from "constants/theme"; + +import Wizard, { + Header, + Heading, + Content, + Warning, + SpacedRow, +} from "components/modals/Wizard"; +import Button from "components/buttons/Button"; + +const FoldersPane = styled.div` + max-height: 17em; + overflow: hidden; + overflow-y: scroll; + margin-bottom: 0.5em; +`; + +const FolderContainer = styled.div` + background-color: ${colors.blue}; + border-radius: ${radius}; + margin: 0 0 0.5em; + color: ${colors.white}; + padding: 0.5em 1em; + p { + margin: 0; + } +`; + +const commonButtonStyling = ` + background: transparent; + border: none; + cursor: pointer; + &:focus { + ${focusStyle} + } +`; + +const RemoveButton = styled.button` + ${commonButtonStyling} + color: ${colors.white}; + ${getTextHoverStyle(colors.white)} +`; + +const RemoveAllButton = styled.button` + ${commonButtonStyling} + font-weight: bold; + color: ${colors.blue}; + font-size: 1em; + ${getTextHoverStyle(colors.blue)} +`; + +const ContentHeading = styled.h4` + margin: 1em 0; + color: ${colors.textLight}; +`; + +const Container = styled.div` + display: flex; + gap: 0.5em; +`; + +const WarningWrapper = styled.div` + .warning-icon { + margin-top: -1.1em; + } + .warning-flex-container { + width: 40em; + } +`; + +const Subheading = styled.h3` + margin: 0 0.5em 0 0; + display: block; + font-weight: bold; + font-size: 1em; +`; + +const FolderRow = ({ folder: { id, alias, title, displayName }, onRemove }) => ( + + +
+

{title && alias}

+

{title || displayName}

+
+ + + ✕ + + +
+
+); + +FolderRow.propTypes = { + folder: PropTypes.shape({ + alias: PropTypes.string, + title: PropTypes.string, + displayName: PropTypes.string, + }), + onRemove: PropTypes.func.isRequired, +}; + +const ImportFolderReviewModal = ({ + questionnaire, + startingSelectedFolders, + isOpen, + onConfirm, + onCancel, + onBack, + onSelectQuestions, + onSelectFolders, + onSelectSections, + onRemoveSingle, + onRemoveAll, +}) => ( + onConfirm(startingSelectedFolders)} + onCancel={onCancel} + onBack={onBack} + confirmEnabled={Boolean(startingSelectedFolders?.length) || false} + > +
+ Import content from {questionnaire.title} + + + + Question logic, piping and Q codes will not be imported. Any extra + spaces in lines of text will be removed. + + + +
+ + {startingSelectedFolders?.length ? ( + <> + + + Folder{startingSelectedFolders.length > 1 && "s"} to import + + Remove all + + + {startingSelectedFolders.map((folder, index) => ( + onRemoveSingle(index)} + /> + ))} + + + ) : ( + + Select sections, folders or questions to import + + )} + + {startingSelectedFolders?.length === 0 && ( + + )} + + {startingSelectedFolders?.length === 0 && ( + + )} + + +
+); + +ImportFolderReviewModal.propTypes = { + isOpen: PropTypes.bool.isRequired, + onConfirm: PropTypes.func.isRequired, + onCancel: PropTypes.func.isRequired, + onBack: PropTypes.func.isRequired, + onSelectQuestions: PropTypes.func.isRequired, + onSelectFolders: PropTypes.func.isRequired, + onSelectSections: PropTypes.func.isRequired, + onRemoveSingle: PropTypes.func.isRequired, + onRemoveAll: PropTypes.func.isRequired, + questionnaire: PropTypes.shape({ + title: PropTypes.string.isRequired, + }), + startingSelectedFolders: PropTypes.array, // eslint-disable-line +}; + +export default ImportFolderReviewModal; diff --git a/eq-author/src/components/modals/ImportFolderReviewModal/index.test.js b/eq-author/src/components/modals/ImportFolderReviewModal/index.test.js new file mode 100644 index 0000000000..193f2e5ffb --- /dev/null +++ b/eq-author/src/components/modals/ImportFolderReviewModal/index.test.js @@ -0,0 +1,95 @@ +import React from "react"; +import { render, fireEvent } from "tests/utils/rtl"; + +import ImportFolderReviewModal from "."; + +const mockQuestionnaire = { + title: "Import folders", +}; + +describe("Import folder review modal", () => { + const defaultProps = { + questionnaire: mockQuestionnaire, + isOpen: true, + startingSelectedFolders: [], + onSelectSections: jest.fn(), + onSelectFolders: jest.fn(), + onSelectQuestions: jest.fn(), + onConfirm: jest.fn(), + onCancel: jest.fn(), + onBack: jest.fn(), + onRemoveAll: jest.fn(), + onRemoveSingle: jest.fn(), + }; + + const renderImportFolderReviewModal = (args) => { + return render(); + }; + + it("should render", () => { + const { getByText } = renderImportFolderReviewModal(); + + expect( + getByText(/Select sections, folders or questions to import/) + ).toBeTruthy(); + }); + + it("should call onSelectFolders when the folders button is clicked", () => { + const { getByTestId } = renderImportFolderReviewModal(); + + expect(defaultProps.onSelectFolders).not.toHaveBeenCalled(); + + fireEvent.click(getByTestId("folder-review-select-folders-button")); + + expect(defaultProps.onSelectFolders).toHaveBeenCalledTimes(1); + }); + + it("should render selected folders", () => { + const startingSelectedFolders = [ + { id: "folder-1", displayName: "Folder 1 display name" }, + { id: "folder-2", title: "Folder 2 title", alias: "Folder 2 alias" }, + ]; + const { getByText } = renderImportFolderReviewModal({ + startingSelectedFolders, + }); + + expect(getByText(/Folder 1 display name/)).toBeInTheDocument(); + expect(getByText(/Folder 2 title/)).toBeInTheDocument(); + expect(getByText(/Folder 2 alias/)).toBeInTheDocument(); + }); + + it("should call onConfirm when import button is clicked", () => { + const startingSelectedFolders = [ + { id: "folder-1", displayName: "Folder 1" }, + ]; + const { getByText } = renderImportFolderReviewModal({ + startingSelectedFolders, + }); + + const importButton = getByText(/^Import$/); // Matches exact "Import" text - gets import button + + expect(defaultProps.onConfirm).not.toHaveBeenCalled(); + + fireEvent.click(importButton); + + expect(defaultProps.onConfirm).toHaveBeenCalledTimes(1); + }); + + it("should call onRemoveSingle when folder item remove button is clicked", () => { + const startingSelectedFolders = [ + { id: "folder-1", displayName: "Folder 1" }, + { id: "folder-2", displayName: "Folder 2" }, + ]; + + const { getByTestId } = renderImportFolderReviewModal({ + startingSelectedFolders, + }); + + expect(defaultProps.onRemoveSingle).not.toHaveBeenCalled(); + + fireEvent.click(getByTestId("folder-review-item-remove-button-folder-1")); + + expect(defaultProps.onRemoveSingle).toHaveBeenCalledWith(0); + expect(defaultProps.onRemoveSingle).toHaveBeenCalledTimes(1); + }); +}); diff --git a/eq-author/src/components/modals/ImportQuestionReviewModal/index.js b/eq-author/src/components/modals/ImportQuestionReviewModal/index.js index 0c770087bc..f438b1dd77 100644 --- a/eq-author/src/components/modals/ImportQuestionReviewModal/index.js +++ b/eq-author/src/components/modals/ImportQuestionReviewModal/index.js @@ -106,6 +106,7 @@ const ImportQuestionReviewModal = ({ onCancel, onBack, onSelectQuestions, + onSelectFolders, onSelectSections, onRemoveSingle, onRemoveAll, @@ -123,7 +124,7 @@ const ImportQuestionReviewModal = ({ - Question logic, piping and Qcodes will not be imported. Any extra + Question logic, piping and Q codes will not be imported. Any extra spaces in lines of text will be removed. @@ -151,19 +152,10 @@ const ImportQuestionReviewModal = ({ ) : ( - *Select individual questions or entire sections to be imported, you - cannot choose both* + Select sections, folders or questions to import )} - {startingSelectedQuestions?.length === 0 && ( + )} + @@ -182,8 +190,9 @@ ImportQuestionReviewModal.propTypes = { onConfirm: PropTypes.func.isRequired, onCancel: PropTypes.func.isRequired, onBack: PropTypes.func.isRequired, - onSelectQuestions: PropTypes.func.isRequired, onSelectSections: PropTypes.func.isRequired, + onSelectFolders: PropTypes.func.isRequired, + onSelectQuestions: PropTypes.func.isRequired, onRemoveSingle: PropTypes.func.isRequired, onRemoveAll: PropTypes.func.isRequired, questionnaire: PropTypes.shape({ diff --git a/eq-author/src/components/modals/ImportQuestionReviewModal/index.test.js b/eq-author/src/components/modals/ImportQuestionReviewModal/index.test.js index 8d7c95e0ea..07a3b7db43 100644 --- a/eq-author/src/components/modals/ImportQuestionReviewModal/index.test.js +++ b/eq-author/src/components/modals/ImportQuestionReviewModal/index.test.js @@ -16,6 +16,7 @@ const mockQuestions = [ const mockOnSelectQuestions = jest.fn(); const mockOnRemoveSingle = jest.fn(); const mockOnRemoveAll = jest.fn(); +const mockOnSelectFolders = jest.fn(); const mockOnSelectSections = jest.fn(); describe("Import questions review modal", () => { @@ -25,6 +26,7 @@ describe("Import questions review modal", () => { questionnaire={mockQuestionnaire} isOpen onSelectQuestions={mockOnSelectQuestions} + onSelectFolders={mockOnSelectFolders} onSelectSections={mockOnSelectSections} startingSelectedQuestions={[]} onConfirm={jest.fn()} @@ -36,9 +38,7 @@ describe("Import questions review modal", () => { ); expect( - screen.queryByText( - /Select individual questions or entire sections to be imported, you cannot choose both/ - ) + screen.queryByText(/Select sections, folders or questions to import/) ).toBeTruthy(); // Import button should be disabled when no questions selected expect(screen.getByText(/^Import$/)).toBeDisabled(); @@ -57,6 +57,7 @@ describe("Import questions review modal", () => { isOpen startingSelectedQuestions={mockQuestions} onSelectQuestions={mockOnSelectQuestions} + onSelectFolders={mockOnSelectFolders} onSelectSections={mockOnSelectSections} onConfirm={jest.fn()} onCancel={jest.fn()} @@ -76,6 +77,7 @@ describe("Import questions review modal", () => { isOpen startingSelectedQuestions={mockQuestions} onSelectQuestions={mockOnSelectQuestions} + onSelectFolders={mockOnSelectFolders} onSelectSections={mockOnSelectSections} onConfirm={jest.fn()} onCancel={jest.fn()} @@ -97,6 +99,7 @@ describe("Import questions review modal", () => { isOpen startingSelectedQuestions={mockQuestions} onSelectSections={mockOnSelectSections} + onSelectFolders={mockOnSelectFolders} onSelectQuestions={mockOnSelectQuestions} onConfirm={jest.fn()} onCancel={jest.fn()} @@ -118,6 +121,7 @@ describe("Import questions review modal", () => { questionnaire={mockQuestionnaire} isOpen onSelectSections={mockOnSelectSections} + onSelectFolders={mockOnSelectFolders} onSelectQuestions={mockOnSelectQuestions} startingSelectedQuestions={mockQuestions} onConfirm={mockHandleConfirm} @@ -141,6 +145,7 @@ describe("Import questions review modal", () => { questionnaire={mockQuestionnaire} isOpen onSelectSections={mockOnSelectSections} + onSelectFolders={mockOnSelectFolders} onSelectQuestions={mockOnSelectQuestions} startingSelectedQuestions={[]} onConfirm={jest.fn()} @@ -162,6 +167,7 @@ describe("Import questions review modal", () => { questionnaire={mockQuestionnaire} isOpen onSelectSections={mockOnSelectSections} + onSelectFolders={mockOnSelectFolders} onSelectQuestions={mockOnSelectQuestions} startingSelectedQuestions={mockQuestions} onConfirm={jest.fn()} @@ -183,6 +189,7 @@ describe("Import questions review modal", () => { questionnaire={mockQuestionnaire} isOpen onSelectSections={mockOnSelectSections} + onSelectFolders={mockOnSelectFolders} onSelectQuestions={mockOnSelectQuestions} startingSelectedQuestions={[]} onConfirm={jest.fn()} diff --git a/eq-author/src/components/modals/ImportSectionReviewModal/index.js b/eq-author/src/components/modals/ImportSectionReviewModal/index.js index 9dd7f5ba01..695f93bba8 100644 --- a/eq-author/src/components/modals/ImportSectionReviewModal/index.js +++ b/eq-author/src/components/modals/ImportSectionReviewModal/index.js @@ -107,6 +107,7 @@ const ImportSectionReviewModal = ({ onCancel, onBack, onSelectQuestions, + onSelectFolders, onSelectSections, onRemoveSingle, onRemoveAll, @@ -124,7 +125,7 @@ const ImportSectionReviewModal = ({ - Question logic, piping and Qcodes will not be imported. Any extra + Question logic, piping and Q codes will not be imported. Any extra spaces in lines of text will be removed. @@ -151,19 +152,10 @@ const ImportSectionReviewModal = ({ ) : ( - *Select individual questions or entire sections to be imported, you - cannot choose both* + Select sections, folders or questions to import )} - {startingSelectedSections?.length === 0 && ( - - )} + )} + {startingSelectedSections?.length === 0 && ( + + )} @@ -182,8 +190,9 @@ ImportSectionReviewModal.propTypes = { onConfirm: PropTypes.func.isRequired, onCancel: PropTypes.func.isRequired, onBack: PropTypes.func.isRequired, - onSelectQuestions: PropTypes.func.isRequired, onSelectSections: PropTypes.func.isRequired, + onSelectFolders: PropTypes.func.isRequired, + onSelectQuestions: PropTypes.func.isRequired, onRemoveSingle: PropTypes.func.isRequired, onRemoveAll: PropTypes.func.isRequired, questionnaire: PropTypes.shape({ diff --git a/eq-author/src/components/modals/ImportSectionReviewModal/index.test.js b/eq-author/src/components/modals/ImportSectionReviewModal/index.test.js index fb6dea9fcc..5645443a2e 100644 --- a/eq-author/src/components/modals/ImportSectionReviewModal/index.test.js +++ b/eq-author/src/components/modals/ImportSectionReviewModal/index.test.js @@ -1,7 +1,7 @@ import React from "react"; import { render, screen } from "tests/utils/rtl"; import userEvent from "@testing-library/user-event"; -import ImportQuestionReviewModal from "."; +import ImportSectionReviewModal from "."; import mockSections from "../../../tests/mocks/mockSections.json"; const mockQuestionnaire = { @@ -11,15 +11,17 @@ const mockQuestionnaire = { const mockOnSelectSections = jest.fn(); const mockOnRemoveSingle = jest.fn(); const mockOnRemoveAll = jest.fn(); +const mockOnSelectFolders = jest.fn(); const mockOnSelectQuestions = jest.fn(); describe("Import sections review modal", () => { it("Should call onSelectSections when the sections button is clicked", () => { render( - { ); expect( - screen.queryByText( - /Select individual questions or entire sections to be imported, you cannot choose both/ - ) + screen.queryByText(/Select sections, folders or questions to import/) ).toBeTruthy(); // Import button should be disabled when no questions selected expect(screen.getByText(/^Import$/)).toBeDisabled(); @@ -49,11 +49,12 @@ describe("Import sections review modal", () => { it("Should display the selected sections when there are some", () => { render( - { it("Should call onRemoveSingle with the index of the item that is signaled to be removed", () => { render( - { it("Should delete all items", () => { render( - { const mockHandleConfirm = jest.fn(); render( - { const mockHandleConfirm = jest.fn(); render( - { const mockHandleConfirm = jest.fn(); render( - { const mockHandleConfirm = jest.fn(); render( - { - // Replace multiple spaces and tabs with a single space - let formattedText = text.replace(/[ \t]+/g, " "); - - // Split the text into lines - let lines = formattedText.split(/\r?\n/); - - // Remove leading and trailing spaces from each line and join them back with newline characters - formattedText = lines.map((line) => line.trim()).join("\n"); - - return formattedText; -}; - const ModalWrapper = styled.div` .modal-button-container { margin-top: 1em; diff --git a/eq-author/src/components/modals/PasteModal/index.test.js b/eq-author/src/components/modals/PasteModal/index.test.js index 11f7631f15..1f3b70e8b5 100644 --- a/eq-author/src/components/modals/PasteModal/index.test.js +++ b/eq-author/src/components/modals/PasteModal/index.test.js @@ -1,27 +1,11 @@ import React from "react"; import { render, fireEvent } from "tests/utils/rtl"; -import PasteModal, { preserveRichFormatting } from "."; +import PasteModal from "."; import { keyCodes } from "constants/keyCodes"; const { Escape } = keyCodes; -describe("preserveRichFormatting function", () => { - it("should replace multiple spaces and tabs with a single space", () => { - const inputText = " Hello \t\t World "; - const expectedOutput = "Hello World"; - const result = preserveRichFormatting(inputText); - expect(result).toBe(expectedOutput); - }); - - it("should remove leading and trailing spaces from each line", () => { - const inputText = " Line 1 \n Line 2 \n Line 3 "; - const expectedOutput = "Line 1\nLine 2\nLine 3"; - const result = preserveRichFormatting(inputText); - expect(result).toBe(expectedOutput); - }); -}); - describe("PasteModal", () => { let onConfirm, onCancel; onConfirm = jest.fn(); diff --git a/eq-author/src/components/preview/elements/PageTitle.js b/eq-author/src/components/preview/elements/PageTitle.js index d77f2589d3..ad8ff4ef6e 100644 --- a/eq-author/src/components/preview/elements/PageTitle.js +++ b/eq-author/src/components/preview/elements/PageTitle.js @@ -4,12 +4,14 @@ import styled from "styled-components"; import Error from "components/preview/Error"; +import { colors } from "../../../constants/theme"; + const Title = styled.h1` font-size: 1.4em; margin: 0 0 1em; word-wrap: break-word; strong { - background-color: #dce5b0; + background-color: ${colors.neonYellow}; padding: 0 0.125em; font-style: normal; } diff --git a/eq-author/src/constants/answer-types.js b/eq-author/src/constants/answer-types.js index c0f3ac7aee..11860d9757 100644 --- a/eq-author/src/constants/answer-types.js +++ b/eq-author/src/constants/answer-types.js @@ -11,6 +11,7 @@ export const DATE_RANGE = "DateRange"; export const UNIT = "Unit"; export const DURATION = "Duration"; export const SELECT = "Select"; +export const MUTUALLY_EXCLUSIVE = "MutuallyExclusive"; export const ROUTING_ANSWER_TYPES = [ RADIO, @@ -21,6 +22,7 @@ export const ROUTING_ANSWER_TYPES = [ UNIT, DATE, SELECT, + MUTUALLY_EXCLUSIVE, ]; export const ROUTING_METADATA_TYPES = [TEXT.value, TEXT_OPTIONAL.value]; @@ -28,7 +30,6 @@ export const ROUTING_METADATA_TYPES = [TEXT.value, TEXT_OPTIONAL.value]; export const RADIO_OPTION = "RadioOption"; export const CHECKBOX_OPTION = "CheckboxOption"; export const SELECT_OPTION = "SelectOption"; -export const MUTUALLY_EXCLUSIVE = "MutuallyExclusive"; export const MUTUALLY_EXCLUSIVE_OPTION = "MutuallyExclusiveOption"; export const ANSWER_OPTION_TYPES = { diff --git a/eq-author/src/constants/themes.js b/eq-author/src/constants/themes.js index 2ded63c971..797cedfe72 100644 --- a/eq-author/src/constants/themes.js +++ b/eq-author/src/constants/themes.js @@ -63,6 +63,12 @@ const THEMES = [ title: "Office of Rail and Road", description: "Header includes the Office of Rail and Road logo", }, + { + id: "ons-nhs", + title: "NHS England", + description: + "Header includes the logos for NHS England and the Office for National Statistics but does not include the links for 'Help', 'My account' or 'Sign out' ", + }, ]; export default THEMES; diff --git a/eq-author/src/constants/validationMessages.js b/eq-author/src/constants/validationMessages.js index 5c8013063a..c305e700b6 100644 --- a/eq-author/src/constants/validationMessages.js +++ b/eq-author/src/constants/validationMessages.js @@ -346,6 +346,8 @@ export const destinationErrors = { export const SURVEY_ID_ERRORS = { ERR_VALID_REQUIRED: "Enter a survey ID", ERR_INVALID: "Enter a survey ID in the correct format", + ERR_SURVEY_ID_MISMATCH: + "The survey ID does not match the linked supplementary data schema", }; export const FORM_TYPE_ERRORS = { diff --git a/eq-author/src/graphql/fragments/answer.graphql b/eq-author/src/graphql/fragments/answer.graphql index 4cef7afb26..311582ad46 100644 --- a/eq-author/src/graphql/fragments/answer.graphql +++ b/eq-author/src/graphql/fragments/answer.graphql @@ -10,4 +10,7 @@ fragment Answer on Answer { displayName qCode advancedProperties + page { + id + } } diff --git a/eq-author/src/graphql/getQuestionnaire.graphql b/eq-author/src/graphql/getQuestionnaire.graphql index 37f5fc434a..e967424e62 100644 --- a/eq-author/src/graphql/getQuestionnaire.graphql +++ b/eq-author/src/graphql/getQuestionnaire.graphql @@ -37,6 +37,9 @@ query GetQuestionnaire($input: QueryInput!) { displayName title position + ... on ListCollectorFolder { + listId + } pages { id title diff --git a/eq-author/src/graphql/importFolders.graphql b/eq-author/src/graphql/importFolders.graphql new file mode 100644 index 0000000000..26ef870322 --- /dev/null +++ b/eq-author/src/graphql/importFolders.graphql @@ -0,0 +1,130 @@ +#import "./fragments/folder.graphql" +#import "./fragments/page.graphql" +#import "./fragments/answer.graphql" +#import "./fragments/option.graphql" +#import "./fragments/comment.graphql" + +mutation ImportFolders($input: ImportFoldersInput!) { + importFolders(input: $input) { + folders { + ...Folder + pages { + ...Page + position + displayName + pageType + title + ... on QuestionPage { + alias + description + guidance + answers { + ...Answer + ... on BasicAnswer { + secondaryQCode + } + ... on MultipleChoiceAnswer { + options { + ...Option + } + mutuallyExclusiveOption { + id + displayName + label + description + value + qCode + } + } + } + confirmation { + id + qCode + displayName + comments { + ...Comment + } + } + comments { + ...Comment + } + } + ... on CalculatedSummaryPage { + id + title + alias + pageType + pageDescription + displayName + position + totalTitle + type + answers { + ...Answer + ... on BasicAnswer { + secondaryQCode + } + } + summaryAnswers { + id + } + comments { + ...Comment + } + } + ... on ListCollectorQualifierPage { + id + answers { + id + ... on MultipleChoiceAnswer { + ...Answer + options { + ...Option + } + mutuallyExclusiveOption { + id + } + } + } + comments { + ...Comment + } + } + ... on ListCollectorAddItemPage { + id + description + descriptionEnabled + guidance + guidanceEnabled + definitionLabel + definitionContent + definitionEnabled + additionalInfoLabel + additionalInfoContent + additionalInfoEnabled + comments { + ...Comment + } + } + ... on ListCollectorConfirmationPage { + id + answers { + id + ... on MultipleChoiceAnswer { + ...Answer + options { + ...Option + } + mutuallyExclusiveOption { + id + } + } + } + comments { + ...Comment + } + } + } + } + } +} diff --git a/eq-author/src/graphql/movePage.graphql b/eq-author/src/graphql/movePage.graphql index d5b38de94a..f8f02b160a 100644 --- a/eq-author/src/graphql/movePage.graphql +++ b/eq-author/src/graphql/movePage.graphql @@ -49,6 +49,10 @@ mutation MovePage($input: MovePageInput!) { listId } } + section { + id + repeatingSectionListId + } ... on QuestionPage { answers { ...Answer diff --git a/eq-author/src/utils/getContentBeforeEntity.js b/eq-author/src/utils/getContentBeforeEntity.js index 472dcf38cc..f623ed053b 100644 --- a/eq-author/src/utils/getContentBeforeEntity.js +++ b/eq-author/src/utils/getContentBeforeEntity.js @@ -1,6 +1,9 @@ import { remove } from "lodash"; import isListCollectorPageType from "utils/isListCollectorPageType"; +import { getPageByAnswerId } from "utils/questionnaireUtils"; + +import { MUTUALLY_EXCLUSIVE } from "constants/answer-types"; const identity = (x) => x; @@ -8,9 +11,12 @@ const getContentBeforeEntity = ( questionnaire, id, preprocessAnswers, - includeTarget + includeTarget, + expressionGroup, + selectedId ) => { const sections = []; + const selectedAnswerPage = getPageByAnswerId(questionnaire, selectedId); for (const section of questionnaire.sections) { if (section.id === id) { @@ -37,9 +43,69 @@ const getContentBeforeEntity = ( return sections; } - const answers = + let answers = !isListCollectorPageType(page.pageType) && (page?.answers?.flatMap(preprocessAnswers) || []); + + /* + When expression group's condition is "And": + 1. Do not include mutually exclusive answers on the same page as the expression's left side answer + 2. Do not include answers on the same page as the expression's left side answer when the expression's left side answer is mutually exclusive + */ + if (expressionGroup?.operator === "And") { + expressionGroup.expressions.forEach((expression) => { + if (expression?.left?.page?.id) { + // Filters answers if the expression's left side page is not the selected answer's page - allows selection of answers on the same page as the selected answer + if (expression.left.page.id !== selectedAnswerPage?.id) { + answers = answers.filter((answer) => { + // If the expression's left side answer is on the same page as the looped answer + if (expression.left.page.id === answer.page.id) { + // If the expression's left side answer is mutually exclusive, do not include the looped answer (as looped answer and expression answer are on the same page) + if (expression.left.type === MUTUALLY_EXCLUSIVE) { + return false; + } + // If the expression's left side answer is not mutually exclusive, only include the looped answer if it is also not mutually exclusive (as looped answer and expression answer are on the same page) + else { + return answer.type !== MUTUALLY_EXCLUSIVE; + } + } + return true; + }); + } + // Filters answers if the expression's left side page matches the selected answer's page - allows selection of answers on the same page as the selected answer + else { + // Gets all expressions using answers from the same page as the selected answer + const expressionsFromSamePage = + expressionGroup.expressions.filter( + (expression) => + expression?.left?.page?.id === selectedAnswerPage?.id + ); + + /* + Checks if the expression group includes more than one expression using the selected answer's page + (to allow selection of answers on the same page as the selected answer when it is the only expression using the selected answer's page) + */ + const expressionGroupIncludesExpressionFromSamePage = + expressionsFromSamePage.length > 1; // Checks length to see if there is more than one expression in the expression group using the selected answer's page + + // Filters out answers on the same page as the selected answer if the expression group includes more than one expression using the selected answer's page and the selected answer's page includes a mutually exclusive answer + answers = answers.filter((answer) => { + if ( + answer.page.id === selectedAnswerPage?.id && + expressionGroupIncludesExpressionFromSamePage && + selectedAnswerPage?.answers.some( + (answer) => answer.type === MUTUALLY_EXCLUSIVE + ) + ) { + return false; + } + return true; + }); + } + } + }); + } + if (answers.length) { sections[sections.length - 1].folders[ sections[sections.length - 1].folders.length - 1 @@ -75,6 +141,8 @@ export default ({ id, preprocessAnswers = identity, includeTargetPage = false, + expressionGroup, + selectedId, } = {}) => { if (!questionnaire || !id || questionnaire?.introduction?.id === id) { return []; @@ -85,7 +153,9 @@ export default ({ questionnaire, id, preprocessAnswers, - includeTargetPage + includeTargetPage, + expressionGroup, + selectedId ).filter(({ folders }) => folders.length) ); }; diff --git a/eq-author/src/utils/getContentBeforeEntity.test.js b/eq-author/src/utils/getContentBeforeEntity.test.js index 79aa565593..21c93d32f9 100644 --- a/eq-author/src/utils/getContentBeforeEntity.test.js +++ b/eq-author/src/utils/getContentBeforeEntity.test.js @@ -4,6 +4,8 @@ import { } from "tests/utils/createMockQuestionnaire"; import getPreviousContent from "./getContentBeforeEntity"; +import { MUTUALLY_EXCLUSIVE, NUMBER } from "constants/answer-types"; + let questionnaire = buildQuestionnaire({ sectionCount: 2, folderCount: 2, @@ -15,10 +17,321 @@ questionnaire.introduction = { }; describe("utils/getPreviousAnswers", () => { + beforeEach(() => { + // Adds page ID to each answer + questionnaire.sections.forEach((section) => { + section.folders.forEach((folder) => { + folder.pages.forEach((page) => { + page.answers.forEach((answer) => { + answer.page = { + id: page.id, + }; + }); + }); + }); + }); + }); + it("should return empty array when questionnaire or ID not provided", () => { expect(getPreviousContent()).toHaveLength(0); }); + it("should return mutually exclusive answers when expression's left side answer is on a different page", () => { + questionnaire.sections[1].folders[0].pages[1].answers[1].type = + MUTUALLY_EXCLUSIVE; + + const previousContent = getPreviousContent({ + questionnaire, + id: questionnaire.sections[1].folders[0].pages[1].id, + includeTargetPage: true, + expressionGroup: { + operator: "And", + expressions: [ + { + left: { + answerId: + questionnaire.sections[1].folders[0].pages[0].answers[0].id, + page: { + id: questionnaire.sections[1].folders[0].pages[0].id, + }, + }, + }, + ], + }, + }); + + expect(previousContent).toHaveLength(2); + + // Tests previousContent[0] matches the questionnaire's first section + expect(previousContent[0]).toMatchObject(questionnaire.sections[0]); + + // Tests previousContent[1]'s first folder has two pages + expect(previousContent[1].folders[0].pages).toHaveLength(2); + // Tests previousContent[1]'s first folder's first page has two answers + expect(previousContent[1].folders[0].pages[0].answers).toHaveLength(2); + // Tests previousContent[1]'s first folder's first page matches the questionnaire's second section's first folder's first page + expect(previousContent[1].folders[0].pages[0]).toMatchObject( + questionnaire.sections[1].folders[0].pages[0] + ); + + // Tests previousContent[1]'s first folder's second page has two answers + expect(previousContent[1].folders[0].pages[1].answers).toHaveLength(2); + // Tests previousContent[1]'s first folder's second page matches the questionnaire's second section's first folder's second page + expect(previousContent[1].folders[0].pages[1]).toMatchObject( + questionnaire.sections[1].folders[0].pages[1] + ); + }); + + it("should not return mutually exclusive answers on the same page as the expression's left side answer", () => { + questionnaire.sections[1].folders[0].pages[1].answers[1].type = + MUTUALLY_EXCLUSIVE; + + const previousContent = getPreviousContent({ + questionnaire, + id: questionnaire.sections[1].folders[0].pages[1].id, + includeTargetPage: true, + expressionGroup: { + operator: "And", + expressions: [ + { + left: { + answerId: + questionnaire.sections[1].folders[0].pages[1].answers[0].id, + page: { + id: questionnaire.sections[1].folders[0].pages[1].id, + }, + }, + }, + ], + }, + }); + + expect(previousContent).toHaveLength(2); + + // Tests previousContent[0] matches the questionnaire's first section + expect(previousContent[0]).toMatchObject(questionnaire.sections[0]); + + // Tests previousContent[1]'s first folder has two pages + expect(previousContent[1].folders[0].pages).toHaveLength(2); + // Tests previousContent[1]'s first folder's first page has two answers + expect(previousContent[1].folders[0].pages[0].answers).toHaveLength(2); + // Tests previousContent[1]'s first folder's first page matches the questionnaire's second section's first folder's first page + expect(previousContent[1].folders[0].pages[0]).toMatchObject( + questionnaire.sections[1].folders[0].pages[0] + ); + + // Tests to assert that the mutually exclusive answer is removed from previousContent[1]'s first folder's second page + expect(previousContent[1].folders[0].pages[1].answers).toHaveLength(1); + expect(previousContent[1].folders[0].pages[1].answers[0]).toMatchObject( + questionnaire.sections[1].folders[0].pages[1].answers[0] + ); + expect( + previousContent[1].folders[0].pages[1].answers.some( + (answer) => answer.type === MUTUALLY_EXCLUSIVE + ) + ).toBe(false); + }); + + it("should not return answers on the same page as expression's left side answer when left side answer is mutually exclusive", () => { + questionnaire.sections[1].folders[0].pages[1].answers[1].type = + MUTUALLY_EXCLUSIVE; + + const previousContent = getPreviousContent({ + questionnaire, + id: questionnaire.sections[1].folders[0].pages[1].id, + includeTargetPage: true, + expressionGroup: { + operator: "And", + expressions: [ + { + left: { + answerId: + questionnaire.sections[1].folders[0].pages[1].answers[1].id, + type: MUTUALLY_EXCLUSIVE, + page: { + id: questionnaire.sections[1].folders[0].pages[1].id, + }, + }, + }, + ], + }, + }); + + expect(previousContent).toHaveLength(2); + + // Tests previousContent[0] matches the questionnaire's first section + expect(previousContent[0]).toMatchObject(questionnaire.sections[0]); + + // Tests previousContent[1]'s first folder has one page (the second page has been removed as left side answer is mutually exclusive) + expect(previousContent[1].folders[0].pages).toHaveLength(1); + // Tests previousContent[1]'s first folder's first page has two answers + expect(previousContent[1].folders[0].pages[0].answers).toHaveLength(2); + // Tests previousContent[1]'s first folder's first page matches the questionnaire's second section's first folder's first page + expect(previousContent[1].folders[0].pages[0]).toMatchObject( + questionnaire.sections[1].folders[0].pages[0] + ); + }); + + it("should allow selection of answers on the same page as the selected answer when it is the only expression using the selected answer's page", () => { + questionnaire.sections[1].folders[0].pages[1].answers[1].type = + MUTUALLY_EXCLUSIVE; + + const previousContent = getPreviousContent({ + questionnaire, + id: questionnaire.sections[1].folders[0].pages[1].id, + includeTargetPage: true, + expressionGroup: { + operator: "And", + expressions: [ + { + left: { + answerId: + questionnaire.sections[1].folders[0].pages[1].answers[0].id, + page: { + id: questionnaire.sections[1].folders[0].pages[1].id, + }, + }, + }, + { + left: { + answerId: + questionnaire.sections[1].folders[0].pages[0].answers[0].id, + page: { + id: questionnaire.sections[1].folders[0].pages[0].id, + }, + }, + }, + ], + }, + selectedId: questionnaire.sections[1].folders[0].pages[1].answers[0].id, + }); + + expect(previousContent).toHaveLength(2); + + // Tests previousContent[0] matches the questionnaire's first section + expect(previousContent[0]).toMatchObject(questionnaire.sections[0]); + + // Tests previousContent[1]'s first folder has two pages + expect(previousContent[1].folders[0].pages).toHaveLength(2); + // Tests previousContent[1]'s first folder's first page has two answers + expect(previousContent[1].folders[0].pages[0].answers).toHaveLength(2); + // Tests previousContent[1]'s first folder's first page matches the questionnaire's second section's first folder's first page + expect(previousContent[1].folders[0].pages[0]).toMatchObject( + questionnaire.sections[1].folders[0].pages[0] + ); + + // Tests previousContent[1]'s first folder's second page has two answers + expect(previousContent[1].folders[0].pages[1].answers).toHaveLength(2); + // Tests previousContent[1]'s first folder's second page matches the questionnaire's second section's first folder's second page + expect(previousContent[1].folders[0].pages[1]).toMatchObject( + questionnaire.sections[1].folders[0].pages[1] + ); + }); + + it("should not allow selection of answers on the same page as the selected answer when other expressions in expression group use the selected answer's page and selected answer's page includes a mutually exclusive answer", () => { + questionnaire.sections[1].folders[0].pages[1].answers[1].type = + MUTUALLY_EXCLUSIVE; + + const previousContent = getPreviousContent({ + questionnaire, + id: questionnaire.sections[1].folders[0].pages[1].id, + includeTargetPage: true, + expressionGroup: { + operator: "And", + expressions: [ + { + left: { + answerId: + questionnaire.sections[1].folders[0].pages[1].answers[0].id, + page: { + id: questionnaire.sections[1].folders[0].pages[1].id, + }, + }, + }, + { + left: { + answerId: + questionnaire.sections[1].folders[0].pages[1].answers[1].id, + page: { + id: questionnaire.sections[1].folders[0].pages[1].id, + }, + }, + }, + ], + }, + selectedId: questionnaire.sections[1].folders[0].pages[1].answers[1].id, + }); + + expect(previousContent).toHaveLength(2); + + // Tests previousContent[0] matches the questionnaire's first section + expect(previousContent[0]).toMatchObject(questionnaire.sections[0]); + + // Tests previousContent[1]'s first folder has one page (the second page has been removed as another expression is using an answer from the same page) + expect(previousContent[1].folders[0].pages).toHaveLength(1); + // Tests previousContent[1]'s first folder's first page has two answers + expect(previousContent[1].folders[0].pages[0].answers).toHaveLength(2); + // Tests previousContent[1]'s first folder's first page matches the questionnaire's second section's first folder's first page + expect(previousContent[1].folders[0].pages[0]).toMatchObject( + questionnaire.sections[1].folders[0].pages[0] + ); + }); + + it("should allow selection of answers on the same page as the selected answer when other expressions in expression group use the selected answer's page and selected answer's page does not include a mutually exclusive answer", () => { + questionnaire.sections[1].folders[0].pages[1].answers[1].type = NUMBER; + + const previousContent = getPreviousContent({ + questionnaire, + id: questionnaire.sections[1].folders[0].pages[1].id, + includeTargetPage: true, + expressionGroup: { + operator: "And", + expressions: [ + { + left: { + answerId: + questionnaire.sections[1].folders[0].pages[1].answers[0].id, + page: { + id: questionnaire.sections[1].folders[0].pages[1].id, + }, + }, + }, + { + left: { + answerId: + questionnaire.sections[1].folders[0].pages[1].answers[1].id, + page: { + id: questionnaire.sections[1].folders[0].pages[1].id, + }, + }, + }, + ], + }, + selectedId: questionnaire.sections[1].folders[0].pages[1].answers[1].id, + }); + + expect(previousContent).toHaveLength(2); + + // Tests previousContent[0] matches the questionnaire's first section + expect(previousContent[0]).toMatchObject(questionnaire.sections[0]); + + // Tests previousContent[1]'s first folder has two pages + expect(previousContent[1].folders[0].pages).toHaveLength(2); + // Tests previousContent[1]'s first folder's first page has two answers + expect(previousContent[1].folders[0].pages[0].answers).toHaveLength(2); + // Tests previousContent[1]'s first folder's first page matches the questionnaire's second section's first folder's first page + expect(previousContent[1].folders[0].pages[0]).toMatchObject( + questionnaire.sections[1].folders[0].pages[0] + ); + + // Tests previousContent[1]'s first folder's second page has two answers + expect(previousContent[1].folders[0].pages[1].answers).toHaveLength(2); + // Tests previousContent[1]'s first folder's second page matches the questionnaire's second section's first folder's second page + expect(previousContent[1].folders[0].pages[1]).toMatchObject( + questionnaire.sections[1].folders[0].pages[1] + ); + }); + it("should return empty array on questionnaire introduction page", () => { const previousSections = getPreviousContent({ questionnaire, diff --git a/eq-author/src/utils/searchFunctions/searchByFolderTitleOrShortCode.js b/eq-author/src/utils/searchFunctions/searchByFolderTitleOrShortCode.js new file mode 100644 index 0000000000..30606f92f4 --- /dev/null +++ b/eq-author/src/utils/searchFunctions/searchByFolderTitleOrShortCode.js @@ -0,0 +1,20 @@ +const searchByFolderTitleOrShortCode = (data, searchTerm) => { + if (!searchTerm || searchTerm === "") { + return data; + } + + const lowerCaseSearchTerm = searchTerm.toLowerCase(); + + const filteredData = data.map(({ folders, ...rest }) => ({ + folders: folders.filter(({ alias, title, displayName }) => + `${alias ? alias : ""} ${title ? title : displayName}` + .toLowerCase() + .includes(lowerCaseSearchTerm) + ), + ...rest, + })); + + return filteredData; +}; + +export default searchByFolderTitleOrShortCode; diff --git a/eq-author/src/utils/searchFunctions/searchByFolderTitleOrShortCode.test.js b/eq-author/src/utils/searchFunctions/searchByFolderTitleOrShortCode.test.js new file mode 100644 index 0000000000..ec67ee6e9e --- /dev/null +++ b/eq-author/src/utils/searchFunctions/searchByFolderTitleOrShortCode.test.js @@ -0,0 +1,68 @@ +import searchByFolderTitleOrShortCode from "./searchByFolderTitleOrShortCode"; + +describe("searchByFolderTitleOrShortCode", () => { + let data; + + beforeEach(() => { + data = [ + { + folders: [ + { + id: "folder-1", + alias: "Folder 1", + }, + { + id: "folder-2", + alias: "Folder 2", + }, + ], + }, + { + folders: [ + { + id: "list-folder-1", + listId: "list-1", + alias: "List 1", + title: "List folder 1", + }, + { + id: "list-folder-2", + listId: "list-2", + title: "List folder 2", + }, + ], + }, + ]; + }); + + it("should return data when searchTerm is empty string", () => { + const searchResult = searchByFolderTitleOrShortCode(data, ""); + + expect(searchResult).toEqual(data); + }); + + it("should return filtered data when searchTerm is not empty string", () => { + const searchResult = searchByFolderTitleOrShortCode(data, "folder 1"); + + expect(searchResult).toEqual([ + { + folders: [ + { + id: "folder-1", + alias: "Folder 1", + }, + ], + }, + { + folders: [ + { + id: "list-folder-1", + listId: "list-1", + alias: "List 1", + title: "List folder 1", + }, + ], + }, + ]); + }); +}); diff --git a/eq-author/yarn.lock b/eq-author/yarn.lock index 7301bd4997..fabdcc8a4f 100644 --- a/eq-author/yarn.lock +++ b/eq-author/yarn.lock @@ -7331,6 +7331,21 @@ draft-js-block-breakout-plugin@latest: dependencies: immutable "~3.7.4" +draft-js-import-element@^1.4.0: + version "1.4.0" + resolved "https://registry.yarnpkg.com/draft-js-import-element/-/draft-js-import-element-1.4.0.tgz#8760acbfeb60ed824a1c8319ec049f702681df66" + integrity sha512-WmYT5PrCm47lGL5FkH6sRO3TTAcn7qNHsD3igiPqLG/RXrqyKrqN4+wBgbcT2lhna/yfWTRtgzAbQsSJoS1Meg== + dependencies: + draft-js-utils "^1.4.0" + synthetic-dom "^1.4.0" + +draft-js-import-html@latest: + version "1.4.1" + resolved "https://registry.yarnpkg.com/draft-js-import-html/-/draft-js-import-html-1.4.1.tgz#c222a3a40ab27dee5874fcf78526b64734fe6ea4" + integrity sha512-KOZmtgxZriCDgg5Smr3Y09TjubvXe7rHPy/2fuLSsL+aSzwUDwH/aHDA/k47U+WfpmL4qgyg4oZhqx9TYJV0tg== + dependencies: + draft-js-import-element "^1.4.0" + draft-js-plugins-editor@latest: version "3.0.0" resolved "https://registry.yarnpkg.com/draft-js-plugins-editor/-/draft-js-plugins-editor-3.0.0.tgz#196d1e065e2c29faebaab4ec081b734fdef294a2" @@ -7346,6 +7361,11 @@ draft-js-raw-content-state@latest: dependencies: draft-js "^0.10.5" +draft-js-utils@^1.4.0: + version "1.4.1" + resolved "https://registry.yarnpkg.com/draft-js-utils/-/draft-js-utils-1.4.1.tgz#a59c792ad621f7050292031a237d524708a6d509" + integrity sha512-xE81Y+z/muC5D5z9qWmKfxEW1XyXfsBzSbSBk2JRsoD0yzMGGHQm/0MtuqHl/EUDkaBJJLjJ2EACycoDMY/OOg== + draft-js@^0.10.5: version "0.10.5" resolved "https://registry.yarnpkg.com/draft-js/-/draft-js-0.10.5.tgz#bfa9beb018fe0533dbb08d6675c371a6b08fa742" @@ -16753,6 +16773,11 @@ synchronous-promise@latest: resolved "https://registry.yarnpkg.com/synchronous-promise/-/synchronous-promise-2.0.15.tgz#07ca1822b9de0001f5ff73595f3d08c4f720eb8e" integrity sha512-k8uzYIkIVwmT+TcglpdN50pS2y1BDcUnBPK9iJeGu0Pl1lOI8pD6wtzgw91Pjpe+RxtTncw32tLxs/R0yNL2Mg== +synthetic-dom@^1.4.0: + version "1.4.0" + resolved "https://registry.yarnpkg.com/synthetic-dom/-/synthetic-dom-1.4.0.tgz#d988d7a4652458e2fc8706a875417af913e4dd34" + integrity sha512-mHv51ZsmZ+ShT/4s5kg+MGUIhY7Ltq4v03xpN1c8T1Krb5pScsh/lzEjyhrVD0soVDbThbd2e+4dD9vnDG4rhg== + tabbable@^6.0.0: version "6.0.0" resolved "https://registry.yarnpkg.com/tabbable/-/tabbable-6.0.0.tgz#7f95ea69134e9335979092ba63866fe67b521b01"