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:
- "
Data should relate to all sites in England, Scotland, Wales and Northern Ireland unless otherwise stated.
You can provide info estimates if actual figures are not available.
We will treat your data securely and confidentially.
",
+ "
Data should relate to all sites in England, Scotland, Wales and Northern Ireland unless otherwise stated.
You can provide info estimates if actual figures are not available.
We will treat your data securely and confidentially.
",
legalBasis: NOTICE_1,
// TODO: previewQuestions
previewQuestions: false,
diff --git a/eq-author/package.json b/eq-author/package.json
index 2e3b4adf90..1562f6ab95 100644
--- a/eq-author/package.json
+++ b/eq-author/package.json
@@ -107,6 +107,7 @@
"draft-js-block-breakout-plugin": "latest",
"draft-js-plugins-editor": "latest",
"draft-js-raw-content-state": "latest",
+ "draft-js-import-html": "latest",
"draftjs-filters": "^2.5.0",
"firebase": "latest",
"firebaseui": "latest",
diff --git a/eq-author/src/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}