Skip to content

Commit

Permalink
Merge pull request #3118 from ONSdigital/EAR-1914-logic-mutually-excl…
Browse files Browse the repository at this point in the history
…usive

EAR 1914 mutually exclusive in logic
  • Loading branch information
farres1 authored Jun 10, 2024
2 parents f0b0859 + 06e17cf commit ac7f7e4
Show file tree
Hide file tree
Showing 15 changed files with 613 additions and 17 deletions.
10 changes: 7 additions & 3 deletions eq-author-api/schema/resolvers/logic/binaryExpression2/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ const isLeftSideAnswerTypeCompatible = (
[answerTypes.CHECKBOX]: "SelectedOptions",
[answerTypes.DATE]: "DateValue",
[answerTypes.SELECT]: "SelectedOptions",
[answerTypes.MUTUALLY_EXCLUSIVE]: "SelectedOptions",
};

if (secondaryCondition) {
Expand Down Expand Up @@ -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";
}
Expand Down
111 changes: 110 additions & 1 deletion eq-author-api/schema/tests/routing.test.js
Original file line number Diff line number Diff line change
@@ -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 {
Expand All @@ -21,6 +26,7 @@ const {
ERR_ANSWER_NOT_SELECTED,
ERR_RIGHTSIDE_NO_VALUE,
ERR_RIGHTSIDE_NO_CONDITION,
ERR_LOGICAL_AND,
} = require("../../constants/validationErrorCodes");

const {
Expand Down Expand Up @@ -59,6 +65,10 @@ describe("routing", () => {
{
type: NUMBER,
},
{
id: "mutually-exclusive-answer",
type: MUTUALLY_EXCLUSIVE,
},
],
routing: {},
},
Expand Down Expand Up @@ -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: {} }],
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ const VALID_TYPES = [
answerTypes.CHECKBOX,
answerTypes.DATE,
answerTypes.SELECT,
answerTypes.MUTUALLY_EXCLUSIVE,
];

describe("AnswerTypeToCondition", () => {
Expand All @@ -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,
Expand All @@ -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]);
Expand Down
1 change: 1 addition & 0 deletions eq-author-api/src/businessLogic/answerTypeToConditions.js
Original file line number Diff line number Diff line change
Expand Up @@ -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) =>
Expand Down
Original file line number Diff line number Diff line change
@@ -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");
Expand All @@ -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(
Expand All @@ -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)
Expand Down
2 changes: 1 addition & 1 deletion eq-author-api/src/validation/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -365,6 +365,9 @@ fragment Answers on Answer {
type
properties
advancedProperties
page {
id
}
... on BasicAnswer {
secondaryQCode
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ import {
ERR_COUNT_OF_GREATER_THAN_AVAILABLE_OPTIONS,
} from "constants/validationMessages";
import { colors } from "constants/theme";
import { RADIO, SELECT } from "constants/answer-types";
import { RADIO, SELECT, MUTUALLY_EXCLUSIVE } from "constants/answer-types";
import { Select } from "components/Forms";

import TextButton from "components/buttons/TextButton";
Expand All @@ -32,6 +32,8 @@ const answerConditions = {
NOTANYOF: "NotAnyOf",
};

const exclusiveAnswers = [RADIO, SELECT, MUTUALLY_EXCLUSIVE];

const MultipleChoiceAnswerOptions = styled.div`
align-items: center;
display: inline-flex;
Expand Down Expand Up @@ -206,7 +208,7 @@ class MultipleChoiceAnswerOptionsSelector extends React.Component {
return message ? <ValidationError>{message}</ValidationError> : null;
};

renderRadioOptionSelector(hasError) {
renderExclusiveOptionSelector(hasError) {
const { expression } = this.props;
const options = get(expression, "left.options", []);

Expand Down Expand Up @@ -331,8 +333,8 @@ class MultipleChoiceAnswerOptionsSelector extends React.Component {
const hasConditionError =
errors.filter(({ field }) => field === "condition").length > 0;

if (answerType === RADIO || answerType === SELECT) {
return this.renderRadioOptionSelector(hasError);
if (exclusiveAnswers.includes(answerType)) {
return this.renderExclusiveOptionSelector(hasError);
} else {
return this.renderCheckboxOptionSelector(hasError, hasConditionError);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,8 @@ export const preprocessMetadata = (metadata) =>
const RoutingAnswerContentPicker = ({
includeSelf,
selectedContentDisplayName,
expressionGroup,
selectedId,
...otherProps
}) => {
const { questionnaire } = useQuestionnaire();
Expand All @@ -38,8 +40,10 @@ const RoutingAnswerContentPicker = ({
id: pageId,
includeTargetPage: includeSelf,
preprocessAnswers,
expressionGroup,
selectedId,
}),
[questionnaire, pageId, includeSelf]
[questionnaire, pageId, includeSelf, expressionGroup, selectedId]
);

const filteredPreviousAnswers = previousAnswers.map((answer) => {
Expand Down Expand Up @@ -78,6 +82,8 @@ RoutingAnswerContentPicker.propTypes = {
PropTypes.object,
PropTypes.string,
]),
selectedId: PropTypes.string,
expressionGroup: PropTypes.object, //eslint-disable-line
};

export default RoutingAnswerContentPicker;
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,36 @@ exports[`BinaryExpressionEditor should render consistently 1`] = `
>
<RoutingAnswerContentPicker
data-test="routing-answer-picker"
expressionGroup={
Object {
"expressions": Array [
Object {
"condition": "Equal",
"expressionGroup": Object {
"__typename": "ExpressionGroup2",
"id": "1",
"validationErrorInfo": Object {
"errors": Array [],
"id": "1",
"totalCount": 0,
},
},
"id": "1",
"left": Object {
"id": "2",
"type": "Radio",
},
"right": null,
"secondaryCondition": null,
"validationErrorInfo": Object {
"errors": Array [],
"id": "6dd",
"totalCount": 0,
},
},
],
}
}
hasError={false}
onSubmit={[Function]}
selectedId="2"
Expand Down
Loading

0 comments on commit ac7f7e4

Please sign in to comment.