diff --git a/editor.planx.uk/src/@planx/components/Calculate/logic.test.ts b/editor.planx.uk/src/@planx/components/Calculate/logic.test.ts index d8d96d0260..3194ae5a77 100644 --- a/editor.planx.uk/src/@planx/components/Calculate/logic.test.ts +++ b/editor.planx.uk/src/@planx/components/Calculate/logic.test.ts @@ -1,11 +1,9 @@ import { ComponentType as TYPES } from "@opensystemslab/planx-core/types"; +import { clickContinue, visitedNodes } from "pages/FlowEditor/lib/__tests__/utils"; import { Store, useStore } from "pages/FlowEditor/lib/store"; const { getState, setState } = useStore; -const { upcomingCardIds, resetPreview, record } = getState(); - -// Helper method -const visitedNodes = () => Object.keys(getState().breadcrumbs); +const { upcomingCardIds, resetPreview, autoAnswerableOptions } = getState(); beforeEach(() => { resetPreview(); @@ -17,13 +15,12 @@ test("When formatOutputForAutomations is true, Calculate writes an array and fut expect(upcomingCardIds()).toEqual(["Calculate", "Question"]); // Step forwards through the Calculate - record("Calculate", { data: { testGroup: ["2"] }, auto: true }); - upcomingCardIds(); - - // The Question has been auto-answered - expect(visitedNodes()).toEqual(["Calculate", "Question"]); + clickContinue("Calculate", { data: { testGroup: ["2"] }, auto: true }); - expect(upcomingCardIds()).toEqual(["Group2Notice"]); + // The Question can be auto-answered + expect(visitedNodes()).toEqual(["Calculate"]); + expect(upcomingCardIds()).toEqual(["Question"]) + expect(autoAnswerableOptions("Question")).toEqual(["Group2Response"]); }); test("When formatOutputForAutomations is false, Calculate writes a number and future questions are not auto-answered", () => { @@ -32,13 +29,12 @@ test("When formatOutputForAutomations is false, Calculate writes a number and fu expect(upcomingCardIds()).toEqual(["Calculate", "Question"]); // Step forwards through the Calculate - record("Calculate", { data: { testGroup: 2 }, auto: true }); - upcomingCardIds(); + clickContinue("Calculate", { data: { testGroup: 2 }, auto: true }); - // The Question has NOT been auto-answered + // The Question cannot be auto-answered expect(visitedNodes()).toEqual(["Calculate"]); - expect(upcomingCardIds()).toEqual(["Question"]); + expect(autoAnswerableOptions("Question")).toBeUndefined(); }); const flowWithAutomation: Store.Flow = { diff --git a/editor.planx.uk/src/@planx/components/Checklist/Editor.tsx b/editor.planx.uk/src/@planx/components/Checklist/Editor.tsx index d5de61d6aa..c4585a46da 100644 --- a/editor.planx.uk/src/@planx/components/Checklist/Editor.tsx +++ b/editor.planx.uk/src/@planx/components/Checklist/Editor.tsx @@ -35,6 +35,7 @@ export interface ChecklistProps extends Checklist { node?: { data?: { allRequired?: boolean; + neverAutoAnswer?: boolean; categories?: Array; description?: string; fn?: string; @@ -285,6 +286,7 @@ export const ChecklistComponent: React.FC = (props) => { const formik = useFormik({ initialValues: { allRequired: props.node?.data?.allRequired || false, + neverAutoAnswer: props.node?.data?.neverAutoAnswer || false, description: props.node?.data?.description || "", fn: props.node?.data?.fn || "", groupedOptions: props.groupedOptions, @@ -422,6 +424,22 @@ export const ChecklistComponent: React.FC = (props) => { label="All required" /> + + + formik.setFieldValue( + "neverAutoAnswer", + !formik.values.neverAutoAnswer, + ) + } + /> + } + label="Always put to user (forgo automation)" + /> + diff --git a/editor.planx.uk/src/@planx/components/Checklist/Public.tsx b/editor.planx.uk/src/@planx/components/Checklist/Public.tsx index 11cdd84c31..50a9827ca3 100644 --- a/editor.planx.uk/src/@planx/components/Checklist/Public.tsx +++ b/editor.planx.uk/src/@planx/components/Checklist/Public.tsx @@ -12,7 +12,8 @@ import ImageButton from "@planx/components/shared/Buttons/ImageButton"; import Card from "@planx/components/shared/Preview/Card"; import { CardHeader } from "@planx/components/shared/Preview/CardHeader/CardHeader"; import { getIn, useFormik } from "formik"; -import React, { useState } from "react"; +import { useStore } from "pages/FlowEditor/lib/store"; +import React, { useEffect, useState } from "react"; import { ExpandableList, ExpandableListItem } from "ui/public/ExpandableList"; import FormWrapper from "ui/public/FormWrapper"; import FullWidthWrapper from "ui/public/FullWidthWrapper"; @@ -38,6 +39,40 @@ function toggleInArray(value: T, arr: Array): Array { } const ChecklistComponent: React.FC = (props) => { + if (props.neverAutoAnswer) { + return ; + } + + const autoAnswerableOptions = useStore( + (state) => state.autoAnswerableOptions, + ); + + let idsThatCanBeAutoAnswered: string[] | undefined; + if (props.id) idsThatCanBeAutoAnswered = autoAnswerableOptions(props.id); + if (idsThatCanBeAutoAnswered) { + return ( + + ); + } + + return ; +}; + +// An auto-answered Checklist won't be seen by the user, but still leaves a breadcrumb +const AutoAnsweredChecklist: React.FC = ( + props, +) => { + useEffect(() => { + props.handleSubmit?.({ + answers: props.answerIds, + auto: true, + }); + }, []); + + return null; +}; + +const VisibleChecklist: React.FC = (props) => { const { description = "", groupedOptions, @@ -168,9 +203,8 @@ const ChecklistComponent: React.FC = (props) => { pb={2} aria-labelledby={`group-${index}-heading`} id={`group-${index}-content`} - data-testid={`group-${index}${ - isExpanded ? "-expanded" : "" - }`} + data-testid={`group-${index}${isExpanded ? "-expanded" : "" + }`} > {group.children.map((option) => ( ; + neverAutoAnswer?: boolean; } interface ChecklistExpandableProps { diff --git a/editor.planx.uk/src/@planx/components/Filter/Editor.tsx b/editor.planx.uk/src/@planx/components/Filter/Editor.tsx index b7b84f8ec3..835c3c4abb 100644 --- a/editor.planx.uk/src/@planx/components/Filter/Editor.tsx +++ b/editor.planx.uk/src/@planx/components/Filter/Editor.tsx @@ -63,13 +63,12 @@ const Filter: React.FC = (props) => { through the left-most matching flag option only. - + = (props) => { onChange={formik.handleChange} /> + + + formik.setFieldValue( + "neverAutoAnswer", + !formik.values.neverAutoAnswer, + ) + } + /> + } + label="Always put to user (forgo automation)" + /> + @@ -227,7 +244,6 @@ export const Question: React.FC = (props) => { /> - { + act(() => setState({ flow })); +}); + const responses: { [key in QuestionLayout]: Question["responses"] } = { [QuestionLayout.Basic]: [ { @@ -58,9 +96,10 @@ describe("Question component", () => { describe(`${QuestionLayout[type]} layout`, () => { it(`renders the layout correctly`, async () => { const handleSubmit = vi.fn(); - + const { user, getByTestId, getByRole, getByText } = setup( { const handleSubmit = vi.fn(); const { user, getByRole, getByTestId } = setup( { const handleSubmit = vi.fn(); const { container } = setup( { const { user, getByTestId, getByText, queryByText } = setup( = (props) => { + if (props.neverAutoAnswer) { + return ; + } + + const [flow, autoAnswerableOptions] = useStore((state) => [ + state.flow, + state.autoAnswerableOptions, + ]); + + // Questions without edges act like "sticky notes" in the graph for editors only & can be immediately auto-answered + let edges: Edges | undefined; + if (props.id) edges = flow[props.id]?.edges + if (!edges || edges.length === 0) { + return ; + } + + let idsThatCanBeAutoAnswered: string[] | undefined; + if (props.id) idsThatCanBeAutoAnswered = autoAnswerableOptions(props.id); + if (idsThatCanBeAutoAnswered) { + return ( + + ); + } + + return ; +}; + +// An auto-answered Question won't be seen by the user, but still leaves a breadcrumb +const AutoAnsweredQuestion: React.FC< + Question & { answerIds: string[] | undefined } +> = (props) => { + useEffect(() => { + props.handleSubmit?.({ + answers: props.answerIds, + auto: true, + }); + }, []); + + return null; +}; + +const VisibleQuestion: React.FC = (props) => { const previousResponseId = props?.previouslySubmittedData?.answers?.[0]; const previousResponseKey = props.responses.find( (response) => response.id === previousResponseId, diff --git a/editor.planx.uk/src/@planx/components/Question/model.ts b/editor.planx.uk/src/@planx/components/Question/model.ts index 908b5bc2bb..c5aef4d74a 100644 --- a/editor.planx.uk/src/@planx/components/Question/model.ts +++ b/editor.planx.uk/src/@planx/components/Question/model.ts @@ -8,6 +8,7 @@ export interface Question extends BaseNodeData { text?: string; description?: string; img?: string; + neverAutoAnswer?: boolean; responses: { id?: string; responseKey: string | number; diff --git a/editor.planx.uk/src/pages/FlowEditor/components/Flow/components/Filter.tsx b/editor.planx.uk/src/pages/FlowEditor/components/Flow/components/Filter.tsx index 21ce13e1f2..07aa0c0243 100644 --- a/editor.planx.uk/src/pages/FlowEditor/components/Flow/components/Filter.tsx +++ b/editor.planx.uk/src/pages/FlowEditor/components/Flow/components/Filter.tsx @@ -57,6 +57,7 @@ const Filter: React.FC = React.memo((props) => { isDragging, isClone: isClone(props.id), isNote: childNodes.length === 0, + wasVisited: props.wasVisited, })} > { - getState().resetPreview(); -}); - -test("apple", () => { - setState({ - flow, - }); - - expect(getState().upcomingCardIds()).toEqual([ - "Imks7j68BD", - "HV0gV8DOil", - "2PT6bTPTqj", - "3H2bGdzpIN", - "AFX3QwbOCd", - ]); - - // record apple - getState().record("Imks7j68BD", { answers: ["EqfqaqZ6CH"] }); - - expect(getState().upcomingCardIds()).toEqual(["6RR1J1lmrM", "mOPogpQa7V"]); -}); - -test("apple and spanner", () => { - setState({ - flow, - }); - - // record apple and spanner - getState().record("Imks7j68BD", { answers: ["EqfqaqZ6CH", "7tV1uvR9ng"] }); - - expect(getState().upcomingCardIds()).toEqual(["6RR1J1lmrM", "KcLGMm3UWw"]); -}); - -test("apple and bread", () => { - setState({ - flow, - }); - - // record apple and bread - getState().record("Imks7j68BD", { answers: ["EqfqaqZ6CH", "pXFKKRG6lE"] }); - - expect(getState().upcomingCardIds()).toEqual(["t3SCqQKeUK", "mOPogpQa7V"]); -}); diff --git a/editor.planx.uk/src/pages/FlowEditor/lib/__tests__/automations.blanks.test.ts b/editor.planx.uk/src/pages/FlowEditor/lib/__tests__/automations.blanks.test.ts new file mode 100644 index 0000000000..b3546d3ad1 --- /dev/null +++ b/editor.planx.uk/src/pages/FlowEditor/lib/__tests__/automations.blanks.test.ts @@ -0,0 +1,508 @@ +import { Store, useStore } from "../store"; +import { clickContinue } from "./utils"; + +const { getState, setState } = useStore; +const { resetPreview, upcomingCardIds, autoAnswerableOptions, computePassport } = getState(); + +describe("Auto-answering blanks", () => { + beforeEach(() => { + resetPreview(); + setState({ flow }); + }); + + test("Checklists with the same options auto-answer the blank", () => { + // Manually select the test path + expect(upcomingCardIds()).toEqual(["TestPathSelection"]); + clickContinue("TestPathSelection", { answers: ["TestPath1"], auto: false }); + + // Manually proceed through the first blank + expect(upcomingCardIds()).toEqual(["Path1Checklist1", "Path1Checklist2"]); + clickContinue("Path1Checklist1", { answers: ["Path1Checklist1Blank"], auto: false }); + + // The second blank is auto-answered because we do not have a passport value but we've seen all options before + expect(computePassport()?.data).not.toHaveProperty("option"); + expect(autoAnswerableOptions("Path1Checklist2")).toEqual(["Path1Checklist2Blank"]); + }); + + test("Checklists with different options do not auto-answer the blank", () => { + // Manually select the test path + expect(upcomingCardIds()).toEqual(["TestPathSelection"]); + clickContinue("TestPathSelection", { answers: ["TestPath2"], auto: false }); + + // Manually proceed through the first blank + expect(upcomingCardIds()).toEqual(["Path2Checklist1", "Path2Checklist2"]); + clickContinue("Path2Checklist1", { answers: ["Path2Checklist1Blank"], auto: false }); + + // The second blank is put to the user because we do not have a passport value and we have NOT seen all options before + expect(computePassport()?.data).not.toHaveProperty("option"); + expect(autoAnswerableOptions("Path2Checklist2")).toBeUndefined; + }); + + test("Questions with the same options auto-answer the blank", () => { + // Manually select the test path + expect(upcomingCardIds()).toEqual(["TestPathSelection"]); + clickContinue("TestPathSelection", { answers: ["TestPath3"], auto: false }); + + // Manually proceed through the first blank + expect(upcomingCardIds()).toEqual(["Path3Question1", "Path3Question2"]); + clickContinue("Path3Question1", { answers: ["Path3Question1Blank"], auto: false }); + + // The second blank is auto-answered because we do not have a passport value but we've seen all options before + expect(computePassport()?.data).not.toHaveProperty("option"); + expect(autoAnswerableOptions("Path3Question1")).toEqual(["Path3Question1Blank"]); + }); + + test("Questions with different options do not auto-answer the blank", () => { + // Manually select the test path + expect(upcomingCardIds()).toEqual(["TestPathSelection"]); + clickContinue("TestPathSelection", { answers: ["TestPath4"], auto: false }); + + // Manually proceed through the first blank + expect(upcomingCardIds()).toEqual(["Path4Question1", "Path4Question2", "Path4Question3"]); + clickContinue("Path4Question1", { answers: ["Path4Question1Blank"], auto: false }); + + // The second blank is put to the user because we do not have a passport value and we have NOT seen all options before + expect(computePassport()?.data).not.toHaveProperty("option"); + expect(autoAnswerableOptions("Path4Question2")).toBeUndefined; + + // Manually proceed through the second blank + clickContinue("Path4Question2", { answers: ["Path4Question2Blank"], auto: false }); + + // The third blank is auto-answered because we do not have a passport value but we've seen all options before now + expect(computePassport()?.data).not.toHaveProperty("option"); + expect(autoAnswerableOptions("Path4Question3")).toEqual(["Path4Question3Blank"]); + }); + + test("Checklist then a Question with the same options auto-answer the blank", () => { + // Manually select the test path + expect(upcomingCardIds()).toEqual(["TestPathSelection"]); + clickContinue("TestPathSelection", { answers: ["TestPath5"], auto: false }); + + // Manually proceed through the first blank + expect(upcomingCardIds()).toEqual(["Path5Checklist", "Path5Question"]); + clickContinue("Path5Checklist", { answers: ["Path5ChecklistBlank"], auto: false }); + + // The second blank is auto-answered because we do not have a passport value but we've seen all options before + expect(computePassport()?.data).not.toHaveProperty("option"); + expect(autoAnswerableOptions("Path5Question")).toEqual(["Path5QuestionBlank"]); + }); +}); + +// editor.planx.dev/testing/automate-blanks-test +const flow: Store.Flow = { + "_root": { + "edges": [ + "TestPathSelection" + ] + }, + "Path5Checklist1OptionA": { + "data": { + "val": "a", + "text": "A" + }, + "type": 200 + }, + "Path5Checklist": { + "data": { + "fn": "option", + "text": "Options 1", + "allRequired": false + }, + "type": 105, + "edges": [ + "Path5ChecklistOptionA", + "Path5ChecklistOptionB", + "Path5ChecklistBlank" + ] + }, + "Path4Question2Blank": { + "data": { + "text": "None" + }, + "type": 200 + }, + "Path3Question2": { + "data": { + "fn": "option", + "text": "Options 2" + }, + "type": 100, + "edges": [ + "Path3Question2OptionA", + "Path3Question2Blank" + ] + }, + "Path1Checklist1": { + "data": { + "fn": "option", + "text": "Options 1", + "allRequired": false + }, + "type": 105, + "edges": [ + "Path5Checklist1OptionA", + "Path5Checklist1OptionB", + "Path5Checklist1Blank" + ] + }, + "Path5ChecklistOptionA": { + "data": { + "val": "a", + "text": "A" + }, + "type": 200 + }, + "TestPathSelection": { + "data": { + "text": "Which flow?" + }, + "type": 100, + "edges": [ + "TestPath1", + "TestPath2", + "TestPath3", + "TestPath4", + "TestPath5" + ] + }, + "Path3Question1": { + "data": { + "fn": "option", + "text": "Options 1" + }, + "type": 100, + "edges": [ + "Path3Question1OptionA", + "Path3Question1Blank" + ] + }, + "Path5Question": { + "data": { + "fn": "option", + "text": "Options 2" + }, + "type": 100, + "edges": [ + "Path5QuestionOptionA", + "Path5QuestionOptionB", + "Path5QuestionBlank" + ] + }, + "Path3Question1OptionA": { + "data": { + "val": "a", + "text": "A" + }, + "type": 200 + }, + "Path3Question2OptionA": { + "data": { + "val": "a", + "text": "A" + }, + "type": 200 + }, + "Path1Checklist2OptionA": { + "data": { + "val": "a", + "text": "A" + }, + "type": 200 + }, + "Path2Checklist1": { + "data": { + "fn": "option", + "text": "Options 1", + "allRequired": false + }, + "type": 105, + "edges": [ + "Path2Checklist1OptionA", + "Path2Checklist1OptionB", + "Path2Checklist1Blank" + ] + }, + "Path5QuestionBlank": { + "data": { + "text": "None" + }, + "type": 200 + }, + "Path2Checklist2Blank": { + "data": { + "text": "None" + }, + "type": 200 + }, + "Path4Question1": { + "data": { + "fn": "option", + "text": "Options 1" + }, + "type": 100, + "edges": [ + "Path4Question1OptionA", + "Path4Question1Blank" + ] + }, + "TestPath2": { + "data": { + "text": "2", + "description": "Checklists with different options" + }, + "type": 200, + "edges": [ + "Path2Checklist1", + "Path2Checklist2" + ] + }, + "Path4Question2OptionA": { + "data": { + "val": "a", + "text": "A" + }, + "type": 200 + }, + "Path4Question1Blank": { + "data": { + "text": "None" + }, + "type": 200 + }, + "TestPath5": { + "data": { + "text": "5", + "description": "Checklist then question with same options" + }, + "type": 200, + "edges": [ + "Path5Checklist", + "Path5Question" + ] + }, + "Path4Question1OptionA": { + "data": { + "val": "a", + "text": "A" + }, + "type": 200 + }, + "Path2Checklist2OptionA": { + "data": { + "val": "a", + "text": "A" + }, + "type": 200 + }, + "Path5ChecklistOptionB": { + "data": { + "val": "b", + "text": "B" + }, + "type": 200 + }, + "Path1Checklist2OptionB": { + "data": { + "val": "b", + "text": "B" + }, + "type": 200 + }, + "Path5QuestionOptionB": { + "data": { + "val": "b", + "text": "B" + }, + "type": 200 + }, + "TestPath3": { + "data": { + "text": "3", + "description": "Questions with same options" + }, + "type": 200, + "edges": [ + "Path3Question1", + "Path3Question2" + ] + }, + "Path3Question1Blank": { + "data": { + "text": "None" + }, + "type": 200 + }, + "Path2Checklist2OptionB": { + "data": { + "val": "b", + "text": "B" + }, + "type": 200 + }, + "Path4Question2OptionB": { + "data": { + "val": "b", + "text": "B" + }, + "type": 200 + }, + "Path3Question2Blank": { + "data": { + "text": "None" + }, + "type": 200 + }, + "Path2Checklist1Blank": { + "data": { + "text": "None" + }, + "type": 200 + }, + "Path5Checklist1Blank": { + "data": { + "text": "None" + }, + "type": 200 + }, + "Path4Question3": { + "data": { + "fn": "option", + "text": "Options 3" + }, + "type": 100, + "edges": [ + "Path4Question3OptionB", + "Path4Question3OptionA", + "Path4Question3Blank" + ] + }, + "Path4Question3Blank": { + "data": { + "text": "None" + }, + "type": 200 + }, + "Path1Checklist2": { + "data": { + "fn": "option", + "text": "Options 2", + "allRequired": false + }, + "type": 105, + "edges": [ + "Path1Checklist2OptionA", + "Path1Checklist2OptionB", + "Path1Checklist2Blank" + ] + }, + "TestPath1": { + "data": { + "text": "1", + "description": "Checklists with same options" + }, + "type": 200, + "edges": [ + "Path1Checklist1", + "Path1Checklist2" + ] + }, + "TestPath4": { + "data": { + "text": "4", + "description": "Questions with different options" + }, + "type": 200, + "edges": [ + "Path4Question1", + "Path4Question2", + "Path4Question3" + ] + }, + "Path5ChecklistBlank": { + "data": { + "text": "None" + }, + "type": 200 + }, + "Path4Question2": { + "data": { + "fn": "option", + "text": "Options 2" + }, + "type": 100, + "edges": [ + "Path4Question2OptionA", + "Path4Question2OptionB", + "Path4Question2Blank" + ] + }, + "Path1Checklist2Blank": { + "data": { + "text": "None" + }, + "type": 200 + }, + "Path4Question3OptionB": { + "data": { + "val": "b", + "text": "B" + }, + "type": 200 + }, + "Path2Checklist1OptionB": { + "data": { + "val": "b", + "text": "B" + }, + "type": 200 + }, + "Path2Checklist2": { + "data": { + "fn": "option", + "text": "Options 2", + "allRequired": false + }, + "type": 105, + "edges": [ + "Path2Checklist2OptionA", + "Path2Checklist2OptionB", + "Path2Checklist2OptionC", + "Path2Checklist2Blank" + ] + }, + "Path5Checklist1OptionB": { + "data": { + "val": "b", + "text": "B" + }, + "type": 200 + }, + "Path4Question3OptionA": { + "data": { + "val": "a", + "text": "A" + }, + "type": 200 + }, + "Path5QuestionOptionA": { + "data": { + "val": "a", + "text": "A" + }, + "type": 200 + }, + "Path2Checklist1OptionA": { + "data": { + "val": "a", + "text": "A" + }, + "type": 200 + }, + "Path2Checklist2OptionC": { + "data": { + "val": "c", + "text": "C" + }, + "type": 200 + } +}; diff --git a/editor.planx.uk/src/pages/FlowEditor/lib/__tests__/automations.parentChild.test.ts b/editor.planx.uk/src/pages/FlowEditor/lib/__tests__/automations.parentChild.test.ts new file mode 100644 index 0000000000..b03fb9c12a --- /dev/null +++ b/editor.planx.uk/src/pages/FlowEditor/lib/__tests__/automations.parentChild.test.ts @@ -0,0 +1,207 @@ +import { Store, useStore } from "../store"; +import { clickContinue } from "./utils"; + +const { getState, setState } = useStore; +const { upcomingCardIds, resetPreview, autoAnswerableOptions, computePassport } = getState(); + +describe("Parent-child automations and granularity", () => { + beforeEach(() => { + resetPreview(); + setState({ flow }); + }); + + test("Selecting `a` and `a.x` only auto-answers `a.x`", () => { + // Manually answer the first Checklist + expect(upcomingCardIds()).toEqual(["Checklist1", "Checklist2", "Question"]); + clickContinue("Checklist1", { answers: ["Checklist1ParentA", "Checklist1ChildAX"], auto: false }); + + // Only the most granular value is retained in the passport and queued up for auto-answering the subsequent Checklist & Question + expect(upcomingCardIds()?.[0]).toEqual("Checklist2"); + expect(computePassport()?.data).toEqual({ "values": ["a.x"] }); + expect(autoAnswerableOptions("Checklist2")).toEqual(["Checklist2ChildAX"]); + expect(autoAnswerableOptions("Checklist2")).not.toContain("Checklist2ParentA"); + clickContinue("Checklist2", { answers: autoAnswerableOptions("Checklist2"), auto: true }); + + expect(upcomingCardIds()?.[0]).toEqual("Question"); + expect(computePassport()?.data).toEqual({ "values": ["a.x"] }); + expect(autoAnswerableOptions("Question")).toEqual(["QuestionChildAX"]); + expect(autoAnswerableOptions("Question")).toHaveLength(1); + }); + + test("Selecting `a` and `b` auto-answers both", () => { + // Manually answer the first Checklist + expect(upcomingCardIds()).toEqual(["Checklist1", "Checklist2", "Question"]); + clickContinue("Checklist1", { answers: ["Checklist1ParentA", "Checklist1ParentB"], auto: false }); + + // Both values are queued up for auto-answering the subsequent Checklist because they are the same granularity + expect(upcomingCardIds()?.[0]).toEqual("Checklist2"); + expect(computePassport()?.data).toEqual({ "values": ["a", "b"] }); + expect(autoAnswerableOptions("Checklist2")).toEqual(["Checklist2ParentA", "Checklist2ParentB"]); + clickContinue("Checklist2", { answers: autoAnswerableOptions("Checklist2"), auto: true }); + + // Only the left-most value is queued up for auto-answering the Question + expect(upcomingCardIds()?.[0]).toEqual("Question"); + expect(computePassport()?.data).toEqual({ "values": ["a", "b"] }); + expect(autoAnswerableOptions("Question")).toEqual(["QuestionParentA"]); + expect(autoAnswerableOptions("Question")).toHaveLength(1); + }); + + test("Selecting `a`, `a.x` and `b` auto-answers `a.x` and `b` if a Checklist and only `a.x` if a Question", () => { + // Manually answer the first Checklist + expect(upcomingCardIds()).toEqual(["Checklist1", "Checklist2", "Question"]); + clickContinue("Checklist1", { answers: ["Checklist1ParentA", "Checklist1ChildAX", "Checklist1ParentB"], auto: false }); + + // Only the most granular value _per_ parent category is retained in the passport and queued up for auto-answering the subsequent Checklist + expect(upcomingCardIds()?.[0]).toEqual("Checklist2"); + expect(computePassport()?.data).toEqual({ "values": ["a.x", "b"] }); + expect(autoAnswerableOptions("Checklist2")).toEqual(["Checklist2ChildAX", "Checklist2ParentB"]); + expect(autoAnswerableOptions("Checklist2")).not.toContain("Checklist2ParentA"); + clickContinue("Checklist2", { answers: autoAnswerableOptions("Checklist2"), auto: true }); + + // Only the most granular, left-most value is queued up for auto-answering the Question + expect(upcomingCardIds()?.[0]).toEqual("Question"); + expect(computePassport()?.data).toEqual({ "values": ["a.x", "b"] }); + expect(autoAnswerableOptions("Question")).toEqual(["QuestionChildAX"]); + expect(autoAnswerableOptions("Question")).toHaveLength(1); + }); +}); + +// editor.planx.dev/testing/behaviour-check +const flow: Store.Flow = { + "_root": { + "edges": [ + "Checklist1", + "Checklist2", + "Question" + ] + }, + "QuestionParentB": { + "data": { + "val": "b", + "text": "b parent" + }, + "type": 200 + }, + "Checklist1ParentA": { + "data": { + "val": "a", + "text": "a parent" + }, + "type": 200 + }, + "Checklist2ChildAX": { + "data": { + "val": "a.x", + "text": "a child" + }, + "type": 200 + }, + "Checklist1": { + "data": { + "fn": "values", + "tags": [], + "text": "Pick many", + "allRequired": false, + "neverAutoAnswer": false + }, + "type": 105, + "edges": [ + "Checklist1ParentA", + "Checklist1ChildAX", + "Checklist1ParentB" + ] + }, + "Checklist2": { + "data": { + "fn": "values", + "tags": [], + "text": "Pick many", + "allRequired": false, + "neverAutoAnswer": false + }, + "type": 105, + "edges": [ + "Checklist2ParentA", + "Checklist2ChildAX", + "Checklist2ParentB", + "Checklist2ParentC", + "Checklist2Blank" + ] + }, + "QuestionChildAX": { + "data": { + "val": "a.x", + "text": "a child" + }, + "type": 200 + }, + "QuestionParentA": { + "data": { + "val": "a", + "text": "a parent" + }, + "type": 200 + }, + "Checklist2ParentC": { + "data": { + "val": "c", + "text": "c parent" + }, + "type": 200 + }, + "QuestionBlank": { + "data": { + "text": "blank" + }, + "type": 200 + }, + "Checklist2Blank": { + "data": { + "text": "blank" + }, + "type": 200 + }, + "Checklist2ParentA": { + "data": { + "val": "a", + "text": "a parent" + }, + "type": 200 + }, + "Question": { + "data": { + "fn": "values", + "tags": [], + "text": "Pick one", + "neverAutoAnswer": false + }, + "type": 100, + "edges": [ + "QuestionParentA", + "QuestionChildAX", + "QuestionParentB", + "QuestionBlank" + ] + }, + "Checklist1ParentB": { + "data": { + "val": "b", + "text": "b parent" + }, + "type": 200 + }, + "Checklist2ParentB": { + "data": { + "val": "b", + "text": "b parent" + }, + "type": 200 + }, + "Checklist1ChildAX": { + "data": { + "val": "a.x", + "text": "a child" + }, + "type": 200 + } +}; diff --git a/editor.planx.uk/src/pages/FlowEditor/lib/__tests__/automations.planningConstraintNots.test.ts b/editor.planx.uk/src/pages/FlowEditor/lib/__tests__/automations.planningConstraintNots.test.ts new file mode 100644 index 0000000000..1f677455d8 --- /dev/null +++ b/editor.planx.uk/src/pages/FlowEditor/lib/__tests__/automations.planningConstraintNots.test.ts @@ -0,0 +1,154 @@ +import { Store, useStore } from "../store"; +import { clickContinue } from "./utils"; + +const { getState, setState } = useStore; +const { upcomingCardIds, resetPreview, autoAnswerableOptions, computePassport } = getState(); + +describe("Auto-answering using planning constraints `_nots`", () => { + beforeEach(() => { + resetPreview(); + setState({ flow }); + }); + + test("When there are postive intersecting constraints and `_nots`", () => { + expect(upcomingCardIds()).toEqual(["PlanningConstraints", "ConservationAreaQuestion", "Article4Question", "FloodZone1Question"]); + + // Manually proceed forward through PlanningConstraints as if we've checked 4x datasets: Article 4, Conservation Area, Flood Zone 2, Flood Zone 3 + clickContinue("PlanningConstraints", { + data: { + "property.constraints.planning": ["article4"], + "_nots": { + "property.constraints.planning": ["designated.conservationArea", "flood.zone.2", "flood.zone.3"] + } + }, + auto: false + }); + + expect(computePassport()?.data).toHaveProperty("property.constraints.planning"); + expect(computePassport()?.data).toHaveProperty(["_nots", "property.constraints.planning"]); + + // Confirm auto-answer behavior + expect(autoAnswerableOptions("ConservationAreaQuestion")).toEqual(["ConservationAreaNo"]); + expect(autoAnswerableOptions("Article4Question")).toEqual(["Article4Yes"]); + expect(autoAnswerableOptions("FloodZone1Question")).toEqual(["FloodZone1No"]); // Because we have passport vals, follows blank independent of options + }); + + test("When there are only negative `_nots` constraints", () => { + expect(upcomingCardIds()).toEqual(["PlanningConstraints", "ConservationAreaQuestion", "Article4Question", "FloodZone1Question"]); + + // Manually proceed forward through PlanningConstraints as if we've checked 4x datasets: Article 4, Conservation Area, Flood Zone 2, Flood Zone 3 + clickContinue("PlanningConstraints", { + data: { + "_nots": { + "property.constraints.planning": ["article4", "designated.conservationArea", "flood.zone.2", "flood.zone.3"] + } + }, + auto: false + }); + + expect(computePassport()?.data).not.toHaveProperty("property.constraints.planning"); + expect(computePassport()?.data).toHaveProperty(["_nots", "property.constraints.planning"]); + + // Confirm auto-answer behavior + expect(autoAnswerableOptions("ConservationAreaQuestion")).toEqual(["ConservationAreaNo"]); + expect(autoAnswerableOptions("Article4Question")).toEqual(["Article4No"]); + expect(autoAnswerableOptions("FloodZone1Question")).toBeUndefined(); // Because we do not have positive passport vals, puts to user because unseen option + }); +}); + +const flow: Store.Flow = { + "_root": { + "edges": [ + "PlanningConstraints", + "ConservationAreaQuestion", + "Article4Question", + "FloodZone1Question" // flood.zone.1 is NOT fetched or set by Planning Data + ] + }, + "PlanningConstraints": { + "type": 11, + "data": { + "title": "Planning constraints", + "description": "Planning constraints might limit how you can develop or use the property", + "fn": "property.constraints.planning", + "disclaimer": "

This page does not include information about historic planning conditions that may apply to this property.

" + } + }, + "ConservationAreaQuestion": { + "type": 100, + "data": { + "fn": "property.constraints.planning", + "text": "Are you in a conservation area?", + "neverAutoAnswer": false, + "tags": [] + }, + "edges": [ + "ConservationAreaYes", + "ConservationAreaNo" + ] + }, + "ConservationAreaYes": { + "type": 200, + "data": { + "text": "Yes", + "val": "designated.conservationArea" + } + }, + "ConservationAreaNo": { + "type": 200, + "data": { + "text": "No" + } + }, + "Article4Question": { + "type": 100, + "data": { + "fn": "property.constraints.planning", + "text": "Do any Article 4 directions apply?", + "neverAutoAnswer": false + }, + "edges": [ + "Article4Yes", + "Article4No" + ] + }, + "Article4Yes": { + "type": 200, + "data": { + "text": "Yes", + "val": "article4" + } + }, + "Article4No": { + "type": 200, + "data": { + "text": "No" + } + }, + "FloodZone1Question": { + "type": 100, + "data": { + "description": "

(This dataset is not fetched or set via Planning Data)

", + "fn": "property.constraints.planning", + "text": "Are you in flood zone 1?", + "neverAutoAnswer": false + }, + "edges": [ + "FloodZone1Yes", + "FloodZone1No" + ] + }, + "FloodZone1Yes": { + "type": 200, + "data": { + "text": "Yes", + "val": "flood.zone.1" + } + }, + "FloodZone1No": { + "type": 200, + "data": { + "text": "No" + } + } +}; diff --git a/editor.planx.uk/src/pages/FlowEditor/lib/__tests__/automations.setValue.test.ts b/editor.planx.uk/src/pages/FlowEditor/lib/__tests__/automations.setValue.test.ts new file mode 100644 index 0000000000..8f864e1fec --- /dev/null +++ b/editor.planx.uk/src/pages/FlowEditor/lib/__tests__/automations.setValue.test.ts @@ -0,0 +1,339 @@ +import { Store, useStore } from "../store"; +import { clickContinue } from "./utils"; + +const { getState, setState } = useStore; +const { resetPreview, upcomingCardIds, autoAnswerableOptions, computePassport } = getState(); + +describe("Auto-answering based on a SetValue", () => { + beforeEach(() => { + resetPreview(); + }); + + test("Questions and Checklists auto-answer the correct paths", () => { + setState({ flow }); + expect(upcomingCardIds()).toEqual(["SetValueChocolate", "InitialChecklistFood", "QuestionChocolate", "QuestionFruit", "QuestionRyeBread", "LastChecklistFood"]); + + // Proceed through SetValue + clickContinue("SetValueChocolate", { data: { "food": ["chocolate"] }, auto: true }); + expect(computePassport()?.data).toHaveProperty("food"); + + // Confirm all upcoming cards are auto-answerable + expect(autoAnswerableOptions("InitialChecklistFood")).toEqual(["InitialChecklistOptionBlank"]); + expect(autoAnswerableOptions("QuestionChocolate")).toEqual(["QuestionChocolateOptionYes"]); + expect(autoAnswerableOptions("QuestionFruit")).toEqual(["QuestionFruitOptionBlank"]); + expect(autoAnswerableOptions("QuestionRyeBread")).toEqual(["OptionRyeBreadBlank"]); + expect(autoAnswerableOptions("LastChecklistFood")).toEqual(["LastChecklistOptionChocolate"]); + }); + + test("A node using the `neverAutoAnswer` prop is not auto-answered and put to the user", () => { + const alteredFlow = structuredClone(flow); + Object.assign(alteredFlow, { + "InitialChecklistFood": { + "data": { + "fn": "foods", + "tags": [], + "text": "Which foods do you want?", + "allRequired": false, + "neverAutoAnswer": true // toggled to `true` + }, + "type": 105, + "edges": [ + "InitialChecklistOptionFruit", + "InitialChecklistOptionBread", + "InitialChecklistOptionBlank" + ] + } + }); + setState({ flow: alteredFlow }); + expect(upcomingCardIds()?.[0]).toEqual("SetValueChocolate"); + + // Proceed through SetValue + clickContinue("SetValueChocolate", { data: { "foods": ["chocolate"] }, auto: true }); + expect(computePassport()?.data).toHaveProperty("foods"); + + // Confirm that the `neverAutoAnswer` Checklist is not auto-answerable and manually proceed through + expect(autoAnswerableOptions("InitialChecklistFood")).toBeUndefined(); + clickContinue("InitialChecklistFood", { answers: ["InitialChecklistOptionBread"], auto: false }); + expect(computePassport()?.data).toEqual({ "foods": ["bread", "chocolate" ] }); + + // The followup Question only has options that are more granular than our passport values so it is put to the user + expect(upcomingCardIds()?.[0]).toEqual("QuestionBreadType"); + expect(autoAnswerableOptions("QuestionBreadType")).toBeUndefined(); + clickContinue("QuestionBreadType", { answers: ["OptionBreadTypeBagel"], auto: false }); + + // Confirm all upcoming cards are auto-answerable + expect(autoAnswerableOptions("QuestionChocolate")).toEqual(["QuestionChocolateOptionYes"]); + expect(autoAnswerableOptions("QuestionFruit")).toEqual(["QuestionFruitOptionBlank"]); + expect(autoAnswerableOptions("QuestionRyeBread")).toEqual(["OptionRyeBreadBlank"]); + expect(autoAnswerableOptions("LastChecklistFood")).toEqual(["LastChecklistOptionBread", "LastChecklistOptionChocolate"]); + }); +}); + +const flow: Store.Flow = { + "_root": { + "edges": [ + "SetValueChocolate", + "InitialChecklistFood", + "QuestionChocolate", + "QuestionFruit", + "QuestionRyeBread", + "LastChecklistFood" + ] + }, + "OptionFruitTypeGreenGrapes": { + "data": { + "val": "fruit.grapes.green", + "text": "Green grapes" + }, + "type": 200 + }, + "QuestionChocolateOptionYes": { + "data": { + "val": "chocolate", + "text": "Yes" + }, + "type": 200 + }, + "LastChecklistOptionBread": { + "data": { + "val": "bread", + "text": "Bread" + }, + "type": 200 + }, + "InitialChecklistOptionBread": { + "data": { + "val": "bread", + "text": "Bread" + }, + "type": 200, + "edges": [ + "QuestionBreadType" + ] + }, + "QuestionBreadType": { + "data": { + "fn": "foods", + "tags": [], + "text": "Which bread?", + "neverAutoAnswer": false + }, + "type": 100, + "edges": [ + "OptionBreadTypeBagel", + "OptionBreadTypeSourdough" + ] + }, + "OptionBreadTypeSourdough": { + "data": { + "val": "bread.sourdough", + "text": "Sourdough" + }, + "type": 200 + }, + "OptionFruitTypeRedGrapes": { + "data": { + "val": "fruit.grapes.red", + "text": "Red grapes" + }, + "type": 200 + }, + "OptionFruitTypeLessGranularBlank": { + "data": { + "text": "Another kind of fruit" + }, + "type": 200 + }, + "OptionBreadTypeBagel": { + "data": { + "val": "bread.bagel", + "text": "Bagel" + }, + "type": 200 + }, + "LastChecklistOptionBlank": { + "data": { + "text": "None" + }, + "type": 200 + }, + "QuestionRyeBread": { + "data": { + "fn": "foods", + "text": "Do you have rye bread?" + }, + "type": 100, + "edges": [ + "OptionRyeBreadYes", + "OptionRyeBreadBlank" + ] + }, + "QuestionFruit": { + "data": { + "fn": "foods", + "text": "Do you have fruit?" + }, + "type": 100, + "edges": [ + "QuestionFruitOptionYes", + "QuestionFruitOptionBlank" + ] + }, + "LastChecklistOptionFruit": { + "data": { + "val": "fruit", + "text": "Fruit" + }, + "type": 200 + }, + "SetValueChocolate": { + "data": { + "fn": "foods", + "val": "chocolate", + "operation": "append" + }, + "type": 380 + }, + "OptionRyeBreadBlank": { + "data": { + "text": "No" + }, + "type": 200 + }, + "QuestionFruitOptionYes": { + "data": { + "val": "fruit", + "text": "Yes" + }, + "type": 200, + "edges": [ + "QuestionFruitTypeLessGranular" + ] + }, + "InitialChecklistFood": { + "data": { + "fn": "foods", + "tags": [], + "text": "Which foods do you want?", + "allRequired": false, + "neverAutoAnswer": false + }, + "type": 105, + "edges": [ + "InitialChecklistOptionFruit", + "InitialChecklistOptionBread", + "InitialChecklistOptionBlank" + ] + }, + "OptionRyeBreadYes": { + "data": { + "val": "bread.rye", + "text": "Yes" + }, + "type": 200 + }, + "OptionFruitTypeBanana": { + "data": { + "val": "fruit.bananas", + "text": "Bananas" + }, + "type": 200 + }, + "QuestionChocolateOptionBlank": { + "data": { + "text": "No" + }, + "type": 200 + }, + "LastChecklistFood": { + "data": { + "fn": "foods", + "tags": [], + "text": "Which do you have?", + "allRequired": false + }, + "type": 105, + "edges": [ + "LastChecklistOptionFruit", + "LastChecklistOptionBread", + "LastChecklistOptionChocolate", + "LastChecklistOptionBlank" + ] + }, + "QuestionChocolate": { + "data": { + "fn": "foods", + "tags": [], + "text": "Do you have chocolate?", + "neverAutoAnswer": false + }, + "type": 100, + "edges": [ + "QuestionChocolateOptionYes", + "QuestionChocolateOptionBlank" + ] + }, + "LastChecklistOptionChocolate": { + "data": { + "val": "chocolate", + "text": "Chocolate" + }, + "type": 200 + }, + "QuestionFruitOptionBlank": { + "data": { + "text": "No" + }, + "type": 200 + }, + "InitialChecklistOptionFruit": { + "data": { + "val": "fruit", + "text": "Fruit" + }, + "type": 200, + "edges": [ + "QuestionFruitType" + ] + }, + "OptionFruitTypeLessGranularGrapes": { + "data": { + "val": "fruit.grapes", + "text": "Grapes" + }, + "type": 200 + }, + "QuestionFruitTypeLessGranular": { + "data": { + "fn": "foods", + "tags": [], + "text": "Which kind of fruit do you have?", + "neverAutoAnswer": false + }, + "type": 100, + "edges": [ + "OptionFruitTypeLessGranularGrapes", + "OptionFruitTypeLessGranularBlank" + ] + }, + "InitialChecklistOptionBlank": { + "data": { + "text": "None of these" + }, + "type": 200 + }, + "QuestionFruitType": { + "data": { + "fn": "foods", + "tags": [], + "text": "Which fruit?", + "neverAutoAnswer": false + }, + "type": 100, + "edges": [ + "OptionFruitTypeBanana", + "OptionFruitTypeRedGrapes", + "OptionFruitTypeGreenGrapes" + ] + } +}; diff --git a/editor.planx.uk/src/pages/FlowEditor/lib/__tests__/automations.test.ts b/editor.planx.uk/src/pages/FlowEditor/lib/__tests__/automations.test.ts deleted file mode 100644 index 54fc776b78..0000000000 --- a/editor.planx.uk/src/pages/FlowEditor/lib/__tests__/automations.test.ts +++ /dev/null @@ -1,192 +0,0 @@ -import { ComponentType as TYPES } from "@opensystemslab/planx-core/types"; -import shuffle from "lodash/shuffle"; - -import { useStore } from "../store"; - -const { getState, setState } = useStore; - -beforeEach(() => { - getState().resetPreview(); -}); - -describe("(basic) if the passport contains", () => { - [ - ["food.fruit", "food"], - ["hardware", "hardware"], - ["stationary", "other"], - ].forEach(([item, expected]) => { - test(`[${item}] it should go down the [${expected}] path`, () => { - setState({ - flow: { - _root: { - edges: ["_setter", "item"], - }, - _setter: {}, - item: { - type: TYPES.Question, - data: { fn: "item" }, - edges: ["food", "hardware", "other"], - }, - food: { - type: TYPES.Answer, - data: { val: "food" }, - }, - hardware: { - type: TYPES.Answer, - data: { val: "hardware" }, - }, - other: { - type: TYPES.Answer, - }, - }, - }); - - const defaultPassportData = { - data: { item: [item] }, - }; - getState().record("_setter", defaultPassportData); - - getState().upcomingCardIds(); - - expect(getState().breadcrumbs).toEqual({ - _setter: { - auto: false, - ...defaultPassportData, - }, - item: { - answers: [expected], - auto: true, - }, - }); - }); - }); -}); - -describe("(more advanced) if the passport contains", () => { - [ - ["food.fruit", "food.fruit"], - ["food.dairy", "food"], - ["clothes", "other"], - ].forEach(([item, expected]) => { - test(`[${item}] it should go down the [${expected}] path`, () => { - setState({ - flow: { - _root: { - edges: ["_setter", "item"], - }, - _setter: {}, - item: { - type: TYPES.Question, - data: { fn: "item" }, - edges: ["food.fruit", "food", "other"], - }, - "food.fruit": { - type: TYPES.Answer, - data: { val: "food.fruit" }, - }, - food: { - type: TYPES.Answer, - data: { val: "food" }, - }, - other: { - type: TYPES.Answer, - }, - }, - }); - - const defaultPassportData = { - data: { item: [item] }, - }; - getState().record("_setter", defaultPassportData); - - getState().upcomingCardIds(); - - expect(getState().breadcrumbs).toEqual({ - _setter: { - auto: false, - ...defaultPassportData, - }, - item: { - answers: [expected], - auto: true, - }, - }); - }); - }); -}); - -describe("(advanced) if the passport contains", () => { - const data: Array<[string[], string]> = [ - [["food.fruit.banana"], "neither_apples_nor_bread"], - - [["food.bread"], "bread"], - - [["food.fruit.apple"], "apples"], - - [["food.fruit.apple", "food.fruit.banana"], "apples"], - [["food.fruit.apple", "food.bread"], "apples_and_bread"], - - [ - ["food.fruit.apple", "food.fruit.banana", "food.bread"], - "apples_and_bread", - ], - - [["food.fruit.banana", "food.bread"], "bread"], - ]; - data.forEach(([item, expected]: [string[], string]) => { - test(`[${item.join( - " & ", - )}] it should go down the [${expected}] path`, () => { - setState({ - flow: { - _root: { - edges: ["_setter", "contains"], - }, - _setter: {}, - contains: { - type: TYPES.Question, - data: { fn: "item" }, - edges: shuffle([ - "apples", - "bread", - "apples_and_bread", - "neither_apples_nor_bread", - ]), - }, - apples: { - type: TYPES.Answer, - data: { val: "food.fruit.apple" }, - }, - bread: { - type: TYPES.Answer, - data: { val: "food.bread" }, - }, - apples_and_bread: { - type: TYPES.Answer, - data: { val: "food.fruit.apple,food.bread" }, - }, - neither_apples_nor_bread: { - type: TYPES.Answer, - }, - }, - }); - - const defaultPassportData = { data: { item: shuffle(item) } }; - - getState().record("_setter", defaultPassportData); - - getState().upcomingCardIds(); - - expect(getState().breadcrumbs).toEqual({ - _setter: { - auto: false, - ...defaultPassportData, - }, - contains: { - answers: [expected], - auto: true, - }, - }); - }); - }); -}); diff --git a/editor.planx.uk/src/pages/FlowEditor/lib/__tests__/filters.test.ts b/editor.planx.uk/src/pages/FlowEditor/lib/__tests__/filters.test.ts index 9ce3674e38..5ef0106b9b 100644 --- a/editor.planx.uk/src/pages/FlowEditor/lib/__tests__/filters.test.ts +++ b/editor.planx.uk/src/pages/FlowEditor/lib/__tests__/filters.test.ts @@ -1,152 +1,324 @@ -import { useStore } from "../store"; -import flowWithAutoAnsweredFilterPaths from "./mocks/flowWithAutoAnsweredFilterPaths.json"; -import flowWithBranchingFilters from "./mocks/flowWithBranchingFilters.json"; -import flowWithRootFilter from "./mocks/flowWithRootFilter.json"; +import { Store, useStore } from "../store"; +import { clickContinue, visitedNodes } from "./utils"; const { getState, setState } = useStore; -const { - upcomingCardIds, - resetPreview, - record, - getCurrentCard, - collectedFlags, -} = getState(); - -// https://i.imgur.com/k0kkKox.png +const { upcomingCardIds, resetPreview, autoAnswerableFlag, autoAnswerableOptions } = getState(); + describe("A filter on the root of the graph", () => { beforeEach(() => { resetPreview(); + setState({ flow: flowWithFilters }); }); - test.skip("don't expand filters before visiting them (A)", () => { - setState({ - flow: flowWithRootFilter, - }); + test("Filter options are auto-answered correctly when a higher order flag is collected first", () => { + expect(upcomingCardIds()).toEqual(["FirstQuestion", "SecondQuestion", "RootFilter", "BranchingQuestion", "EndNotice"]); + clickContinue("FirstQuestion", { answers: ["FirstQuestionYesAnswer"], auto: false }); + clickContinue("SecondQuestion", { answers: ["SecondQuestionNoAnswer"], auto: false }); - expect(upcomingCardIds()).toEqual([ - "d5SxIWZej9", - "LAz2YqYChs", - "nroxFPM2Jx", - ]); + expect(upcomingCardIds()?.[0]).toEqual("RootFilter"); + expect(autoAnswerableFlag("RootFilter")).toEqual("RootFilterYes"); }); - test("immune path (B)", () => { - setState({ - flow: flowWithRootFilter, - breadcrumbs: { - d5SxIWZej9: { - auto: false, - answers: ["FZ1kmhT37j"], - }, - }, - }); - - expect(upcomingCardIds()).toEqual(["TmpbJgjGPH", "nroxFPM2Jx"]); + test("Filter options are auto-answered correctly when a lower order flag is collected first", () => { + expect(upcomingCardIds()).toEqual(["FirstQuestion", "SecondQuestion", "RootFilter", "BranchingQuestion", "EndNotice"]); + clickContinue("FirstQuestion", { answers: ["FirstQuestionNoAnswer"], auto: false }); + clickContinue("SecondQuestion", { answers: ["SecondQuestionYesAnswer"], auto: false }); + + expect(upcomingCardIds()?.[0]).toEqual("RootFilter"); + expect(autoAnswerableFlag("RootFilter")).toEqual("RootFilterYes"); }); - test("not immune path (C)", () => { - setState({ - flow: flowWithRootFilter, - breadcrumbs: { - d5SxIWZej9: { - auto: false, - answers: ["ZTZqcDAOoG"], - }, - }, - }); - - expect(upcomingCardIds()).toEqual(["lOrm4XmVGv", "nroxFPM2Jx"]); + test("Filter 'No flag result' option is auto-answered correctly when no flags in this category have been collected", () => { + expect(upcomingCardIds()).toEqual(["FirstQuestion", "SecondQuestion", "RootFilter", "BranchingQuestion", "EndNotice"]); + clickContinue("FirstQuestion", { answers: ["FirstQuestionIdkAnswer"], auto: false }); + clickContinue("SecondQuestion", { answers: ["SecondQuestionIdkAnswer"], auto: false }); + + expect(upcomingCardIds()?.[0]).toEqual("RootFilter"); + expect(autoAnswerableFlag("RootFilter")).toEqual("RootFilterNoFlagResult"); }); }); describe("A filter on a branch", () => { beforeEach(() => { resetPreview(); - setState({ flow: flowWithBranchingFilters }); + setState({ flow: flowWithFilters }); + }); + + test("Filter options are auto-answered correctly when a higher order flag is collected first", () => { + clickContinue("FirstQuestion", { answers: ["FirstQuestionYesAnswer"], auto: false }); + clickContinue("SecondQuestion", { answers: ["SecondQuestionNoAnswer"], auto: false }); + clickContinue("RootFilter", { answers: ["RootFilterYes"], auto: true }); + + expect(upcomingCardIds()?.[0]).toEqual("AutoAnswerableChecklist"); + expect(autoAnswerableOptions("AutoAnswerableChecklist")).toEqual(["ChecklistFacadeAnswer"]); + clickContinue("AutoAnswerableChecklist", { answers: ["ChecklistFacadeAnswer"], auto: true }); + + clickContinue("BranchingQuestion", { answers: ["GoToBranchAnswer"], auto: false }); + + expect(upcomingCardIds()?.[0]).toEqual("BranchFilter"); + expect(autoAnswerableFlag("BranchFilter")).toEqual("BranchFilterYes"); }); - test.skip("Picking up flag routes me correctly through the second filter", () => { - const visitedNodes = () => Object.keys(getState().breadcrumbs); + test("Filter options are auto-answered correctly when a lower order flag is collected first", () => { + clickContinue("FirstQuestion", { answers: ["FirstQuestionNoAnswer"], auto: false }); + clickContinue("SecondQuestion", { answers: ["SecondQuestionYesAnswer"], auto: false }); + clickContinue("RootFilter", { answers: ["RootFilterYes"], auto: true }); - // Traverse forward to pick up an "IMMUNE" flag - record("pickFlag", { answers: ["setImmunity"] }); - record("immunityPath1", { answers: [] }); - expect(collectedFlags("immunityPath1", visitedNodes())).toStrictEqual([ - "IMMUNE", - ]); + expect(upcomingCardIds()?.[0]).toEqual("AutoAnswerableChecklist"); + expect(autoAnswerableOptions("AutoAnswerableChecklist")).toEqual(["ChecklistLandscapingAnswer"]); + clickContinue("AutoAnswerableChecklist", { answers: ["ChecklistLandscapingAnswer"], auto: true }); - // Traverse forward through next filter - record("fork", { answers: ["filter2"] }); + clickContinue("BranchingQuestion", { answers: ["GoToBranchAnswer"], auto: false }); - // XXX: Test fails here - // getCurrentCard returns as "immunityFlag2" which we should not land on - - // the flags on the first filter are skipped, we go direct from "immunityPath1" to "fork" - expect(getCurrentCard()?.id).toBe("immunityPath2"); + expect(upcomingCardIds()?.[0]).toEqual("BranchFilter"); + expect(autoAnswerableFlag("BranchFilter")).toEqual("BranchFilterYes"); + }); + + test("Filter 'No flag result' option is auto-answered correctly when no flags in this category have been collected", () => { + clickContinue("FirstQuestion", { answers: ["FirstQuestionIdkAnswer"], auto: false }); + clickContinue("SecondQuestion", { answers: ["SecondQuestionIdkAnswer"], auto: false }); + clickContinue("RootFilter", { answers: ["RootFilterNoFlagResult"], auto: true }); + clickContinue("BranchingQuestion", { answers: ["GoToBranchAnswer"], auto: false }); + + expect(upcomingCardIds()?.[0]).toEqual("BranchFilter"); + expect(autoAnswerableFlag("BranchFilter")).toEqual("BranchFilterNoFlagResult"); }); }); -describe("Nodes on a filter path should only be auto-answered when the path matches the result", () => { +describe("Auto-answerable Questions or Checklists on filter paths", () => { beforeEach(() => { resetPreview(); - setState({ flow: flowWithAutoAnsweredFilterPaths }); // https://editor.planx.uk/testing/flag-order-test-with-autoanswer + setState({ flow: flowWithFilters }); }); - test("Filter path nodes are auto-answered correctly when the highest order flag is picked up first", () => { - const visitedNodes = () => Object.keys(getState().breadcrumbs); - - // go forward manually: select not listed and select an answer with permission needed (higher order) flag - record("zlKQyPuKsl", { answers: ["qW1jzS1qPy"], auto: false }); - record("Ve90wVIXsV", { answers: ["d98AoVIXsV"], auto: false }); // as we pick up this flag, later nodes in matching filter path are auto-answered immediately - - // continue forward manually: select an answer with permitted development (lower order) flag - record("TiIuAVIXsV", { answers: ["hdaeOVIXsV"], auto: false }); - - // land on the correct result component - expect(getCurrentCard()?.id).toBe("seN42VIXsV"); - expect(getState().resultData()["Planning permission"]).toHaveProperty( - "flag.value", - "PLANNING_PERMISSION_REQUIRED", - ); - - // expect the auto-answered question on the permission needed filter path to be in our breadcrumbs - expect(visitedNodes()).toContain("1ShlhWrXPl"); - expect(getState().breadcrumbs["1ShlhWrXPl"]).toEqual({ - answers: ["tesCNavKYo"], - auto: true, - }); - - // make sure the auto-answerable question and its child from the permitted development filter path is not in our breadcrumbs nor upcoming card ids - expect(visitedNodes()).not.toContain("AaEuHnVUb4"); - expect(upcomingCardIds).not.toContain("xjcujhpzjs"); + test("Are only auto-answered when they are reached and when the path matches the result", () => { + clickContinue("FirstQuestion", { answers: ["FirstQuestionYesAnswer"], auto: false }); + clickContinue("SecondQuestion", { answers: ["SecondQuestionYesAnswer"], auto: false }); + expect(visitedNodes()).not.toContain("AutoAnswerableChecklist"); + + expect(upcomingCardIds()?.[0]).toEqual("RootFilter"); + expect(autoAnswerableFlag("RootFilter")).toEqual("RootFilterYes"); + clickContinue("RootFilter", { answers: ["RootFilterYes"], auto: true }); + + expect(upcomingCardIds()?.[0]).toEqual("AutoAnswerableChecklist"); + expect(autoAnswerableOptions("AutoAnswerableChecklist")).toEqual(["ChecklistFacadeAnswer", "ChecklistLandscapingAnswer"]); }); - test.skip("Filter path nodes are auto-answered correctly when a lower order flag is picked up first", () => { - const visitedNodes = () => Object.keys(getState().breadcrumbs); - - // go forward manually: select not listed and select an answer with permitted dev (lower order) flag - record("zlKQyPuKsl", { answers: ["qW1jzS1qPy"], auto: false }); - record("Ve90wVIXsV", { answers: ["pghecgQLgs"], auto: false }); - upcomingCardIds(); // mimic "continue" and properly set visitedNodes() - - // TODO ensure that the auto-answerable question in the permitted dev filter path has not been immediately auto-answered before reaching the filter node - expect(visitedNodes()).not.toContain("AaEuHnVUb4"); - - // continue forward manually: select an answer with permission needed (higher order) flag - record("TiIuAVIXsV", { answers: ["OPOWoVIXsV"], auto: false }); - upcomingCardIds(); - - // land on the correct result component - expect(getCurrentCard()?.id).toBe("seN42VIXsV"); - expect(getState().resultData()["Planning permission"]).toHaveProperty( - "flag.value", - "PLANNING_PERMISSION_REQUIRED", - ); - - // expect the auto-answered question on the permission needed filter path to be in our breadcrumbs - expect(visitedNodes()).toContain("1ShlhWrXPl"); - expect(getState().breadcrumbs["1ShlhWrXPl"]).toEqual({ - answers: ["tesCNavKYo"], - auto: true, - }); + test("Are never auto-answered if the path does not match the result", () => { + clickContinue("FirstQuestion", { answers: ["FirstQuestionNoAnswer"], auto: false }); + clickContinue("SecondQuestion", { answers: ["SecondQuestionIdkAnswer"], auto: false }); + expect(visitedNodes()).not.toContain("AutoAnswerableChecklist"); + + expect(upcomingCardIds()?.[0]).toEqual("RootFilter"); + expect(autoAnswerableFlag("RootFilter")).toEqual("RootFilterNo"); + clickContinue("RootFilter", { answers: ["RootFilterNo"], auto: true }); + + expect(upcomingCardIds()).not.toContain("AutoAnswerableChecklist"); }); }); + +const flowWithFilters: Store.Flow = { + "_root": { + "edges": [ + "FirstQuestion", + "SecondQuestion", + "RootFilter", + "BranchingQuestion", + "EndNotice" + ] + }, + "SecondQuestionYesAnswer": { + "data": { + "val": "alter.landscaping", + "flag": "MCOU_TRUE", + "text": "Yes" + }, + "type": 200 + }, + "FirstQuestionNoAnswer": { + "data": { + "flag": "MCOU_FALSE", + "text": "No" + }, + "type": 200 + }, + "AutoAnswerableChecklist": { + "data": { + "fn": "proposal.projectType", + "text": "What are you altering?", + "allRequired": false + }, + "type": 105, + "edges": [ + "ChecklistFacadeAnswer", + "ChecklistLandscapingAnswer" + ] + }, + "RootFilterYes": { + "data": { + "val": "MCOU_TRUE", + "text": "Material change of use" + }, + "type": 200, + "edges": [ + "AutoAnswerableChecklist" + ] + }, + "BranchingQuestion": { + "data": { + "text": "What about a filter on a branch?" + }, + "type": 100, + "edges": [ + "GoToBranchAnswer", + "SkipBranchAnswer" + ] + }, + "EndNotice": { + "data": { + "color": "#EFEFEF", + "title": "End of test", + "resetButton": true + }, + "type": 8 + }, + "RootFilterNoFlagResult": { + "data": { + "text": "No flag result" + }, + "type": 200 + }, + "BranchFilter": { + "data": { + "fn": "flag", + "category": "Material change of use" + }, + "type": 500, + "edges": [ + "BranchFilterYes", + "BranchFilterNo", + "BranchFilterNoFlagResult" + ], + }, + "BranchFilterNo": { + "data": { + "val": "MCOU_FALSE", + "text": "Not material change of use" + }, + "type": 200 + }, + "ChecklistLandscapingAnswer": { + "data": { + "val": "alter.landscaping", + "text": "Landscaping" + }, + "type": 200 + }, + "RootFilterNo": { + "data": { + "val": "MCOU_FALSE", + "text": "Not material change of use" + }, + "type": 200 + }, + "ChecklistFacadeAnswer": { + "data": { + "val": "alter.facade", + "text": "Facade" + }, + "type": 200 + }, + "FirstQuestion": { + "data": { + "fn": "proposal.projectType", + "tags": [], + "text": "Are you changing the building facade?" + }, + "type": 100, + "edges": [ + "FirstQuestionYesAnswer", + "FirstQuestionNoAnswer", + "FirstQuestionIdkAnswer" + ] + }, + "BranchFilterNoFlagResult": { + "data": { + "text": "No flag result" + }, + "type": 200 + }, + "FirstQuestionYesAnswer": { + "data": { + "val": "alter.facade", + "flag": "MCOU_TRUE", + "text": "Yes" + }, + "type": 200 + }, + "BranchFilterYes": { + "data": { + "val": "MCOU_TRUE", + "text": "Material change of use" + }, + "type": 200 + }, + "FirstQuestionIdkAnswer": { + "data": { + "text": "I don't know" + }, + "type": 200 + }, + "GoToBranchAnswer": { + "data": { + "text": "Let's go" + }, + "type": 200, + "edges": [ + "BranchFilter" + ] + }, + "RootFilter": { + "data": { + "fn": "flag", + "category": "Material change of use" + }, + "type": 500, + "edges": [ + "RootFilterYes", + "RootFilterNo", + "RootFilterNoFlagResult" + ] + }, + "SecondQuestionNoAnswer": { + "data": { + "flag": "MCOU_FALSE", + "text": "No" + }, + "type": 200 + }, + "SecondQuestionIdkAnswer": { + "data": { + "text": "I don't know" + }, + "type": 200 + }, + "SkipBranchAnswer": { + "data": { + "text": "Skip it" + }, + "type": 200 + }, + "SecondQuestion": { + "data": { + "fn": "proposal.projectType", + "tags": [], + "text": "Are you changing the landscaping materials?" + }, + "type": 100, + "edges": [ + "SecondQuestionYesAnswer", + "SecondQuestionNoAnswer", + "SecondQuestionIdkAnswer" + ] + } +}; diff --git a/editor.planx.uk/src/pages/FlowEditor/lib/__tests__/flags.test.ts b/editor.planx.uk/src/pages/FlowEditor/lib/__tests__/flags.test.ts deleted file mode 100644 index 1c6e8f4e83..0000000000 --- a/editor.planx.uk/src/pages/FlowEditor/lib/__tests__/flags.test.ts +++ /dev/null @@ -1,213 +0,0 @@ -import { ComponentType as TYPES } from "@opensystemslab/planx-core/types"; - -import { useStore } from "../store"; - -const { getState, setState } = useStore; - -describe("in a flow with no collected flags, the user", () => { - beforeEach(() => { - getState().resetPreview(); - - setState({ - flow: { - _root: { - edges: ["question", "filter"], - }, - question: { - type: TYPES.Question, - edges: ["missing_info_answer", "immune_answer", "noflag_answer"], - }, - missing_info_answer: { - type: TYPES.Answer, - data: { - flag: "MISSING_INFO", - }, - }, - immune_answer: { - type: TYPES.Answer, - data: { - flag: "IMMUNE", - }, - }, - noflag_answer: { - type: TYPES.Answer, - }, - filter: { - type: TYPES.Filter, - edges: ["missing_info_flag", "immune_flag", "no_flag"], - }, - missing_info_flag: { - type: TYPES.Answer, - data: { - val: "MISSING_INFO", - }, - edges: ["missing_info_followup"], - }, - immune_flag: { - type: TYPES.Answer, - data: { - val: "IMMUNE", - }, - edges: ["immune_followup"], - }, - no_flag: { - type: TYPES.Answer, - edges: ["noflag_followup"], - }, - missing_info_followup: { type: TYPES.Content }, - immune_followup: { type: TYPES.Content }, - noflag_followup: { type: TYPES.Content }, - }, - }); - }); - - it.skip("will follow a path that doesn't require flags by default", () => { - expect(getState().upcomingCardIds()).toEqual(["question", "filter"]); - }); - - const scenarios: [string, string[], string][] = [ - ["missing_info_answer", ["MISSING_INFO"], "missing_info_followup"], - ["immune_answer", ["IMMUNE"], "immune_followup"], - ["noflag_answer", [], "noflag_followup"], - ]; - - scenarios.forEach(([answer, flags, followup]) => { - it(`but after visiting [${answer}], collects [${flags}] and the next question is [${followup}]`, () => { - getState().record("question", { answers: [answer] }); - expect(getState().collectedFlags("question")).toEqual(flags); - expect(getState().upcomingCardIds()).toEqual([followup]); - }); - }); -}); - -describe("changing flag inside flag filter doesn't affect the filter's behaviour", () => { - // https://imgur.com/kVeyr1t - beforeEach(() => { - getState().resetPreview(); - setState({ - flow: { - _root: { - edges: ["q1", "filter"], - }, - missing_info: { - data: { - val: "MISSING_INFO", - text: "Missing information", - }, - type: TYPES.Answer, - edges: ["missing_info_content"], - }, - q2: { - data: { - text: "another", - }, - type: TYPES.Question, - edges: ["missing_2", "nothing_2"], - }, - missing_info_content: { - data: { - content: "missing info", - }, - type: TYPES.Content, - }, - nothing_2: { - data: { - text: "nothing", - }, - type: TYPES.Answer, - }, - no_result: { - data: { - text: "(No Result)", - }, - type: TYPES.Answer, - edges: ["q2"], - }, - missing_2: { - data: { - flag: "MISSING_INFO", - text: "missing", - }, - type: TYPES.Answer, - }, - immune: { - data: { - val: "IMMUNE", - text: "Immune", - }, - type: TYPES.Answer, - }, - filter: { - data: { - fn: "flag", - }, - type: TYPES.Filter, - edges: ["missing_info", "immune", "no_result"], - }, - q1: { - type: TYPES.Question, - data: { - text: "q", - }, - edges: ["missing_1", "nothing_1"], - }, - missing_1: { - type: TYPES.Answer, - data: { - text: "missing", - flag: "MISSING_INFO", - }, - }, - nothing_1: { - type: TYPES.Answer, - data: { - text: "nothing", - }, - }, - }, - }); - }); - - test("nothing_1 > missing_2", () => { - getState().record("q1", { answers: ["nothing_1"] }); - getState().record("q2", { answers: ["missing_2"] }); - expect(getState().upcomingCardIds()).toEqual([]); - }); - - test("nothing_1 > nothing_2", () => { - getState().record("q1", { answers: ["nothing_1"] }); - getState().record("q2", { answers: ["nothing_2"] }); - expect(getState().upcomingCardIds()).toEqual([]); - }); - - test("missing_1", () => { - getState().record("q1", { answers: ["missing_1"] }); - expect(getState().upcomingCardIds()).toEqual(["missing_info_content"]); - }); -}); - -describe("displaying flags as result", () => { - beforeEach(() => getState().resetPreview()); - it("returns a reasonable default state", () => { - const defaultState = { - "Planning permission": { - displayText: { - description: "Planning permission", - heading: "No result", - }, - flag: { - bgColor: "#EEEEEE", - category: "Planning permission", - color: "#000000", - text: "No result", - value: undefined, - description: "", - }, - responses: [], - }, - }; - - const data = getState().resultData(); - expect(data).toEqual(defaultState); - }); -}); diff --git a/editor.planx.uk/src/pages/FlowEditor/lib/__tests__/mocks/flowWithAutoAnsweredFilterPaths.json b/editor.planx.uk/src/pages/FlowEditor/lib/__tests__/mocks/flowWithAutoAnsweredFilterPaths.json deleted file mode 100644 index 03e8507eac..0000000000 --- a/editor.planx.uk/src/pages/FlowEditor/lib/__tests__/mocks/flowWithAutoAnsweredFilterPaths.json +++ /dev/null @@ -1,248 +0,0 @@ -{ - "_root": { - "edges": [ - "zlKQyPuKsl", - "Ve90wVIXsV", - "TiIuAVIXsV", - "yYSKdVIXsV", - "rkqKFVIXsV" - ] - }, - "AwAJxVIXsV": { - "data": { - "val": "IMMUNE", - "text": "Immune" - }, - "type": 200 - }, - "CXV6wVIXsV": { - "data": { - "val": "PP-NOT_DEVELOPMENT", - "text": "Not development" - }, - "type": 200 - }, - "D79fwVIXsV": { - "data": { - "val": "PRIOR_APPROVAL", - "text": "Prior approval" - }, - "type": 200 - }, - "DtoqgVIXsV": { - "data": { - "val": "PP-NOTICE", - "text": "Notice" - }, - "type": 200 - }, - "GxTpoVIXsV": { - "data": { - "val": "MISSING_INFO", - "text": "Missing information" - }, - "type": 200 - }, - "O91ogVIXsV": { - "data": { - "val": "PLANNING_PERMISSION_REQUIRED", - "text": "Permission needed" - }, - "type": 200, - "edges": ["seN42VIXsV", "1ShlhWrXPl"] - }, - "OPOWoVIXsV": { - "data": { - "flag": "PLANNING_PERMISSION_REQUIRED", - "text": "Permission needed" - }, - "type": 200 - }, - "TiIuAVIXsV": { - "data": { - "text": "Pick another flag" - }, - "type": 100, - "edges": ["OPOWoVIXsV", "hdaeOVIXsV"] - }, - "Ve90wVIXsV": { - "data": { - "text": "Pick a flag" - }, - "type": 100, - "edges": ["d98AoVIXsV", "pghecgQLgs"] - }, - "d98AoVIXsV": { - "data": { - "flag": "PLANNING_PERMISSION_REQUIRED", - "text": "Permission needed", - "val": "permissionNeeded" - }, - "type": 200 - }, - "hdaeOVIXsV": { - "data": { - "flag": "NO_APP_REQUIRED", - "text": "Permitted dev" - }, - "type": 200 - }, - "qmEBoVIXsV": { - "data": { - "val": "NO_APP_REQUIRED", - "text": "Permitted development" - }, - "type": 200, - "edges": ["seN42VIXsV", "AaEuHnVUb4"] - }, - "rkqKFVIXsV": { - "data": { - "color": "#EFEFEF", - "title": "End of service!", - "resetButton": true - }, - "type": 8 - }, - "seN42VIXsV": { - "data": { - "flagSet": "Planning permission" - }, - "type": 3 - }, - "yG4jGVIXsV": { - "data": { - "text": "(No Result)" - }, - "type": 200 - }, - "yYSKdVIXsV": { - "data": { - "fn": "flag" - }, - "type": 500, - "edges": [ - "AwAJxVIXsV", - "GxTpoVIXsV", - "O91ogVIXsV", - "D79fwVIXsV", - "DtoqgVIXsV", - "qmEBoVIXsV", - "CXV6wVIXsV", - "yG4jGVIXsV" - ] - }, - "zlKQyPuKsl": { - "type": 100, - "data": { - "fn": "property.constraints.planning", - "text": "Is it a listed building?" - }, - "edges": ["8ONxTMoU0b", "qW1jzS1qPy"] - }, - "8ONxTMoU0b": { - "type": 200, - "data": { - "text": "Yes", - "val": "listed", - "flag": "LB-REQUIRED" - } - }, - "qW1jzS1qPy": { - "type": 200, - "data": { - "text": "No", - "flag": "LB-NOT_REQUIRED" - } - }, - "P9yPvn8JLs": { - "type": 250, - "data": { - "content": "

Listed and permitted dev path

" - } - }, - "1ShlhWrXPl": { - "type": 100, - "data": { - "fn": "property.constraints.planning", - "text": "Is any part of the property listed? (PN path)" - }, - "edges": ["gUNLEiv4yh", "tesCNavKYo"] - }, - "gUNLEiv4yh": { - "type": 200, - "data": { - "text": "Yes", - "val": "listed" - }, - "edges": ["sSL8kEJ558", "g8efNSpw8h"] - }, - "sSL8kEJ558": { - "type": 3, - "data": { - "flagSet": "Listed building consent", - "overrides": {} - } - }, - "tesCNavKYo": { - "type": 200, - "data": { - "text": "No" - }, - "edges": ["qSJ2jpVVXb"] - }, - "qSJ2jpVVXb": { - "data": { - "content": "

Not listed and permission needed path

" - }, - "type": 250 - }, - "AaEuHnVUb4": { - "type": 100, - "data": { - "fn": "property.constraints.planning", - "text": "Is any part of the property listed? (PD path)" - }, - "edges": ["hcYEJL5mTW", "WbVIceFvVw"] - }, - "hcYEJL5mTW": { - "type": 200, - "data": { - "text": "Yes", - "val": "listed" - }, - "edges": ["Qy3Okt81xb", "P9yPvn8JLs"] - }, - "Qy3Okt81xb": { - "type": 3, - "data": { - "flagSet": "Listed building consent" - } - }, - "WbVIceFvVw": { - "type": 200, - "data": { - "text": "No" - }, - "edges": ["xjcujhpzjs"] - }, - "xjcujhpzjs": { - "data": { - "content": "

Not listed and permitted dev path

" - }, - "type": 250 - }, - "pghecgQLgs": { - "type": 200, - "data": { - "text": "Permitted dev", - "val": "permittedDev", - "flag": "NO_APP_REQUIRED" - } - }, - "g8efNSpw8h": { - "type": 250, - "data": { - "content": "

Listed and permission needed path

" - } - } -} diff --git a/editor.planx.uk/src/pages/FlowEditor/lib/__tests__/mocks/flowWithBranchingFilters.json b/editor.planx.uk/src/pages/FlowEditor/lib/__tests__/mocks/flowWithBranchingFilters.json deleted file mode 100644 index 99982199d9..0000000000 --- a/editor.planx.uk/src/pages/FlowEditor/lib/__tests__/mocks/flowWithBranchingFilters.json +++ /dev/null @@ -1,115 +0,0 @@ -{ - "fork": { - "data": { - "text": "This is a question that forks" - }, - "type": 100, - "edges": ["noFilterPath", "filterPath"] - }, - "_root": { - "edges": ["pickFlag", "filter1", "fork", "finalNode"] - }, - "filter1": { - "data": { - "fn": "flag" - }, - "type": 500, - "edges": ["immunityFlag1", "noResultFlag1"] - }, - "filter2": { - "data": { - "fn": "flag" - }, - "type": 500, - "edges": ["immunityFlag2", "noResultFlag2"] - }, - "pickFlag": { - "data": { - "text": "Pick up a planning permission flag" - }, - "type": 100, - "edges": ["setImmunity"] - }, - "finalNode": { - "data": { - "color": "#EFEFEF", - "title": "End of the line!", - "resetButton": false - }, - "type": 8 - }, - "noResultPath1": { - "data": { - "content": "

No result path 1

" - }, - "type": 250 - }, - "filterPath": { - "data": { - "text": "Path with filter" - }, - "type": 200, - "edges": ["filter2"] - }, - "setImmunity": { - "data": { - "flag": "IMMUNE", - "text": "Immunity" - }, - "type": 200 - }, - "noFilterPath": { - "data": { - "text": "No filter path" - }, - "type": 200 - }, - "noResultPath2": { - "data": { - "content": "

No result path 2

" - }, - "type": 250 - }, - "immunityFlag1": { - "data": { - "val": "IMMUNE", - "text": "Immune" - }, - "type": 200, - "edges": ["immunityPath1"] - }, - "immunityFlag2": { - "data": { - "val": "IMMUNE", - "text": "Immune" - }, - "type": 200, - "edges": ["immunityPath2"] - }, - "immunityPath1": { - "data": { - "content": "

Immunity path 1

" - }, - "type": 250 - }, - "immunityPath2": { - "data": { - "content": "

Immunity path 2

" - }, - "type": 250 - }, - "noResultFlag1": { - "data": { - "text": "(No Result)" - }, - "type": 200, - "edges": ["noResultPath1"] - }, - "noResultFlag2": { - "data": { - "text": "(No Result)" - }, - "type": 200, - "edges": ["noResultPath2"] - } -} diff --git a/editor.planx.uk/src/pages/FlowEditor/lib/__tests__/mocks/flowWithRootFilter.json b/editor.planx.uk/src/pages/FlowEditor/lib/__tests__/mocks/flowWithRootFilter.json deleted file mode 100644 index c4e75e0b40..0000000000 --- a/editor.planx.uk/src/pages/FlowEditor/lib/__tests__/mocks/flowWithRootFilter.json +++ /dev/null @@ -1,116 +0,0 @@ -{ - "_root": { - "edges": ["d5SxIWZej9", "LAz2YqYChs", "nroxFPM2Jx"] - }, - "LAz2YqYChs": { - "type": 500, - "data": { - "fn": "flag" - }, - "edges": [ - "IK6gNsf8iF", - "gOLt5Yd4Fy", - "wgWEaXVfBt", - "QKSqXyhvQW", - "AM6b72H0aV", - "o3H1U1k6v6", - "VkqLPBX1mQ", - "udy3cmVDMh" - ] - }, - "IK6gNsf8iF": { - "type": 200, - "data": { - "text": "Immune", - "val": "IMMUNE" - }, - "edges": ["TmpbJgjGPH"] - }, - "gOLt5Yd4Fy": { - "type": 200, - "data": { - "text": "Missing information", - "val": "MISSING_INFO" - } - }, - "wgWEaXVfBt": { - "type": 200, - "data": { - "text": "Permission needed", - "val": "PLANNING_PERMISSION_REQUIRED" - } - }, - "QKSqXyhvQW": { - "type": 200, - "data": { - "text": "Prior approval", - "val": "PRIOR_APPROVAL" - } - }, - "AM6b72H0aV": { - "type": 200, - "data": { - "text": "Notice", - "val": "PP-NOTICE" - } - }, - "o3H1U1k6v6": { - "type": 200, - "data": { - "text": "Permitted development", - "val": "NO_APP_REQUIRED" - } - }, - "VkqLPBX1mQ": { - "type": 200, - "data": { - "text": "Not development", - "val": "PP-NOT_DEVELOPMENT" - } - }, - "udy3cmVDMh": { - "type": 200, - "data": { - "text": "(No Result)" - }, - "edges": ["lOrm4XmVGv"] - }, - "d5SxIWZej9": { - "type": 100, - "data": { - "text": "is this project immune?" - }, - "edges": ["FZ1kmhT37j", "ZTZqcDAOoG"] - }, - "FZ1kmhT37j": { - "type": 200, - "data": { - "text": "yes", - "flag": "IMMUNE" - } - }, - "ZTZqcDAOoG": { - "type": 200, - "data": { - "text": "no" - } - }, - "TmpbJgjGPH": { - "type": 250, - "data": { - "content": "

this project is immune

\n" - } - }, - "lOrm4XmVGv": { - "type": 250, - "data": { - "content": "

this project is not immune

\n" - } - }, - "nroxFPM2Jx": { - "type": 250, - "data": { - "content": "

last thing

\n" - } - } -} diff --git a/editor.planx.uk/src/pages/FlowEditor/lib/__tests__/ordering.test.ts b/editor.planx.uk/src/pages/FlowEditor/lib/__tests__/ordering.test.ts deleted file mode 100644 index 0a0d7db64a..0000000000 --- a/editor.planx.uk/src/pages/FlowEditor/lib/__tests__/ordering.test.ts +++ /dev/null @@ -1,173 +0,0 @@ -import { ComponentType as TYPES } from "@opensystemslab/planx-core/types"; - -import { Store, useStore } from "../store"; - -const { getState, setState } = useStore; - -const flow: Store.Flow = { - _root: { - edges: ["QuestionTrolley", "ChecklistTrolley"], - }, - ChecklistTrolley: { - type: TYPES.Checklist, - data: { - allRequired: false, - text: "shopping trolley 2", - fn: "item", - }, - edges: ["AppleChecklistResponse", "BananaChecklistResponse"], - }, - AppleChecklistResponse: { - data: { - text: "apple", - val: "food.fruit.apple", - }, - type: TYPES.Answer, - edges: [ - "AppleQuestionWithoutFn", - "AutoAnsweredBananaQuestion", - "FinalContent", - ], - }, - BananaChecklistResponse: { - data: { - text: "banana", - val: "food.fruit.banana", - }, - type: TYPES.Answer, - }, - AutoAnsweredBananaQuestion: { - type: TYPES.Question, - data: { - text: "did you choose the banana?", - fn: "item", - }, - edges: ["YesBanana", "NoBanana"], - }, - YesBanana: { - type: TYPES.Answer, - data: { - text: "yes", - val: "food.fruit.banana", - }, - }, - NoBanana: { - type: TYPES.Answer, - data: { - text: "no", - }, - edges: ["BananaQuestionWithoutFn"], - }, - FinalContent: { - type: TYPES.Content, - data: { - content: "

last thing

\n", - }, - }, - BananaQuestionWithoutFn: { - type: TYPES.Question, - data: { - text: "will you be eating the banana today?", - }, - edges: ["YesEatingBanana", "NoEatingBanana"], - }, - YesEatingBanana: { - type: TYPES.Answer, - data: { - text: "yes", - }, - }, - NoEatingBanana: { - type: TYPES.Answer, - data: { - text: "no", - }, - }, - AppleQuestionWithoutFn: { - type: TYPES.Question, - data: { - text: "you chose apple", - }, - edges: ["YesApple"], - }, - YesApple: { - type: TYPES.Answer, - data: { - text: "i did", - }, - }, - QuestionTrolley: { - type: TYPES.Question, - data: { - fn: "item", - text: "shopping trolley 1", - }, - edges: ["AppleQuestionResponse", "BananaQuestionResponse"], - }, - AppleQuestionResponse: { - type: TYPES.Answer, - data: { - text: "apple", - val: "food.fruit.apple", - }, - }, - BananaQuestionResponse: { - type: TYPES.Answer, - data: { - text: "banana", - val: "food.fruit.banana", - }, - }, -}; - -beforeEach(() => { - getState().resetPreview(); -}); - -test("Nodes are asked in the expected order", () => { - setState({ - flow, - }); - - // Root nodes are immediately queued up in upcomingCardIds() - expect(getState().upcomingCardIds()).toEqual([ - "QuestionTrolley", - "ChecklistTrolley", - ]); - - // Proceed through first Question and answer "Apple" - getState().record("QuestionTrolley", { answers: ["AppleQuestionResponse"] }); - getState().upcomingCardIds(); // mimic "Continue" - - // New upcoming cards - expect(getState().upcomingCardIds()).toEqual([ - "AppleQuestionWithoutFn", - "BananaQuestionWithoutFn", - "FinalContent", - ]); - - // Two nodes have been auto-answered based on Question response - expect(getState().breadcrumbs).toEqual({ - AutoAnsweredBananaQuestion: { answers: ["NoBanana"], auto: true }, - ChecklistTrolley: { answers: ["AppleChecklistResponse"], auto: true }, - QuestionTrolley: { answers: ["AppleQuestionResponse"], auto: false }, - }); - - // Manually answer a branched Question - getState().record("AppleQuestionWithoutFn", { answers: ["YesApple"] }); - getState().upcomingCardIds(); - - // Updated upcoming cards - expect(getState().upcomingCardIds()).toEqual([ - "BananaQuestionWithoutFn", - "FinalContent", - ]); - - // Updated breadcrumbs - expect(getState().breadcrumbs).toEqual({ - AppleQuestionWithoutFn: { answers: ["YesApple"], auto: false }, - QuestionTrolley: { answers: ["AppleQuestionResponse"], auto: false }, - ChecklistTrolley: { answers: ["AppleChecklistResponse"], auto: true }, - AutoAnsweredBananaQuestion: { answers: ["NoBanana"], auto: true }, - }); -}); diff --git a/editor.planx.uk/src/pages/FlowEditor/lib/__tests__/preview/autoAnswerableFlag.test.ts b/editor.planx.uk/src/pages/FlowEditor/lib/__tests__/preview/autoAnswerableFlag.test.ts new file mode 100644 index 0000000000..fbb019a06c --- /dev/null +++ b/editor.planx.uk/src/pages/FlowEditor/lib/__tests__/preview/autoAnswerableFlag.test.ts @@ -0,0 +1,78 @@ +import { Store, useStore } from "../../store"; + +const { getState, setState } = useStore; +const { resetPreview, autoAnswerableFlag } = getState(); + +// Additionally see src/pages/FlowEditor/lib/filters.test.ts for positive autoAnswerableFlag test cases + +describe("Returns undefined and does not auto-answer any flag paths", () => { + beforeEach(() => { + resetPreview(); + }); + + test("If the node is not a Filter type", () => { + setState({ + flow: { + "_root": { "edges": ["SetValue"] }, + "SetValue": { "type": 380, "data": { "fn": "projectType", "val": "alter", "operation": "replace" } }, + } + }); + + expect(autoAnswerableFlag("SetValue")).not.toBeDefined(); + }); + + test("If the node does not set a `fn`", () => { + const alteredFlow = structuredClone(flowWithFilter); + delete alteredFlow["Filter"].data?.fn; + setState({ flow: alteredFlow }); + + expect(autoAnswerableFlag("Filter")).not.toBeDefined(); + }); + + test("If the node does not have any flag paths (aka options)"); + const alteredFlow = structuredClone(flowWithFilter); + delete alteredFlow["Filter"].edges; + setState({ flow: alteredFlow }); + + expect(autoAnswerableFlag("Filter")).not.toBeDefined(); +}); + +const flowWithFilter: Store.Flow = { + "_root": { + "edges": [ + "Filter" + ] + }, + "Filter": { + "type": 500, + "data": { + "fn": "flag", + "category": "Material change of use" + }, + "edges": [ + "Flag1", + "Flag2", + "Flag3" + ] + }, + "Flag1": { + "type": 200, + "data": { + "text": "Material change of use", + "val": "MCOU_TRUE" + } + }, + "Flag2": { + "type": 200, + "data": { + "text": "Not material change of use", + "val": "MCOU_FALSE" + } + }, + "Flag3": { + "type": 200, + "data": { + "text": "No flag result" + } + } +}; diff --git a/editor.planx.uk/src/pages/FlowEditor/lib/__tests__/preview/autoAnswerableOptions.test.ts b/editor.planx.uk/src/pages/FlowEditor/lib/__tests__/preview/autoAnswerableOptions.test.ts new file mode 100644 index 0000000000..87d2e1d397 --- /dev/null +++ b/editor.planx.uk/src/pages/FlowEditor/lib/__tests__/preview/autoAnswerableOptions.test.ts @@ -0,0 +1,388 @@ +import { Store, useStore } from "../../store"; +import { clickContinue } from "../utils"; + +const { getState, setState } = useStore; +const { resetPreview, autoAnswerableOptions, computePassport } = getState(); + +// Find additional auto-answering tests at: +// - src/pages/FlowEditor/lib/automations.blanks.test.ts +// - src/pages/FlowEditor/lib/automations.parentChild.test.ts +// - src/pages/FlowEditor/lib/automations.planningConstraintNots.test.ts +// - src/pages/FlowEditor/lib/automations.setValue.test.ts + +describe("Returns undefined and does not auto-answer any options", () => { + beforeEach(() => { + resetPreview(); + }); + + test("If the node is not a Question or Checklist type", () => { + setState({ + flow: { + "_root": { "edges": ["SetValue"] }, + "SetValue": { "type": 380, "data": { "fn": "projectType", "val": "alter", "operation": "replace" } }, + }, + }); + + expect(autoAnswerableOptions("SetValue")).not.toBeDefined(); + }); + + test("If the node is a 'sticky note' Question without edges", () => { + const alteredFlow = structuredClone(singleNodeFlow); + delete alteredFlow["Question"]?.edges; + setState({ flow: alteredFlow }); + + expect(autoAnswerableOptions("Question")).not.toBeDefined(); + }); + + test("If the node does not set a `fn`", () => { + const alteredFlow = structuredClone(singleNodeFlow); + delete alteredFlow["Question"]?.data?.fn; + setState({ flow: alteredFlow }); + + expect(autoAnswerableOptions("Question")).not.toBeDefined(); + }); + + test("If we've never seen another node with this `fn` before", () => { + setState({ + flow: singleNodeFlow, + breadcrumbs: {} + }); + + expect(autoAnswerableOptions("Question")).not.toBeDefined(); + }); +}); + +describe("Questions and Checklists", () => { + beforeEach(() => { + resetPreview(); + setState({ flow }); + }); + + test("Auto-answer the option that exactly matches a passport value", () => { + clickContinue("InitialChecklistFood", { answers: ["InitialChecklistOptionFruit"], auto: false }); + expect(computePassport()?.data).toEqual({ "foods": ["fruit"] }); + + expect(autoAnswerableOptions("QuestionFruit")).toEqual(["QuestionFruitYesOption"]); + }); + + test("Auto-answer the less granular option when there's a single more granular passport value and no more granular options available", () => { + clickContinue("InitialChecklistFood", { answers: ["InitialChecklistOptionFruit"], auto: false }); + expect(computePassport()?.data).toEqual({ "foods": ["fruit"] }); + + // Answer a followup question and confirm passport only stores most granular value + clickContinue("QuestionFruitType", { answers: ["OptionFruitTypeRedGrapes"], auto: false }); + expect(computePassport()?.data).toEqual({ "foods": ["fruit.grapes.red"] }); + + expect(autoAnswerableOptions("QuestionFruitTypeLessGranular")).toEqual(["OptionFruitTypeLessGranularGrapes"]); + }); + + test("Puts to user when we have seen this node `fn`, we do not have passport vals, and we have NOT seen all possible option `val`", () => { + clickContinue("InitialChecklistFood", { answers: ["InitialChecklistOptionBlank"], auto: false }); + expect(computePassport()?.data).toEqual({}); + + expect(autoAnswerableOptions("QuestionChocolate")).toBeUndefined(); + }); + + test("Auto-answers when we have seen this node `fn`, we have passport vals but not matching ones, and we have NOT seen all possible option `val`", () => { + clickContinue("InitialChecklistFood", { answers: ["InitialChecklistOptionFruit"], auto: false }); + expect(computePassport()?.data).toEqual({ "foods": ["fruit"] }); + + expect(autoAnswerableOptions("QuestionChocolate")).toEqual(["QuestionChocolateOptionBlank"]); + }); + + test.todo("Auto-answer the single most granular, left-most option when there are many matching passport values"); +}); + +const flow: Store.Flow = { + "_root": { + "edges": [ + "InitialChecklistFood", + "QuestionChocolate", + "QuestionFruit", + "QuestionRyeBread", + "LastChecklistFood" + ] + }, + "OptionFruitTypeGreenGrapes": { + "data": { + "val": "fruit.grapes.green", + "text": "Green grapes" + }, + "type": 200 + }, + "QuestionChocolateOptionYes": { + "data": { + "val": "chocolate", + "text": "Yes" + }, + "type": 200 + }, + "LastChecklistOptionBread": { + "data": { + "val": "bread", + "text": "Bread" + }, + "type": 200 + }, + "InitialChecklistOptionBread": { + "data": { + "val": "bread", + "text": "Bread" + }, + "type": 200, + "edges": [ + "QuestionBreadType" + ] + }, + "QuestionBreadType": { + "data": { + "fn": "foods", + "tags": [], + "text": "Which bread?", + "neverAutoAnswer": false + }, + "type": 100, + "edges": [ + "OptionBreadTypeBagel", + "OptionBreadTypeSourdough" + ] + }, + "OptionBreadTypeSourdough": { + "data": { + "val": "bread.sourdough", + "text": "Sourdough" + }, + "type": 200 + }, + "OptionFruitTypeRedGrapes": { + "data": { + "val": "fruit.grapes.red", + "text": "Red grapes" + }, + "type": 200 + }, + "OptionFruitTypeLessGranularBlank": { + "data": { + "text": "Another kind of fruit" + }, + "type": 200 + }, + "OptionBreadTypeBagel": { + "data": { + "val": "bread.bagel", + "text": "Bagel" + }, + "type": 200 + }, + "LastChecklistOptionBlank": { + "data": { + "text": "None" + }, + "type": 200 + }, + "QuestionRyeBread": { + "data": { + "fn": "foods", + "text": "Do you have rye bread?" + }, + "type": 100, + "edges": [ + "OptionRyeBreadYes", + "OptionRyeBreadBlank" + ] + }, + "QuestionFruit": { + "data": { + "fn": "foods", + "text": "Do you have fruit?" + }, + "type": 100, + "edges": [ + "QuestionFruitYesOption", + "QuestionFruitBlankOption" + ] + }, + "LastChecklistOptionFruit": { + "data": { + "val": "fruit", + "text": "Fruit" + }, + "type": 200 + }, + "OptionRyeBreadBlank": { + "data": { + "text": "No" + }, + "type": 200 + }, + "QuestionFruitYesOption": { + "data": { + "val": "fruit", + "text": "Yes" + }, + "type": 200, + "edges": [ + "QuestionFruitTypeLessGranular" + ] + }, + "InitialChecklistFood": { + "data": { + "fn": "foods", + "tags": [], + "text": "Which foods do you want?", + "allRequired": false, + "neverAutoAnswer": false + }, + "type": 105, + "edges": [ + "InitialChecklistOptionFruit", + "InitialChecklistOptionBread", + "InitialChecklistOptionBlank" + ] + }, + "OptionRyeBreadYes": { + "data": { + "val": "bread.rye", + "text": "Yes" + }, + "type": 200 + }, + "OptionFruitTypeBanana": { + "data": { + "val": "fruit.bananas", + "text": "Bananas" + }, + "type": 200 + }, + "QuestionChocolateOptionBlank": { + "data": { + "text": "No" + }, + "type": 200 + }, + "LastChecklistFood": { + "data": { + "fn": "foods", + "tags": [], + "text": "Which do you have?", + "allRequired": false + }, + "type": 105, + "edges": [ + "LastChecklistOptionFruit", + "LastChecklistOptionBread", + "LastChecklistOptionChocolate", + "LastChecklistOptionBlank" + ] + }, + "QuestionChocolate": { + "data": { + "fn": "foods", + "tags": [], + "text": "Do you have chocolate?", + "neverAutoAnswer": false + }, + "type": 100, + "edges": [ + "QuestionChocolateOptionYes", + "QuestionChocolateOptionBlank" + ] + }, + "LastChecklistOptionChocolate": { + "data": { + "val": "chocolate", + "text": "Chocolate" + }, + "type": 200 + }, + "QuestionFruitBlankOption": { + "data": { + "text": "No" + }, + "type": 200 + }, + "InitialChecklistOptionFruit": { + "data": { + "val": "fruit", + "text": "Fruit" + }, + "type": 200, + "edges": [ + "QuestionFruitType" + ] + }, + "OptionFruitTypeLessGranularGrapes": { + "data": { + "val": "fruit.grapes", + "text": "Grapes" + }, + "type": 200 + }, + "QuestionFruitTypeLessGranular": { + "data": { + "fn": "foods", + "tags": [], + "text": "Which kind of fruit do you have?", + "neverAutoAnswer": false + }, + "type": 100, + "edges": [ + "OptionFruitTypeLessGranularGrapes", + "OptionFruitTypeLessGranularBlank" + ] + }, + "InitialChecklistOptionBlank": { + "data": { + "text": "None of these" + }, + "type": 200 + }, + "QuestionFruitType": { + "data": { + "fn": "foods", + "tags": [], + "text": "Which fruit?", + "neverAutoAnswer": false + }, + "type": 100, + "edges": [ + "OptionFruitTypeBanana", + "OptionFruitTypeRedGrapes", + "OptionFruitTypeGreenGrapes" + ] + } +}; + +const singleNodeFlow: Store.Flow = { + "_root": { + "edges": [ + "Question" + ] + }, + "Question": { + "type": 100, + "data": { + "fn": "direction", + "text": "Which direction?", + "neverAutoAnswer": false + }, + "edges": [ + "Option1", + "Option2" + ] + }, + "Option1": { + "type": 200, + "data": { + "text": "Left", + "val": "left" + } + }, + "Option2": { + "type": 200, + "data": { + "text": "Right", + "val": "right" + } + } +}; \ No newline at end of file diff --git a/editor.planx.uk/src/pages/FlowEditor/lib/__tests__/preview/upcomingCardIds.test.ts b/editor.planx.uk/src/pages/FlowEditor/lib/__tests__/preview/upcomingCardIds.test.ts index ef25f2fb86..932d5af014 100644 --- a/editor.planx.uk/src/pages/FlowEditor/lib/__tests__/preview/upcomingCardIds.test.ts +++ b/editor.planx.uk/src/pages/FlowEditor/lib/__tests__/preview/upcomingCardIds.test.ts @@ -1,151 +1,230 @@ -import { ComponentType as TYPES } from "@opensystemslab/planx-core/types"; - import { Store, useStore } from "../../store"; +import { clickContinue, visitedNodes } from "../utils"; const { getState, setState } = useStore; -const { upcomingCardIds, resetPreview, record, getCurrentCard } = getState(); - -const flow: Store.Flow = { - _root: { - edges: ["SetValue", "Content", "AutomatedQuestion"], - }, - ResponseApple: { - data: { - val: "apple", - text: "Apple", - }, - type: TYPES.Answer, - }, - ResponsePear: { - data: { - val: "pear", - text: "Pear", - }, - type: TYPES.Answer, - }, - SetValue: { - data: { - fn: "fruit", - val: "apple", - }, - type: TYPES.SetValue, - }, - AutomatedQuestion: { - data: { - fn: "fruit", - text: "Which fruit?", - }, - type: TYPES.Question, - edges: ["ResponseApple", "ResponsePear"], - }, - Content: { - data: { - content: "

Pause

", - }, - type: TYPES.Content, - }, -}; +const { upcomingCardIds, resetPreview } = getState(); beforeEach(() => { resetPreview(); - setState({ flow }); }); -test("Root nodes are immediately queued up", () => { +test("Root nodes are always queued up", () => { + setState({ flow: flowWithoutPortal }); + expect(upcomingCardIds()).toEqual([ - "SetValue", - "Content", - "AutomatedQuestion", + "StartContent", + "FruitChecklist", + "EndNotice", ]); }); -test.skip("A node is only auto-answered when it is the first upcomingCardId(), not when its' `fn` is first added to the breadcrumbs/passport", () => { - const visitedNodes = () => Object.keys(getState().breadcrumbs); +test("The children of selected answers are queued up", () => { + setState({ flow: flowWithoutPortal }); + + // Step through first Content node + expect(upcomingCardIds()?.[0]).toEqual("StartContent"); + clickContinue("StartContent", { auto: false }); + expect(visitedNodes()?.[0]).toEqual("StartContent"); - // mimic "Continue" button and properly set visitedNodes() - const clickContinue = () => upcomingCardIds(); + // Select two of three options in Checklist + expect(upcomingCardIds()?.[0]).toEqual("FruitChecklist"); + clickContinue("FruitChecklist", { + answers: ["AppleOption", "BananaOption"], + auto: false, + }); + expect(visitedNodes()).toEqual(["StartContent", "FruitChecklist"]); + // Only nodes on selected branches plus the root have been queued up expect(upcomingCardIds()).toEqual([ - "SetValue", - "Content", - "AutomatedQuestion", + "AppleFollowup", + "BananaFollowup", + "EndNotice", ]); + expect(upcomingCardIds()).not.toContain("OrangeFollowup"); +}); - // Step forwards through the SetValue - record("SetValue", { data: { fruit: ["apple"] }, auto: true }); - clickContinue(); - - expect(getCurrentCard()?.id).toBe("Content"); - - // "AutomatedQuestion" should still be queued up, not already answered based on SetValue - expect(visitedNodes()).not.toContain("AutomatedQuestion"); - expect(upcomingCardIds()).toContain("AutomatedQuestion"); - - // Step forwards through Content - record("Content", { data: {}, auto: false }); - clickContinue(); +test("Root nodes nested within internal portals are queued up", () => { + setState({ flow: flowWithInternalPortal }); - // "AutomatedQuestion" has now been auto-answered now, end of flow - expect(visitedNodes()).toContain("AutomatedQuestion"); - expect(upcomingCardIds()).toEqual([]); + expect(upcomingCardIds()).toEqual([ + "StartContent", + "FruitChecklistInPortal", + "EndNotice", + ]); + expect(upcomingCardIds()).not.toContain("InternalPortal"); }); -test("it lists upcoming cards", () => { - setState({ - flow: { - _root: { - edges: ["a", "b"], - }, - a: { - type: TYPES.Question, - edges: ["c"], - }, - b: { - type: TYPES.Question, - }, - c: { - type: TYPES.Answer, - edges: ["d"], - }, - d: { - type: TYPES.Question, - edges: ["e", "f"], - }, - e: { type: TYPES.Answer }, - f: { type: TYPES.Answer }, - }, - }); +test("The children of selected answers within an internal portal are queued up", () => { + setState({ flow: flowWithInternalPortal }); - expect(upcomingCardIds()).toEqual(["a"]); + // Step through first Content node + expect(upcomingCardIds()?.[0]).toEqual("StartContent"); + clickContinue("StartContent", { auto: false }); + expect(visitedNodes()?.[0]).toEqual("StartContent"); - record("a", { answers: ["c"] }); + // Select two of three options in Checklist inside of portal + expect(upcomingCardIds()?.[0]).toEqual("FruitChecklistInPortal"); + clickContinue("FruitChecklistInPortal", { + answers: ["AppleOption", "BananaOption"], + auto: false, + }); + expect(visitedNodes()).toEqual(["StartContent", "FruitChecklistInPortal"]); - expect(upcomingCardIds()).toEqual(["d"]); + // Only nodes on selected branches plus the root have been queued up + expect(upcomingCardIds()).toEqual([ + "AppleFollowup", + "BananaFollowup", + "EndNotice", + ]); + expect(upcomingCardIds()).not.toContain("OrangeFollowup"); - record("d", { answers: ["e", "f"] }); + // Step through followup Contents within portal and navigate back into main flow + clickContinue("AppleFollowup", { auto: false }); + clickContinue("BananaFollowup", { auto: false }); + expect(upcomingCardIds()?.[0]).toEqual("EndNotice"); - expect(upcomingCardIds()).toEqual([]); + // There should be no remaining upcoming cards after final Notice + clickContinue("EndNotice", { auto: false }); + expect(upcomingCardIds()).toHaveLength(0); }); -test("crawling with portals", () => { - setState({ - flow: { - _root: { - edges: ["a", "b"], - }, - a: { - type: TYPES.InternalPortal, - edges: ["c"], - }, - b: { - edges: ["d"], - }, - c: { - edges: ["d"], - }, - d: {}, +const flowWithoutPortal: Store.Flow = { + _root: { + edges: ["StartContent", "FruitChecklist", "EndNotice"], + }, + BananaOption: { + data: { + text: "Banana", }, - }); + type: 200, + edges: ["BananaFollowup"], + }, + AppleOption: { + data: { + text: "Apple", + }, + type: 200, + edges: ["AppleFollowup"], + }, + EndNotice: { + data: { + color: "#EFEFEF", + title: "End of test", + resetButton: true, + }, + type: 8, + }, + FruitChecklist: { + data: { + text: "Which fruit do you want to eat?", + allRequired: false, + }, + type: 105, + edges: ["AppleOption", "OrangeOption", "BananaOption"], + }, + OrangeOption: { + data: { + text: "Orange", + }, + type: 200, + edges: ["OrangeFollowup"], + }, + OrangeFollowup: { + data: { + content: "

Selected orange

", + }, + type: 250, + }, + StartContent: { + data: { + content: "

Welcome to this test flow

", + }, + type: 250, + }, + BananaFollowup: { + data: { + content: "

Selected banana

", + }, + type: 250, + }, + AppleFollowup: { + data: { + content: "

Selected apple

", + }, + type: 250, + }, +}; - expect(upcomingCardIds()).toEqual(["c", "b"]); -}); +const flowWithInternalPortal: Store.Flow = { + _root: { + edges: ["StartContent", "InternalPortal", "EndNotice"], + }, + EndNotice: { + data: { + color: "#EFEFEF", + title: "End of test", + resetButton: true, + }, + type: 8, + }, + StartContent: { + data: { + content: "

Welcome to this test flow

", + }, + type: 250, + }, + InternalPortal: { + type: 300, + data: { + text: "Folder", + }, + edges: ["FruitChecklistInPortal"], + }, + FruitChecklistInPortal: { + data: { + text: "Which fruit do you want to eat?", + allRequired: false, + }, + type: 105, + edges: ["AppleOption", "OrangeOption", "BananaOption"], + }, + AppleOption: { + data: { + text: "Apple", + }, + type: 200, + edges: ["AppleFollowup"], + }, + AppleFollowup: { + data: { + content: "

Selected apple

", + }, + type: 250, + }, + OrangeOption: { + data: { + text: "Orange", + }, + type: 200, + edges: ["OrangeFollowup"], + }, + OrangeFollowup: { + data: { + content: "

Selected orange

", + }, + type: 250, + }, + BananaOption: { + data: { + text: "Banana", + }, + type: 200, + edges: ["BananaFollowup"], + }, + BananaFollowup: { + data: { + content: "

Selected banana

", + }, + type: 250, + }, +}; diff --git a/editor.planx.uk/src/pages/FlowEditor/lib/__tests__/unseen.test.ts b/editor.planx.uk/src/pages/FlowEditor/lib/__tests__/unseen.test.ts deleted file mode 100644 index b8bb32b230..0000000000 --- a/editor.planx.uk/src/pages/FlowEditor/lib/__tests__/unseen.test.ts +++ /dev/null @@ -1,88 +0,0 @@ -import { Store, useStore } from "../store"; - -const { getState, setState } = useStore; - -// https://github.com/theopensystemslab/planx-new/pull/430#issue-625111571 -const flow: Store.Flow = { - _root: { - edges: ["Dq7qLvn9If", "GZAmDGuV3J"], - }, - Dq7qLvn9If: { - type: 100, - data: { - fn: "test", - text: "first", - }, - edges: ["to4BQeRpOn", "6CTQDoPZPQ", "gxrGcPJCqi"], - }, - to4BQeRpOn: { - type: 200, - data: { - text: "1", - val: "1", - }, - }, - "6CTQDoPZPQ": { - type: 200, - data: { - text: "2", - val: "2", - }, - }, - GZAmDGuV3J: { - type: 100, - data: { - fn: "test", - text: "second", - }, - edges: ["R4N2rp5nXt", "tX0BQy3QcA"], - }, - R4N2rp5nXt: { - type: 200, - data: { - text: "1", - val: "1", - }, - }, - tX0BQy3QcA: { - type: 200, - data: { - text: "empty", - }, - edges: ["pws2AF5whV"], - }, - gxrGcPJCqi: { - type: 200, - data: { - text: "empty", - }, - }, - pws2AF5whV: { - type: 100, - data: { - fn: "test", - text: "inner", - }, - edges: ["aH3SQ3Agsi", "WRSytUiGsr"], - }, - aH3SQ3Agsi: { - type: 200, - data: { - text: "1", - val: "1", - }, - }, - WRSytUiGsr: { - type: 200, - data: { - text: "unseen", - val: "unseen", - }, - }, -}; - -it("always shows a question when has a response(value) that hasn't been seen before", () => { - setState({ flow }); - getState().record("Dq7qLvn9If", { answers: ["6CTQDoPZPQ"] }); - expect(getState().upcomingCardIds()).toEqual(["pws2AF5whV"]); -}); diff --git a/editor.planx.uk/src/pages/FlowEditor/lib/__tests__/useNotValues.test.ts b/editor.planx.uk/src/pages/FlowEditor/lib/__tests__/useNotValues.test.ts deleted file mode 100644 index 7de640f186..0000000000 --- a/editor.planx.uk/src/pages/FlowEditor/lib/__tests__/useNotValues.test.ts +++ /dev/null @@ -1,157 +0,0 @@ -import { Store, useStore } from "../store"; - -// flow preview: https://i.imgur.com/nCov5CE.png - -const flow: Store.Flow = { - _root: { - edges: ["NS7QFc7Cjc", "3cNtq1pLmt", "eTBHJsbJKc"], - }, - "3cNtq1pLmt": { - data: { - fn: "animal", - text: "is it a lion?", - }, - type: 100, - edges: ["TDIbLrdTdd", "TnvmCtle0s"], - }, - BecasKrIhI: { - data: { - text: "neither", - }, - type: 200, - }, - NS7QFc7Cjc: { - data: { - fn: "animal", - text: "which wild cat is it?", - }, - type: 100, - edges: ["sv0hklWPX1", "UqZo0rGwcY", "BecasKrIhI"], - }, - Nrf7BHDJvO: { - data: { - text: "neither", - }, - type: 200, - edges: ["UOefNWg6uf"], - }, - Sd38UCC8Cg: { - data: { - content: "

it's a lion

\n", - }, - type: 250, - }, - TDIbLrdTdd: { - data: { - val: "lion", - text: "yes", - }, - type: 200, - }, - TnvmCtle0s: { - data: { - text: "no", - }, - type: 200, - }, - UOefNWg6uf: { - data: { - content: "

it's a tiger or something else

\n", - }, - type: 250, - }, - UqZo0rGwcY: { - data: { - val: "tiger", - text: "tiger", - }, - type: 200, - }, - eOoDvdKjWf: { - data: { - val: "lion", - text: "lion", - }, - type: 200, - edges: ["Sd38UCC8Cg"], - }, - eTBHJsbJKc: { - data: { - fn: "animal", - text: "ok, so which animal is it?", - }, - type: 100, - edges: ["eOoDvdKjWf", "nR15Tl0lhC", "Nrf7BHDJvO"], - }, - nR15Tl0lhC: { - data: { - val: "gazelle", - text: "gazelle", - }, - type: 200, - edges: ["pqZK1mpn23"], - }, - pqZK1mpn23: { - data: { - content: "

it's a gazelle

\n", - }, - type: 250, - }, - sv0hklWPX1: { - data: { - val: "lion", - text: "lion", - }, - type: 200, - }, -}; - -const { getState, setState } = useStore; - -describe("if I initially pick", () => { - beforeEach(() => { - getState().resetPreview(); - setState({ flow }); - }); - - test("lion, it should display 'lion'", () => { - getState().record("NS7QFc7Cjc", { answers: ["TDIbLrdTdd"] }); - expect(getState().upcomingCardIds()).toEqual(["Sd38UCC8Cg"]); - }); - - test("tiger, it should display 'tiger or something else'", () => { - getState().record("NS7QFc7Cjc", { answers: ["UqZo0rGwcY"] }); - expect(getState().upcomingCardIds()).toEqual(["UOefNWg6uf"]); - }); - - test("gazelle, it should ask which animal it is", () => { - getState().record("NS7QFc7Cjc", { answers: ["BecasKrIhI"] }); - expect(getState().upcomingCardIds()).toEqual(["eTBHJsbJKc"]); - getState().record("eTBHJsbJKc", { answers: ["nR15Tl0lhC"] }); - expect(getState().upcomingCardIds()).toEqual(["pqZK1mpn23"]); - }); -}); - -test("back button works as expected", () => { - getState().resetPreview(); - setState({ - flow, - breadcrumbs: { - NS7QFc7Cjc: { - answers: ["BecasKrIhI"], - auto: false, - }, - "3cNtq1pLmt": { - answers: ["TnvmCtle0s"], - auto: true, - }, - eTBHJsbJKc: { - answers: ["nR15Tl0lhC"], - auto: false, - }, - }, - }); - - getState().record("eTBHJsbJKc"); - expect(getState().upcomingCardIds()).toEqual(["eTBHJsbJKc"]); -}); diff --git a/editor.planx.uk/src/pages/FlowEditor/lib/__tests__/utils.ts b/editor.planx.uk/src/pages/FlowEditor/lib/__tests__/utils.ts new file mode 100644 index 0000000000..13b421c3da --- /dev/null +++ b/editor.planx.uk/src/pages/FlowEditor/lib/__tests__/utils.ts @@ -0,0 +1,18 @@ +import { NodeId } from "@opensystemslab/planx-core/types"; +import { Store, useStore } from "../store"; + +const { getState } = useStore; +const { upcomingCardIds, record } = getState(); + +/** + * @returns List of nodes ids that have been visited (seen or automated) + */ +export const visitedNodes = () => Object.keys(getState().breadcrumbs); + +/** + * Mimic clicking "Continue" button on a card and submitting user data + */ +export const clickContinue = (nodeId: NodeId, userData: Store.UserData) => { + record(nodeId, userData); + upcomingCardIds(); +}; diff --git a/editor.planx.uk/src/pages/FlowEditor/lib/store/preview.ts b/editor.planx.uk/src/pages/FlowEditor/lib/store/preview.ts index 66bf427076..89a16a20bb 100644 --- a/editor.planx.uk/src/pages/FlowEditor/lib/store/preview.ts +++ b/editor.planx.uk/src/pages/FlowEditor/lib/store/preview.ts @@ -8,27 +8,24 @@ import type { import { DEFAULT_FLAG_CATEGORY, flatFlags, + ComponentType as TYPES, } from "@opensystemslab/planx-core/types"; -import { ComponentType as TYPES } from "@opensystemslab/planx-core/types"; import { FileList } from "@planx/components/FileUploadAndLabel/model"; import { SetValue } from "@planx/components/SetValue/model"; import { handleSetValue } from "@planx/components/SetValue/utils"; import { sortIdsDepthFirst } from "@planx/graph"; import { logger } from "airbrake"; import { objectWithoutNullishValues } from "lib/objectHelpers"; -import difference from "lodash/difference"; -import flatten from "lodash/flatten"; import isEqual from "lodash/isEqual"; -import isNil from "lodash/isNil"; import omit from "lodash/omit"; import pick from "lodash/pick"; import uniq from "lodash/uniq"; import { v4 as uuidV4 } from "uuid"; import type { StateCreator } from "zustand"; +import type { Store } from "."; import type { Session } from "./../../../../types"; import { ApplicationPath } from "./../../../../types"; -import type { Store } from "."; import { NavigationStore } from "./navigation"; import type { SharedStore } from "./shared"; @@ -88,6 +85,8 @@ export interface PreviewStore extends Store.Store { saveToEmail?: string; overrideAnswer: (fn: string) => void; requestedFiles: () => FileList; + autoAnswerableOptions: (id: NodeId) => Array | undefined; + autoAnswerableFlag: (filterId: NodeId) => NodeId | undefined; } export const previewStore: StateCreator< @@ -395,189 +394,31 @@ export const previewStore: StateCreator< sessionId: uuidV4(), upcomingCardIds() { - const { flow, breadcrumbs, computePassport, collectedFlags } = get(); - - const knownNotVals = knownNots( - flow, - breadcrumbs, - // _nots is created by PlanningConstraints/Public - computePassport().data?._nots, - ); + const { flow, breadcrumbs } = get(); const ids: Set = new Set(); - const visited: Set = new Set(); + // Based on a given node, get the nodes we should navigate through next: the children of any selected options, as well as nodes on the _root graph that should be seen no matter which option is selected const nodeIdsConnectedFrom = (source: NodeId): void => { return (flow[source]?.edges ?? []) .filter((id) => { - if (visited.has(id)) return false; - - visited.add(id); - + // Filter out nodes we've already visited (aka have a breadcrumb for) (eg clones) const node = flow[id]; - - return ( - node && - !breadcrumbs[id] && - ((node.edges || []).length > 0 || - (node.type && !SUPPORTED_DECISION_TYPES.includes(node.type))) - ); + return node && !breadcrumbs[id]; }) .forEach((id) => { const node = flow[id]; - const passport = computePassport(); - + // Recursively get children in internal portals if (node.type === TYPES.InternalPortal) { return nodeIdsConnectedFrom(id); } - const fn = node.type === TYPES.Filter ? "flag" : node.data?.fn; - - const [globalFlag] = collectedFlags(id, Array.from(visited)); - - let passportValues = (() => { - try { - return fn === "flag" ? globalFlag : passport.data?.[fn]?.sort(); - } catch (err) { - return []; - } - })(); - - if (fn && (fn === "flag" || passportValues !== undefined)) { - const responses = node.edges?.map((id) => ({ - id, - ...flow[id], - })); - - let responsesThatCanBeAutoAnswered = [] as any[]; - - const sortedResponses = responses - ? responses - // sort by the most to least number of comma-separated items in data.val - .sort( - (a: any, b: any) => - String(b.data?.val).split(",").length - - String(a.data?.val).split(",").length, - ) - .filter((response) => response.data?.val) - : []; - - if (passportValues !== undefined) { - if (!Array.isArray(passportValues)) - passportValues = [passportValues]; - - passportValues = (passportValues || []).filter((pv: any) => - sortedResponses.some((r) => pv.startsWith(r.data?.val)), - ); - - if (passportValues.length > 0) { - responsesThatCanBeAutoAnswered = (sortedResponses || []).filter( - (r) => { - const responseValues = String(r.data?.val) - .split(",") - .sort(); - return String(responseValues) === String(passportValues); - }, - ); - - if (responsesThatCanBeAutoAnswered.length === 0) { - responsesThatCanBeAutoAnswered = ( - sortedResponses || [] - ).filter((r) => { - const responseValues = String(r.data?.val) - .split(",") - .sort(); - - for (const responseValue of responseValues) { - return passportValues.some((passportValue: any) => - String(passportValue).startsWith(responseValue), - ); - } - }); - } - } - } - - if (responsesThatCanBeAutoAnswered.length === 0) { - const _responses = (responses || []).filter( - (r) => !knownNotVals[fn]?.includes(r.data?.val), - ); - - if (_responses.length === 1 && isNil(_responses[0].data?.val)) { - responsesThatCanBeAutoAnswered = _responses; - } else if ( - !passport.data?.[fn] || - passport.data?.[fn].length > 0 - ) { - responsesThatCanBeAutoAnswered = (responses || []).filter( - (r) => !r.data?.val, - ); - } - } - - if (responsesThatCanBeAutoAnswered.length > 0) { - if (node.type !== TYPES.Checklist) { - responsesThatCanBeAutoAnswered = - responsesThatCanBeAutoAnswered.slice(0, 1); - } - - if (fn !== "flag") { - set({ - breadcrumbs: { - ...breadcrumbs, - [id]: { - answers: responsesThatCanBeAutoAnswered.map((r) => r.id), - auto: true, - }, - }, - }); - } - - return responsesThatCanBeAutoAnswered.forEach((r) => - nodeIdsConnectedFrom(r.id), - ); - } - } else if ( - fn && - knownNotVals[fn] && - passportValues === undefined && - Array.isArray(node.edges) - ) { - const data = node.edges.reduce( - (acc, edgeId) => { - if (flow[edgeId].data?.val === undefined) { - acc.responseWithNoValueId = edgeId; - } else if (!knownNotVals[fn].includes(flow[edgeId].data?.val)) { - acc.edges.push(edgeId); - } - return acc; - }, - { edges: [] } as { - responseWithNoValueId?: NodeId; - edges: Array; - }, - ); - - if (data.responseWithNoValueId && data.edges.length === 0) { - set({ - breadcrumbs: { - ...breadcrumbs, - [id]: { - answers: [data.responseWithNoValueId], - auto: true, - }, - }, - }); - return nodeIdsConnectedFrom(data.responseWithNoValueId); - } - } - ids.add(id); }); }; - // with a guaranteed unique set + // With a guaranteed unique set new Set( // of all the answers collected so far Object.values(breadcrumbs) @@ -590,10 +431,164 @@ export const previewStore: StateCreator< // run nodeIdsConnectedFrom(answerId) ).forEach(nodeIdsConnectedFrom); - // then return an array of the upcoming node ids, in depth-first order + // Then return an array of the upcoming node ids, in depth-first order return sortIdsDepthFirst(flow)(ids); }, + /** + * Questions and Checklists auto-answer based on passport values + * @param id - id of the Question or Checklist node + * @returns - list of ids of the Answer nodes which can auto-answered (max length 1 for Questions) + */ + autoAnswerableOptions: (id: NodeId) => { + const { breadcrumbs, flow, computePassport } = get(); + const { type, data, edges } = flow[id]; + const { data: passportData } = computePassport(); + + // Only Question & Checklist nodes that have an fn & edges are eligible for auto-answering + if (!type || !SUPPORTED_DECISION_TYPES.includes(type) || !data?.fn || !edges || data?.neverAutoAnswer) return; + + // Only proceed if the user has seen at least one node with this fn before + const visitedFns = Object.entries(breadcrumbs).filter(([nodeId, _breadcrumb]) => flow[nodeId].data?.fn === data.fn); + if (!visitedFns) return; + + // Get all options (aka edges or Answer nodes) for this node + const options: Array = edges.map((edgeId) => ({ + id: edgeId, + ...flow[edgeId], + })); + const sortedOptions = options + .sort( + (a, b) => + // Sort by the most to least number of dot-separated items in data.val (most granular to least) + String(b.data?.val).split(".").length - + String(a.data?.val).split(".").length, + ) + // Only keep options with a data value set (remove blanks) + .filter((option) => option.data?.val); + const blankOption = options.find((option) => !option.data?.val); + let optionsThatCanBeAutoAnswered: Array = []; + + // Get existing passport value(s) for this node's fn + let passportValues = passportData?.[data.fn]; + + // If we have existing passport value(s) for this fn in an eligible automation format (eg not numbers or plain strings), + // then proceed through the matching option(s) or the blank option independent if other vals have been seen before + if (Array.isArray(passportValues) && passportValues.length > 0) { + // Check if the existing passport value(s) startsWith at least one option's val (eg passport retains most granular values only) + const matchingPassportValues = passportValues.filter((passportValue: any) => + sortedOptions.some((option) => + passportValue?.startsWith(option.data?.val), + ), + ); + + if (matchingPassportValues.length > 0) { + let foundExactMatch = false; + sortedOptions.forEach((option) => { + passportValues.forEach((passportValue: any) => { + // An option can be auto-answered if it has direct match in the passport + // or if the passport has a more granular version of the option (eg option is `fruit`, passport has `fruit.apple`) + // but only in cases where we don't also have the exact match + if (passportValue === option.data?.val) { + if (option.id) optionsThatCanBeAutoAnswered.push(option.id); + foundExactMatch = true; + } else if (passportValue.startsWith(option.data?.val) && !foundExactMatch) { + if (option.id) optionsThatCanBeAutoAnswered.push(option.id); + } + }); + }); + } else { + if (blankOption?.id) optionsThatCanBeAutoAnswered.push(blankOption.id); + } + } else { + // If we don't have any existing passport values for this fn but we do have a blank option, + // proceed through the blank if every option's val has been visited before + const sortedOptionVals: string[] = sortedOptions.map((option) => option.data?.val); + let visitedOptionVals: string[] = []; + visitedFns.forEach(([nodeId, _breadcrumb]) => { + flow[nodeId].edges?.map((edgeId) => { + if (flow[edgeId].type === TYPES.Answer && flow[edgeId].data?.val) { + visitedOptionVals.push(flow[edgeId].data.val); + } + }) + }); + + // Planning Constraints use a bespoke "_nots" data structure to describe all option vals returned via GIS API + // Concat these onto other visitedOptionVals so that questions about constraints we haven't fetched are put to user exactly once + if (visitedFns.some(([nodeId, _breadcrumb]) => flow[nodeId].type === TYPES.PlanningConstraints)) { + const nots: string[] | undefined = passportData?.["_nots"]?.[data.fn]; + if (nots) visitedOptionVals = visitedOptionVals.concat(nots); + } + + const hasVisitedEveryOption = sortedOptionVals.every(value => visitedOptionVals.includes(value)); + if (blankOption?.id && hasVisitedEveryOption) optionsThatCanBeAutoAnswered.push(blankOption.id); + } + + // Questions 'select one' and therefore can only auto-answer the single left-most matching option + if (type === TYPES.Question) { + optionsThatCanBeAutoAnswered = optionsThatCanBeAutoAnswered.slice(0, 1); + } + + return optionsThatCanBeAutoAnswered.length > 0 ? optionsThatCanBeAutoAnswered : undefined; + }, + + /** + * Filters auto-answer based on a hierarchy of collected flags + * @param filterId - id of the Filter node + * @returns - id of the Answer node of the highest order matching flag + */ + autoAnswerableFlag: (filterId: NodeId) => { + const { breadcrumbs, flow } = get(); + const { type, data, edges } = flow[filterId]; + + // Only Filter nodes that have an fn & edges are eligible for auto-answering + if (!type || type !== TYPES.Filter || !data?.fn || !edges) return; + + // Get all options (aka flags or edges or Answer nodes) for this node + const options: Array = edges.map((edgeId) => ({ + id: edgeId, + ...flow[edgeId], + })); + let optionsThatCanBeAutoAnswered: Array = []; + + // "New" Filters will have a category prop, but existing ones may still be relying on DEFAULT category + const filterCategory = data?.category || DEFAULT_FLAG_CATEGORY; + const possibleFlags = flatFlags.filter( + (flag) => flag.category === filterCategory, + ); + const possibleFlagValues = possibleFlags.map((flag) => flag.value); + + // Get all flags collected so far based on selected answers, excluding flags not in this category + const collectedFlags: Flag[] = []; + Object.entries(breadcrumbs).forEach(([_nodeId, breadcrumb]) => { + if (breadcrumb.answers) { + breadcrumb.answers.forEach((answerId) => { + const node = flow[answerId]; + if (node.data?.flag && possibleFlagValues.includes(node.data.flag)) + collectedFlags.push(node.data?.flag); + }); + } + }); + + // Starting from the left of the Filter options, check for matches + options.forEach((option) => { + collectedFlags.forEach((flag) => { + if (option.data?.val === flag && option.id) { + optionsThatCanBeAutoAnswered.push(option.id); + } + }); + }); + + // If we didn't match a flag, travel through "No result" (aka blank) option + if (optionsThatCanBeAutoAnswered.length === 0) { + const noResultFlag = options.find((option) => !option.data?.val); + if (noResultFlag?.id) optionsThatCanBeAutoAnswered.push(noResultFlag.id); + } + + // Filters 'select one' and therefore can only auto-answer the single left-most matching flag option + return optionsThatCanBeAutoAnswered.slice(0, 1).toString(); + }, + isFinalCard: () => { // Temporarily always returns false until upcomingCardIds is optimised // OSL Slack explanation: https://bit.ly/3x38IRY @@ -669,36 +664,6 @@ export const previewStore: StateCreator< getCurrentCard: () => get().currentCard, }); -const knownNots = ( - flow: Store.Flow, - breadcrumbs: Store.Breadcrumbs, - nots = {}, -) => - Object.entries(breadcrumbs).reduce( - (acc, [id, { answers = [] }]) => { - if (!flow[id]) return acc; - - const _knownNotVals = difference( - flow[id].edges, - answers as Array, - ); - - if (flow[id].data?.fn) { - acc[flow[id].data.fn] = uniq( - flatten([ - ...(acc[flow[id].data?.fn] || []), - _knownNotVals.flatMap((n) => flow[n].data?.val), - ]), - ).filter(Boolean) as Array; - } - - return acc; - }, - { - ...nots, - } as Record>, - ); - interface RemoveOrphansFromBreadcrumbsProps { id: string; flow: Store.Flow; @@ -836,9 +801,9 @@ export const sortBreadcrumbs = ( return editingNodes?.length ? nextBreadcrumbs : sortIdsDepthFirst(flow)(new Set(Object.keys(nextBreadcrumbs))).reduce( - (acc, id) => ({ ...acc, [id]: nextBreadcrumbs[id] }), - {} as Store.Breadcrumbs, - ); + (acc, id) => ({ ...acc, [id]: nextBreadcrumbs[id] }), + {} as Store.Breadcrumbs, + ); }; function handleNodesWithPassport({ diff --git a/editor.planx.uk/src/pages/Preview/Node.tsx b/editor.planx.uk/src/pages/Preview/Node.tsx index 8926eca044..f3170568f1 100644 --- a/editor.planx.uk/src/pages/Preview/Node.tsx +++ b/editor.planx.uk/src/pages/Preview/Node.tsx @@ -19,6 +19,8 @@ import type { FileUpload } from "@planx/components/FileUpload/model"; import FileUploadComponent from "@planx/components/FileUpload/Public"; import type { FileUploadAndLabel } from "@planx/components/FileUploadAndLabel/model"; import FileUploadAndLabelComponent from "@planx/components/FileUploadAndLabel/Public"; +import type { Props as Filter } from "@planx/components/Filter/Editor"; +import FilterComponent from "@planx/components/Filter/Public"; import type { FindProperty } from "@planx/components/FindProperty/model"; import FindPropertyComponent from "@planx/components/FindProperty/Public"; import type { List } from "@planx/components/List/model"; @@ -239,8 +241,11 @@ const Node: React.FC = (props) => { /> ); - case TYPES.ExternalPortal: case TYPES.Filter: + return ()} />; + + // These types are never seen by users, nor do they leave their own breadcrumbs entry + case TYPES.ExternalPortal: case TYPES.Flow: case TYPES.InternalPortal: case TYPES.Answer: