From 30139f2be105ecff6a84a5898f7e47ec482516e7 Mon Sep 17 00:00:00 2001 From: Jessica McInchak Date: Fri, 4 Oct 2024 17:28:04 +0200 Subject: [PATCH 01/25] questions are working --- .../components/Question/Public/Question.tsx | 50 ++- .../src/pages/FlowEditor/lib/store/preview.ts | 301 +++++++++--------- 2 files changed, 199 insertions(+), 152 deletions(-) diff --git a/editor.planx.uk/src/@planx/components/Question/Public/Question.tsx b/editor.planx.uk/src/@planx/components/Question/Public/Question.tsx index 88b1f54b5d..11aa82c073 100644 --- a/editor.planx.uk/src/@planx/components/Question/Public/Question.tsx +++ b/editor.planx.uk/src/@planx/components/Question/Public/Question.tsx @@ -9,7 +9,8 @@ import BasicRadio from "@planx/components/shared/Radio/BasicRadio"; import DescriptionRadio from "@planx/components/shared/Radio/DescriptionRadio"; import ImageRadio from "@planx/components/shared/Radio/ImageRadio"; import { useFormik } from "formik"; -import React from "react"; +import { useStore } from "pages/FlowEditor/lib/store"; +import React, { useEffect } from "react"; import FormWrapper from "ui/public/FormWrapper"; import FullWidthWrapper from "ui/public/FullWidthWrapper"; import ErrorWrapper from "ui/shared/ErrorWrapper"; @@ -24,6 +25,53 @@ export enum QuestionLayout { } const QuestionComponent: React.FC = (props) => { + const [currentNode, passport] = useStore((state) => [ + state.currentCard, + state.computePassport(), + ]); + + const fn = currentNode?.data?.fn; + const existingPassportValues = passport?.data?.[fn]; + const responseValues = props.responses.map((r: any) => r.val); + const responseIdsThatCanBeAutoAnswered: string[] = []; + + // TODO this is VERY barebones proof-of-concept logic (exact match, non heirarchical automation only!) + // Need to extract into a helper function based on original upcomingCardIds comparison checks + if (existingPassportValues?.length > 0 && responseValues?.length > 0) { + responseValues.forEach((response) => { + if (existingPassportValues.includes(response)) { + const id = props.responses.find((r: any) => response === r.val)?.id; + if (id) responseIdsThatCanBeAutoAnswered.push(id); + } + }); + } + + if (responseIdsThatCanBeAutoAnswered.length > 0) { + return ( + + ); + } else { + return ; + } +}; + +const AutoAnsweredQuestion: React.FC = ( + 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/pages/FlowEditor/lib/store/preview.ts b/editor.planx.uk/src/pages/FlowEditor/lib/store/preview.ts index 66bf427076..f596cad5a8 100644 --- a/editor.planx.uk/src/pages/FlowEditor/lib/store/preview.ts +++ b/editor.planx.uk/src/pages/FlowEditor/lib/store/preview.ts @@ -397,12 +397,12 @@ export const previewStore: StateCreator< upcomingCardIds() { const { flow, breadcrumbs, computePassport, collectedFlags } = get(); - const knownNotVals = knownNots( - flow, - breadcrumbs, - // _nots is created by PlanningConstraints/Public - computePassport().data?._nots, - ); + // const knownNotVals = knownNots( + // flow, + // breadcrumbs, + // // _nots is created by PlanningConstraints/Public + // computePassport().data?._nots, + // ); const ids: Set = new Set(); const visited: Set = new Set(); @@ -417,161 +417,160 @@ export const previewStore: StateCreator< const node = flow[id]; return ( - node && - !breadcrumbs[id] && - ((node.edges || []).length > 0 || - (node.type && !SUPPORTED_DECISION_TYPES.includes(node.type))) + node && !breadcrumbs[id] + // ((node.edges || []).length > 0 || + // (node.type && !SUPPORTED_DECISION_TYPES.includes(node.type))) ); }) .forEach((id) => { const node = flow[id]; - const passport = computePassport(); + // const passport = computePassport(); 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); - } - } + // 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); }); From 3911c82d85182a500df7350ed5a58e66e9eee555 Mon Sep 17 00:00:00 2001 From: Jessica McInchak Date: Sat, 5 Oct 2024 13:54:34 +0200 Subject: [PATCH 02/25] add autoAnswerableOptions store method, checklists working too --- .../@planx/components/Checklist/Public.tsx | 34 ++++++++++- .../components/Question/Public/Question.tsx | 32 +++-------- .../src/pages/FlowEditor/lib/store/preview.ts | 57 +++++++++++++++++++ 3 files changed, 98 insertions(+), 25 deletions(-) diff --git a/editor.planx.uk/src/@planx/components/Checklist/Public.tsx b/editor.planx.uk/src/@planx/components/Checklist/Public.tsx index 1920a3ef26..4c986fc192 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"; 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,37 @@ function toggleInArray(value: T, arr: Array): Array { } const ChecklistComponent: React.FC = (props) => { + const autoAnswerableOptions = useStore( + (state) => state.autoAnswerableOptions, + ); + + let idsThatCanBeAutoAnswered: string[] | undefined; + if (props.id) idsThatCanBeAutoAnswered = autoAnswerableOptions(props.id); + + if (idsThatCanBeAutoAnswered && idsThatCanBeAutoAnswered.length > 0) { + return ( + + ); + } else { + 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, diff --git a/editor.planx.uk/src/@planx/components/Question/Public/Question.tsx b/editor.planx.uk/src/@planx/components/Question/Public/Question.tsx index 11aa82c073..482b6781be 100644 --- a/editor.planx.uk/src/@planx/components/Question/Public/Question.tsx +++ b/editor.planx.uk/src/@planx/components/Question/Public/Question.tsx @@ -25,39 +25,23 @@ export enum QuestionLayout { } const QuestionComponent: React.FC = (props) => { - const [currentNode, passport] = useStore((state) => [ - state.currentCard, - state.computePassport(), - ]); + const autoAnswerableOptions = useStore( + (state) => state.autoAnswerableOptions, + ); - const fn = currentNode?.data?.fn; - const existingPassportValues = passport?.data?.[fn]; - const responseValues = props.responses.map((r: any) => r.val); - const responseIdsThatCanBeAutoAnswered: string[] = []; + let idsThatCanBeAutoAnswered: string[] | undefined; + if (props.id) idsThatCanBeAutoAnswered = autoAnswerableOptions(props.id); - // TODO this is VERY barebones proof-of-concept logic (exact match, non heirarchical automation only!) - // Need to extract into a helper function based on original upcomingCardIds comparison checks - if (existingPassportValues?.length > 0 && responseValues?.length > 0) { - responseValues.forEach((response) => { - if (existingPassportValues.includes(response)) { - const id = props.responses.find((r: any) => response === r.val)?.id; - if (id) responseIdsThatCanBeAutoAnswered.push(id); - } - }); - } - - if (responseIdsThatCanBeAutoAnswered.length > 0) { + if (idsThatCanBeAutoAnswered && idsThatCanBeAutoAnswered.length > 0) { return ( - + ); } else { return ; } }; +// An auto-answered Question won't be seen by the user, but still leaves a breadcrumb const AutoAnsweredQuestion: React.FC = ( props, ) => { 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 f596cad5a8..3c200a5151 100644 --- a/editor.planx.uk/src/pages/FlowEditor/lib/store/preview.ts +++ b/editor.planx.uk/src/pages/FlowEditor/lib/store/preview.ts @@ -88,6 +88,7 @@ export interface PreviewStore extends Store.Store { saveToEmail?: string; overrideAnswer: (fn: string) => void; requestedFiles: () => FileList; + autoAnswerableOptions: (id: NodeId) => Array | undefined; } export const previewStore: StateCreator< @@ -593,6 +594,62 @@ export const previewStore: StateCreator< return sortIdsDepthFirst(flow)(ids); }, + autoAnswerableOptions: (id: NodeId) => { + const { flow, computePassport } = get(); + const { type, data, edges } = flow[id]; + + // Only nodes that set data fn & have edges are eligible for auto-answering + if (!data?.fn || !edges) return; + + let optionsThatCanBeAutoAnswered: Array = []; + const options: Array = edges.map((edgeId) => ({ + id: edgeId, + ...flow[edgeId], + })); + const sortedOptions = options + .sort( + (a, b) => + // Sort by the most to least number of comma-separated items in data.val (most granular to least) + String(b.data?.val).split(",").length - + String(a.data?.val).split(",").length, + ) + .filter((option) => option.data?.val); + + // Get existing passport values that match this node's fn + let passportValues = computePassport()?.data?.[data?.fn]?.sort(); + if (!passportValues) return; + if (!Array.isArray(passportValues)) passportValues = [passportValues]; + + // Only proceed if at least one option's data val startsWith an existing passport value (eg passport retains most granular value only) + passportValues = passportValues.filter((passportValue: any) => + sortedOptions.some((option) => + passportValue.startsWith(option.data?.val), + ), + ); + if (!passportValues.length) return; + + // TODO Refactor / clarify logic in this block ?? + passportValues.forEach((passportValue: any) => { + sortedOptions.forEach((option) => { + const optionValues = String(option.data?.val) + .split(",") + .sort(); + if (String(optionValues) === String(passportValue) && option.id) { + optionsThatCanBeAutoAnswered.push(option.id); + } + }); + }); + + // Questions & Filters 'select one' and therefore can only auto-answer the single left-most matching option + if (type && [TYPES.Question, TYPES.Filter].includes(type)) { + optionsThatCanBeAutoAnswered = optionsThatCanBeAutoAnswered.slice(0, 1); + } + + // TODO - Are "blanks" working as expected? "_nots"? + + return optionsThatCanBeAutoAnswered; + }, + isFinalCard: () => { // Temporarily always returns false until upcomingCardIds is optimised // OSL Slack explanation: https://bit.ly/3x38IRY From 63db51aa36f1934192e59b9edde4e604695e78c5 Mon Sep 17 00:00:00 2001 From: Jessica McInchak Date: Sun, 6 Oct 2024 14:16:22 +0200 Subject: [PATCH 03/25] filters are working --- .../src/@planx/components/Filter/Public.tsx | 26 ++++ .../components/Question/Public/Question.tsx | 21 ++- .../src/pages/FlowEditor/lib/store/preview.ts | 120 ++++++++++++------ editor.planx.uk/src/pages/Preview/Node.tsx | 7 +- 4 files changed, 126 insertions(+), 48 deletions(-) create mode 100644 editor.planx.uk/src/@planx/components/Filter/Public.tsx diff --git a/editor.planx.uk/src/@planx/components/Filter/Public.tsx b/editor.planx.uk/src/@planx/components/Filter/Public.tsx new file mode 100644 index 0000000000..ca0830b06a --- /dev/null +++ b/editor.planx.uk/src/@planx/components/Filter/Public.tsx @@ -0,0 +1,26 @@ +import type { PublicProps } from "@planx/components/ui"; +import { useStore } from "pages/FlowEditor/lib/store"; +import { useEffect } from "react"; + +import type { Filter } from "./model"; + +export type Props = PublicProps; + +// A Filter is always auto-answered and never seen by a user, but should still leave a breadcrumb +export default function Component(props: Props) { + const autoAnswerableOptions = useStore( + (state) => state.autoAnswerableOptions, + ); + + let idsThatCanBeAutoAnswered: string[] | undefined; + if (props.id) idsThatCanBeAutoAnswered = autoAnswerableOptions(props.id); + + useEffect(() => { + props.handleSubmit?.({ + answers: idsThatCanBeAutoAnswered, + auto: true, + }); + }, []); + + return null; +} diff --git a/editor.planx.uk/src/@planx/components/Question/Public/Question.tsx b/editor.planx.uk/src/@planx/components/Question/Public/Question.tsx index 482b6781be..e108983ce6 100644 --- a/editor.planx.uk/src/@planx/components/Question/Public/Question.tsx +++ b/editor.planx.uk/src/@planx/components/Question/Public/Question.tsx @@ -3,6 +3,7 @@ import FormLabel from "@mui/material/FormLabel"; import Grid from "@mui/material/Grid"; import RadioGroup from "@mui/material/RadioGroup"; import { visuallyHidden } from "@mui/utils"; +import { Edges } from "@opensystemslab/planx-core/types"; import Card from "@planx/components/shared/Preview/Card"; import CardHeader from "@planx/components/shared/Preview/CardHeader"; import BasicRadio from "@planx/components/shared/Radio/BasicRadio"; @@ -25,9 +26,17 @@ export enum QuestionLayout { } const QuestionComponent: React.FC = (props) => { - const autoAnswerableOptions = useStore( - (state) => state.autoAnswerableOptions, - ); + const [flow, autoAnswerableOptions] = useStore((state) => [ + state.flow, + state.autoAnswerableOptions, + ]); + + // Questions without edges function like 'sticky notes' in the graph for editors only + 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); @@ -42,9 +51,9 @@ const QuestionComponent: React.FC = (props) => { }; // An auto-answered Question won't be seen by the user, but still leaves a breadcrumb -const AutoAnsweredQuestion: React.FC = ( - props, -) => { +const AutoAnsweredQuestion: React.FC< + Question & { answerIds: string[] | undefined } +> = (props) => { useEffect(() => { props.handleSubmit?.({ answers: props.answerIds, 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 3c200a5151..38c7dfafbe 100644 --- a/editor.planx.uk/src/pages/FlowEditor/lib/store/preview.ts +++ b/editor.planx.uk/src/pages/FlowEditor/lib/store/preview.ts @@ -396,7 +396,8 @@ export const previewStore: StateCreator< sessionId: uuidV4(), upcomingCardIds() { - const { flow, breadcrumbs, computePassport, collectedFlags } = get(); + // const { flow, breadcrumbs, computePassport, collectedFlags } = get(); + const { flow, breadcrumbs } = get(); // const knownNotVals = knownNots( // flow, @@ -406,14 +407,14 @@ export const previewStore: StateCreator< // ); const ids: Set = new Set(); - const visited: Set = new Set(); + // const visited: Set = new Set(); const nodeIdsConnectedFrom = (source: NodeId): void => { return (flow[source]?.edges ?? []) .filter((id) => { - if (visited.has(id)) return false; + // if (visited.has(id)) return false; - visited.add(id); + // visited.add(id); const node = flow[id]; @@ -595,57 +596,94 @@ export const previewStore: StateCreator< }, autoAnswerableOptions: (id: NodeId) => { - const { flow, computePassport } = get(); + const { breadcrumbs, flow, computePassport } = get(); const { type, data, edges } = flow[id]; // Only nodes that set data fn & have edges are eligible for auto-answering - if (!data?.fn || !edges) return; + if (!type || !data?.fn || !edges) return; - let optionsThatCanBeAutoAnswered: Array = []; + // Get all options (aka edges or Answer type 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 comma-separated items in data.val (most granular to least) - String(b.data?.val).split(",").length - - String(a.data?.val).split(",").length, - ) - .filter((option) => option.data?.val); - - // Get existing passport values that match this node's fn - let passportValues = computePassport()?.data?.[data?.fn]?.sort(); - if (!passportValues) return; - if (!Array.isArray(passportValues)) passportValues = [passportValues]; - - // Only proceed if at least one option's data val startsWith an existing passport value (eg passport retains most granular value only) - passportValues = passportValues.filter((passportValue: any) => - sortedOptions.some((option) => - passportValue.startsWith(option.data?.val), - ), - ); - if (!passportValues.length) return; - - // TODO Refactor / clarify logic in this block ?? - passportValues.forEach((passportValue: any) => { - sortedOptions.forEach((option) => { - const optionValues = String(option.data?.val) - .split(",") - .sort(); - if (String(optionValues) === String(passportValue) && option.id) { - optionsThatCanBeAutoAnswered.push(option.id); + let optionsThatCanBeAutoAnswered: Array = []; + + // Questions & Checklists auto-answer based on existing passport values matching node data vals + if ([TYPES.Question, TYPES.Checklist].includes(type)) { + const sortedOptions = options + .sort( + (a, b) => + // Sort by the most to least number of comma-separated items in data.val (most granular to least) + String(b.data?.val).split(",").length - + String(a.data?.val).split(",").length, + ) + .filter((option) => option.data?.val); + + // Get existing passport values that match this node's fn + let passportValues = computePassport()?.data?.[data?.fn]?.sort(); + if (!passportValues) return; + if (!Array.isArray(passportValues)) passportValues = [passportValues]; + + // Only proceed if at least one option's data val startsWith an existing passport value (eg passport retains most granular value only) + passportValues = passportValues.filter((passportValue: any) => + sortedOptions.some((option) => + passportValue.startsWith(option.data?.val), + ), + ); + if (!passportValues.length) return; + + // TODO Clarify logic in this block ?? + passportValues.forEach((passportValue: any) => { + sortedOptions.forEach((option) => { + const optionValues = String(option.data?.val) + .split(",") + .sort(); + if (String(optionValues) === String(passportValue) && option.id) { + optionsThatCanBeAutoAnswered.push(option.id); + } + }); + }); + } + + // Filters auto-answer based on a heirarchy of collected flags + if (type === TYPES.Filter) { + // TODO - It's still only possible to filter on the default flag category, in future category configured on node by editor? + const possibleFlags = flatFlags.filter( + (flag) => flag.category === DEFAULT_FLAG_CATEGORY, + ); + const possibleFlagValues = possibleFlags.map((flag) => flag.value); + if (!possibleFlagValues) return; + + // Get all flags collected so far based on selected answers, excluding flags not in the specified category + const collectedFlags: Flag[] = []; + Object.entries(breadcrumbs).forEach(([_nodeId, crumb]) => { + if (crumb.answers) { + crumb.answers.forEach((answerId) => { + const node = flow[answerId]; + if (node.data?.flag && possibleFlagValues.includes(node.data.flag)) + collectedFlags.push(node.data?.flag); + }); } }); - }); + if (!collectedFlags) return; + + // 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); + } + }); + }); + } - // Questions & Filters 'select one' and therefore can only auto-answer the single left-most matching option - if (type && [TYPES.Question, TYPES.Filter].includes(type)) { + // Questions & Filters 'select one' and therefore should only auto-answer the single left-most matching option + if ([TYPES.Question, TYPES.Filter].includes(type)) { optionsThatCanBeAutoAnswered = optionsThatCanBeAutoAnswered.slice(0, 1); } - // TODO - Are "blanks" working as expected? "_nots"? + // TODO - Are "blanks" working as expected? "_nots"? Passport value granularity? return optionsThatCanBeAutoAnswered; }, diff --git a/editor.planx.uk/src/pages/Preview/Node.tsx b/editor.planx.uk/src/pages/Preview/Node.tsx index 8926eca044..f2026e7b7a 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 { Filter } from "@planx/components/Filter/model"; +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: From fddef39966bb5c9d81fdde648ce6d17f555a4693 Mon Sep 17 00:00:00 2001 From: Jessica McInchak Date: Sun, 6 Oct 2024 18:29:56 +0200 Subject: [PATCH 04/25] automate through no result flag if no matching flag --- .../src/pages/FlowEditor/lib/store/preview.ts | 182 ++---------------- 1 file changed, 16 insertions(+), 166 deletions(-) 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 38c7dfafbe..2d05fcd657 100644 --- a/editor.planx.uk/src/pages/FlowEditor/lib/store/preview.ts +++ b/editor.planx.uk/src/pages/FlowEditor/lib/store/preview.ts @@ -396,189 +396,31 @@ export const previewStore: StateCreator< sessionId: uuidV4(), upcomingCardIds() { - // const { flow, breadcrumbs, computePassport, collectedFlags } = get(); const { flow, breadcrumbs } = get(); - // const knownNotVals = knownNots( - // flow, - // breadcrumbs, - // // _nots is created by PlanningConstraints/Public - // computePassport().data?._nots, - // ); - 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) @@ -591,7 +433,7 @@ 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); }, @@ -618,6 +460,7 @@ export const previewStore: StateCreator< String(b.data?.val).split(",").length - String(a.data?.val).split(",").length, ) + // Only keep options with a data value set (eg not blanks) .filter((option) => option.data?.val); // Get existing passport values that match this node's fn @@ -648,7 +491,7 @@ export const previewStore: StateCreator< // Filters auto-answer based on a heirarchy of collected flags if (type === TYPES.Filter) { - // TODO - It's still only possible to filter on the default flag category, in future category configured on node by editor? + // TODO - It's still only possible to filter on the default flagset category, in future category configured on node by editor? const possibleFlags = flatFlags.filter( (flag) => flag.category === DEFAULT_FLAG_CATEGORY, ); @@ -676,6 +519,13 @@ export const previewStore: StateCreator< } }); }); + + // 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 && noResultFlag?.id) + optionsThatCanBeAutoAnswered.push(noResultFlag.id); + } } // Questions & Filters 'select one' and therefore should only auto-answer the single left-most matching option @@ -683,7 +533,7 @@ export const previewStore: StateCreator< optionsThatCanBeAutoAnswered = optionsThatCanBeAutoAnswered.slice(0, 1); } - // TODO - Are "blanks" working as expected? "_nots"? Passport value granularity? + // TODO - Are "blanks" working as expected for Questions & Checklists? "_nots"? Passport value granularity? return optionsThatCanBeAutoAnswered; }, From 0078c28f0107353aa1f0fcf0866a8e98f4bb0ada Mon Sep 17 00:00:00 2001 From: Jessica McInchak Date: Sun, 6 Oct 2024 20:13:17 +0200 Subject: [PATCH 05/25] handle granular automations --- .../src/pages/FlowEditor/lib/store/preview.ts | 38 ++++++++++++------- 1 file changed, 24 insertions(+), 14 deletions(-) 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 2d05fcd657..e3b2db21c8 100644 --- a/editor.planx.uk/src/pages/FlowEditor/lib/store/preview.ts +++ b/editor.planx.uk/src/pages/FlowEditor/lib/store/preview.ts @@ -456,11 +456,11 @@ export const previewStore: StateCreator< const sortedOptions = options .sort( (a, b) => - // Sort by the most to least number of comma-separated items in data.val (most granular to least) - String(b.data?.val).split(",").length - - String(a.data?.val).split(",").length, + // 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 (eg not blanks) + // Only keep options with a data value set (remove blanks) .filter((option) => option.data?.val); // Get existing passport values that match this node's fn @@ -476,17 +476,29 @@ export const previewStore: StateCreator< ); if (!passportValues.length) return; - // TODO Clarify logic in this block ?? - passportValues.forEach((passportValue: any) => { - sortedOptions.forEach((option) => { - const optionValues = String(option.data?.val) - .split(",") - .sort(); - if (String(optionValues) === String(passportValue) && option.id) { - optionsThatCanBeAutoAnswered.push(option.id); + // For each sorted option, check if it has a direct match in the passport + sortedOptions.forEach((option) => { + passportValues.forEach((passportValue: any) => { + if (option.data?.val === passportValue) { + if (option.id) optionsThatCanBeAutoAnswered.push(option.id); } }); }); + + // If we haven't found any exact matches, see if the passport has a more granular version of the option + if (optionsThatCanBeAutoAnswered.length === 0) { + sortedOptions.forEach((option) => { + passportValues.forEach((passportValue: any) => { + // TODO - respect dot-separated segments ?? + if (passportValue.startsWith(option.data?.val)) { + if (option.id) optionsThatCanBeAutoAnswered.push(option.id); + } + }); + }); + } + + // TODO - Handle blanks & "_nots" for Questions & Checklists + // Different than Filters, these types _can_ be put to user and we need to decide when } // Filters auto-answer based on a heirarchy of collected flags @@ -533,8 +545,6 @@ export const previewStore: StateCreator< optionsThatCanBeAutoAnswered = optionsThatCanBeAutoAnswered.slice(0, 1); } - // TODO - Are "blanks" working as expected for Questions & Checklists? "_nots"? Passport value granularity? - return optionsThatCanBeAutoAnswered; }, From 0495d9ee9fe178dc60ad8cca0b221336753deda9 Mon Sep 17 00:00:00 2001 From: Jessica McInchak Date: Fri, 11 Oct 2024 11:36:18 +0200 Subject: [PATCH 06/25] update upcomingCardIds tests --- .../__tests__/preview/upcomingCardIds.test.ts | 328 +++++++++++------- 1 file changed, 209 insertions(+), 119 deletions(-) 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..f7ff533496 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,241 @@ -import { ComponentType as TYPES } from "@opensystemslab/planx-core/types"; +import { + ComponentType as TYPES, + NodeId, +} from "@opensystemslab/planx-core/types"; import { Store, useStore } from "../../store"; const { getState, setState } = useStore; -const { upcomingCardIds, resetPreview, record, getCurrentCard } = getState(); +const { upcomingCardIds, resetPreview, record } = 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 visitedNodes = () => Object.keys(getState().breadcrumbs); + +const clickContinue = (nodeId: NodeId, userData: Store.UserData) => { + record(nodeId, userData); + upcomingCardIds(); }; 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, + }, +}; From 89ad20b2a5587d9661461468e16b5c8d936a36a7 Mon Sep 17 00:00:00 2001 From: Jessica McInchak Date: Tue, 15 Oct 2024 10:17:40 +0200 Subject: [PATCH 07/25] filters work across all flagsets --- editor.planx.uk/src/@planx/components/Filter/Editor.tsx | 3 +-- editor.planx.uk/src/@planx/components/Filter/Public.tsx | 2 +- editor.planx.uk/src/pages/FlowEditor/lib/store/preview.ts | 5 +++-- 3 files changed, 5 insertions(+), 5 deletions(-) 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( + "forceSelection", + !formik.values.forceSelection, + ) + } + /> + } + label="Always put to user (forgo automation)" + /> + @@ -227,7 +244,6 @@ export const Question: React.FC = (props) => { /> - = (props) => { + if (props.forceSelection) { + return ; + } + const [flow, autoAnswerableOptions] = useStore((state) => [ state.flow, state.autoAnswerableOptions, ]); - // Questions without edges function like 'sticky notes' in the graph for editors only + // 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) { diff --git a/editor.planx.uk/src/@planx/components/Question/model.ts b/editor.planx.uk/src/@planx/components/Question/model.ts index 908b5bc2bb..2ccab757e6 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; + forceSelection?: boolean; responses: { id?: string; responseKey: string | number; From a77cca51583688670681cbb61f8d4758055c9eab Mon Sep 17 00:00:00 2001 From: Jessica McInchak Date: Fri, 25 Oct 2024 12:36:08 +0200 Subject: [PATCH 15/25] separate store methods for auto-answering options v flags, blanks work --- .../src/@planx/components/Filter/Public.tsx | 12 +- .../FlowEditor/lib/__tests__/filters.test.ts | 18 +- .../src/pages/FlowEditor/lib/store/preview.ts | 189 ++++++++++-------- 3 files changed, 122 insertions(+), 97 deletions(-) diff --git a/editor.planx.uk/src/@planx/components/Filter/Public.tsx b/editor.planx.uk/src/@planx/components/Filter/Public.tsx index 38f732f188..b0d65628e8 100644 --- a/editor.planx.uk/src/@planx/components/Filter/Public.tsx +++ b/editor.planx.uk/src/@planx/components/Filter/Public.tsx @@ -6,18 +6,18 @@ import type { Props as Filter } from "./Editor"; export type Props = PublicProps; -// A Filter is always auto-answered and never seen by a user, but should still leave a breadcrumb +// Filters are always auto-answered and never seen by a user, but should still leave a breadcrumb export default function Component(props: Props) { - const autoAnswerableOptions = useStore( - (state) => state.autoAnswerableOptions, + const autoAnswerableFlag = useStore( + (state) => state.autoAnswerableFlag, ); - let idsThatCanBeAutoAnswered: string[] | undefined; - if (props.id) idsThatCanBeAutoAnswered = autoAnswerableOptions(props.id); + let idThatCanBeAutoAnswered: string | undefined; + if (props.id) idThatCanBeAutoAnswered = autoAnswerableFlag(props.id); useEffect(() => { props.handleSubmit?.({ - answers: idsThatCanBeAutoAnswered, + answers: [idThatCanBeAutoAnswered], 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 501def648f..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 @@ -2,7 +2,7 @@ import { Store, useStore } from "../store"; import { clickContinue, visitedNodes } from "./utils"; const { getState, setState } = useStore; -const { upcomingCardIds, resetPreview, autoAnswerableOptions } = getState(); +const { upcomingCardIds, resetPreview, autoAnswerableFlag, autoAnswerableOptions } = getState(); describe("A filter on the root of the graph", () => { beforeEach(() => { @@ -16,7 +16,7 @@ describe("A filter on the root of the graph", () => { clickContinue("SecondQuestion", { answers: ["SecondQuestionNoAnswer"], auto: false }); expect(upcomingCardIds()?.[0]).toEqual("RootFilter"); - expect(autoAnswerableOptions("RootFilter")).toEqual(["RootFilterYes"]); + expect(autoAnswerableFlag("RootFilter")).toEqual("RootFilterYes"); }); test("Filter options are auto-answered correctly when a lower order flag is collected first", () => { @@ -25,7 +25,7 @@ describe("A filter on the root of the graph", () => { clickContinue("SecondQuestion", { answers: ["SecondQuestionYesAnswer"], auto: false }); expect(upcomingCardIds()?.[0]).toEqual("RootFilter"); - expect(autoAnswerableOptions("RootFilter")).toEqual(["RootFilterYes"]); + expect(autoAnswerableFlag("RootFilter")).toEqual("RootFilterYes"); }); test("Filter 'No flag result' option is auto-answered correctly when no flags in this category have been collected", () => { @@ -34,7 +34,7 @@ describe("A filter on the root of the graph", () => { clickContinue("SecondQuestion", { answers: ["SecondQuestionIdkAnswer"], auto: false }); expect(upcomingCardIds()?.[0]).toEqual("RootFilter"); - expect(autoAnswerableOptions("RootFilter")).toEqual(["RootFilterNoFlagResult"]); + expect(autoAnswerableFlag("RootFilter")).toEqual("RootFilterNoFlagResult"); }); }); @@ -56,7 +56,7 @@ describe("A filter on a branch", () => { clickContinue("BranchingQuestion", { answers: ["GoToBranchAnswer"], auto: false }); expect(upcomingCardIds()?.[0]).toEqual("BranchFilter"); - expect(autoAnswerableOptions("BranchFilter")).toEqual(["BranchFilterYes"]); + expect(autoAnswerableFlag("BranchFilter")).toEqual("BranchFilterYes"); }); test("Filter options are auto-answered correctly when a lower order flag is collected first", () => { @@ -71,7 +71,7 @@ describe("A filter on a branch", () => { clickContinue("BranchingQuestion", { answers: ["GoToBranchAnswer"], auto: false }); expect(upcomingCardIds()?.[0]).toEqual("BranchFilter"); - expect(autoAnswerableOptions("BranchFilter")).toEqual(["BranchFilterYes"]); + expect(autoAnswerableFlag("BranchFilter")).toEqual("BranchFilterYes"); }); test("Filter 'No flag result' option is auto-answered correctly when no flags in this category have been collected", () => { @@ -81,7 +81,7 @@ describe("A filter on a branch", () => { clickContinue("BranchingQuestion", { answers: ["GoToBranchAnswer"], auto: false }); expect(upcomingCardIds()?.[0]).toEqual("BranchFilter"); - expect(autoAnswerableOptions("BranchFilter")).toEqual(["BranchFilterNoFlagResult"]); + expect(autoAnswerableFlag("BranchFilter")).toEqual("BranchFilterNoFlagResult"); }); }); @@ -97,7 +97,7 @@ describe("Auto-answerable Questions or Checklists on filter paths", () => { expect(visitedNodes()).not.toContain("AutoAnswerableChecklist"); expect(upcomingCardIds()?.[0]).toEqual("RootFilter"); - expect(autoAnswerableOptions("RootFilter")).toEqual(["RootFilterYes"]); + expect(autoAnswerableFlag("RootFilter")).toEqual("RootFilterYes"); clickContinue("RootFilter", { answers: ["RootFilterYes"], auto: true }); expect(upcomingCardIds()?.[0]).toEqual("AutoAnswerableChecklist"); @@ -110,7 +110,7 @@ describe("Auto-answerable Questions or Checklists on filter paths", () => { expect(visitedNodes()).not.toContain("AutoAnswerableChecklist"); expect(upcomingCardIds()?.[0]).toEqual("RootFilter"); - expect(autoAnswerableOptions("RootFilter")).toEqual(["RootFilterNo"]); + expect(autoAnswerableFlag("RootFilter")).toEqual("RootFilterNo"); clickContinue("RootFilter", { answers: ["RootFilterNo"], auto: true }); expect(upcomingCardIds()).not.toContain("AutoAnswerableChecklist"); 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 3b3e070c17..2bf48689ea 100644 --- a/editor.planx.uk/src/pages/FlowEditor/lib/store/preview.ts +++ b/editor.planx.uk/src/pages/FlowEditor/lib/store/preview.ts @@ -89,6 +89,7 @@ export interface PreviewStore extends Store.Store { overrideAnswer: (fn: string) => void; requestedFiles: () => FileList; autoAnswerableOptions: (id: NodeId) => Array | undefined; + autoAnswerableFlag: (filterId: NodeId) => NodeId | undefined; } export const previewStore: StateCreator< @@ -437,40 +438,45 @@ export const previewStore: StateCreator< 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]; - // Only nodes that have an fn & edges are eligible for auto-answering - if (!type || !data?.fn || !edges) return; + // Only Queston & Checklist nodes that have an fn & edges are eligible for auto-answering + if (!type || ![TYPES.Question, TYPES.Checklist].includes(type) || !data?.fn || !edges) return; - // Get all options (aka edges or Answer type nodes) for this node + // 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 = []; - // Questions & Checklists auto-answer based on existing passport values matching node data vals - if ([TYPES.Question, TYPES.Checklist].includes(type)) { - 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); - - // Get existing passport values that match this node's fn - let passportValues = computePassport()?.data?.[data.fn]?.sort(); - if (!passportValues && !blankOption) return; - - if (!Array.isArray(passportValues)) passportValues = [passportValues]; - - // Proceed if an existing passport value startsWith at least one option's val (eg passport retains most granular value only) + // Get existing passport value(s) for this node's fn + let passportValues = computePassport()?.data?.[data.fn]?.sort(); + if (!Array.isArray(passportValues)) passportValues = [passportValues].filter(Boolean); + + if (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), @@ -490,78 +496,97 @@ export const previewStore: StateCreator< }); }); } else { - // If we don't have any relevant matching passport values but we do have a blank option, - // check if we've seen nodes with the same fn before and proceed through the blank only if every option's val has been visited before - const visitedFns = Object.entries(breadcrumbs).filter(([nodeId, _breadcrumb]) => flow[nodeId].data?.fn === data.fn); - if (!visitedFns) return; - - 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 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 = computePassport()?.data?.["_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); + if (blankOption?.id) optionsThatCanBeAutoAnswered.push(blankOption.id); } - } - - // Filters auto-answer based on a heirarchy of collected flags - if (type === TYPES.Filter) { - // "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, crumb]) => { - if (crumb.answers) { - crumb.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); + } 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); } - }); + }) }); - // 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); + // 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 = computePassport()?.data?.["_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 & Filters 'select one' and therefore can only auto-answer the single left-most matching option - if ([TYPES.Question, TYPES.Filter].includes(type)) { + // 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; }, + /** + * Filters auto-answer based on a heirarchy 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 From 738ff993fd2a82c3da00ba722ec4fb442b488f94 Mon Sep 17 00:00:00 2001 From: Jessica McInchak Date: Sat, 26 Oct 2024 11:44:07 +0200 Subject: [PATCH 16/25] tweak parent/child granularity matching --- .../src/pages/FlowEditor/lib/store/preview.ts | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) 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 2bf48689ea..8f286e6c98 100644 --- a/editor.planx.uk/src/pages/FlowEditor/lib/store/preview.ts +++ b/editor.planx.uk/src/pages/FlowEditor/lib/store/preview.ts @@ -475,6 +475,8 @@ export const previewStore: StateCreator< let passportValues = computePassport()?.data?.[data.fn]?.sort(); if (!Array.isArray(passportValues)) passportValues = [passportValues].filter(Boolean); + // If we have an existing passport value for this fn, + // then proceed through the matching option(s) or the blank option independent if other vals have been seen before if (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) => @@ -484,13 +486,16 @@ export const previewStore: StateCreator< ); 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`) + // 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); - } else if (passportValue.startsWith(option.data?.val)) { + foundExactMatch = true; + } else if (passportValue.startsWith(option.data?.val) && !foundExactMatch) { if (option.id) optionsThatCanBeAutoAnswered.push(option.id); } }); From 65d8375e6488212740f51e88e35a24d124220249 Mon Sep 17 00:00:00 2001 From: Jessica McInchak Date: Mon, 28 Oct 2024 12:14:26 +0100 Subject: [PATCH 17/25] lots of tests --- .../components/Question/Public.test.tsx | 46 ++- .../components/Question/Public/Question.tsx | 4 +- .../lib/__tests__/advancedAutomations.test.ts | 268 ------------------ .../lib/__tests__/automations.blanks.test.ts | 15 + .../__tests__/automations.parentChild.test.ts | 206 ++++++++++++++ .../lib/__tests__/automations.test.ts | 192 ------------- .../preview/autoAnswerableFlag.test.ts | 80 ++++++ .../preview/autoAnswerableOptions.test.ts | 47 ++- .../FlowEditor/lib/__tests__/unseen.test.ts | 88 ------ .../lib/__tests__/useNotValues.test.ts | 157 ---------- .../src/pages/FlowEditor/lib/store/preview.ts | 30 -- 11 files changed, 382 insertions(+), 751 deletions(-) delete mode 100644 editor.planx.uk/src/pages/FlowEditor/lib/__tests__/advancedAutomations.test.ts create mode 100644 editor.planx.uk/src/pages/FlowEditor/lib/__tests__/automations.blanks.test.ts create mode 100644 editor.planx.uk/src/pages/FlowEditor/lib/__tests__/automations.parentChild.test.ts delete mode 100644 editor.planx.uk/src/pages/FlowEditor/lib/__tests__/automations.test.ts create mode 100644 editor.planx.uk/src/pages/FlowEditor/lib/__tests__/preview/autoAnswerableFlag.test.ts delete mode 100644 editor.planx.uk/src/pages/FlowEditor/lib/__tests__/unseen.test.ts delete mode 100644 editor.planx.uk/src/pages/FlowEditor/lib/__tests__/useNotValues.test.ts diff --git a/editor.planx.uk/src/@planx/components/Question/Public.test.tsx b/editor.planx.uk/src/@planx/components/Question/Public.test.tsx index 34ad08ae52..f9ae0170fb 100644 --- a/editor.planx.uk/src/@planx/components/Question/Public.test.tsx +++ b/editor.planx.uk/src/@planx/components/Question/Public.test.tsx @@ -1,12 +1,50 @@ -import { waitFor } from "@testing-library/react"; +import { act, waitFor } from "@testing-library/react"; import React from "react"; import { setup } from "testUtils"; import { vi } from "vitest"; import { axe } from "vitest-axe"; +import { Store, useStore } from "pages/FlowEditor/lib/store"; import type { Question } from "./model"; import QuestionComponent, { QuestionLayout } from "./Public"; +const { setState } = useStore; + +// Setup a basic single component flow so that we're testing the "VisibleQuestion" throughout (eg wrapper checks `flow[props.id].edges`) +const flow: Store.Flow = { + "_root": { + "edges": [ + "qustion_id" + ] + }, + "celery_id": { + "data": { + "text": "celery" + }, + "type": 200 + }, + "pizza_id": { + "data": { + "text": "pizza" + }, + "type": 200 + }, + "question_id": { + "data": { + "text": "Best food", + }, + "type": 100, + "edges": [ + "pizza_id", + "celery_id", + ] + }, +}; + +beforeEach(() => { + 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) => { state.autoAnswerableOptions, ]); + console.log("HERE flow", props); + // 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 (props.id) edges = flow[props.id]?.edges if (!edges || edges.length === 0) { return ; } diff --git a/editor.planx.uk/src/pages/FlowEditor/lib/__tests__/advancedAutomations.test.ts b/editor.planx.uk/src/pages/FlowEditor/lib/__tests__/advancedAutomations.test.ts deleted file mode 100644 index f9774e5d9b..0000000000 --- a/editor.planx.uk/src/pages/FlowEditor/lib/__tests__/advancedAutomations.test.ts +++ /dev/null @@ -1,268 +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: [ - "Imks7j68BD", - "HV0gV8DOil", - "2PT6bTPTqj", - "3H2bGdzpIN", - "AFX3QwbOCd", - ], - }, - "0LzMSk4JTO": { - data: { - val: "food.bread", - text: "bread", - }, - type: TYPES.Answer, - }, - "0vojjvJ6rP": { - data: { - val: "food", - text: "food", - }, - type: TYPES.Answer, - edges: ["mOPogpQa7V"], - }, - "2PT6bTPTqj": { - data: { - fn: "item", - text: "contains", - }, - type: TYPES.Question, - edges: ["oB2vfxQs4D", "ykhO0drpaY", "U9S73zxy9n", "LwozLZdXCA"], - }, - "3H2bGdzpIN": { - data: { - fn: "item", - text: "Does the basket contain apples?", - }, - type: TYPES.Question, - edges: ["BJpKurp49I", "hKebzlFQDa"], - }, - "4JPWSgnGtI": { - data: { - val: "tool", - text: "tools", - }, - type: TYPES.Answer, - edges: ["KcLGMm3UWw"], - }, - "52ZNXBMLDP": { - data: { - color: "#EFEFEF", - title: "?, so must be a 🍌 or 🔧", - resetButton: false, - }, - type: TYPES.Notice, - }, - "6RR1J1lmrM": { - data: { - color: "#EFEFEF", - title: "🍏", - resetButton: false, - }, - type: TYPES.Notice, - }, - "7tV1uvR9ng": { - data: { - val: "tool.spanner", - text: "spanner", - }, - type: TYPES.Answer, - }, - AFX3QwbOCd: { - data: { - fn: "item", - text: "Which does the basket contain?", - }, - type: TYPES.Question, - edges: ["4JPWSgnGtI", "0vojjvJ6rP"], - }, - BJpKurp49I: { - data: { - val: "food.fruit.apple", - text: "Yes", - }, - type: TYPES.Answer, - }, - BloOMLvLJK: { - data: { - val: "food.fruit.banana", - text: "banana", - }, - type: TYPES.Answer, - }, - EqfqaqZ6CH: { - data: { - val: "food.fruit.apple", - text: "apple", - }, - type: TYPES.Answer, - }, - HV0gV8DOil: { - data: { - fn: "item", - text: "shopping trolley (should be skipped)", - allRequired: false, - }, - type: TYPES.Checklist, - edges: ["lTosE7Xo1j", "BloOMLvLJK", "0LzMSk4JTO", "OvNhSiRfdL"], - }, - I8DznYCKVg: { - data: { - val: "food.fruit.banana", - text: "banana", - }, - type: TYPES.Answer, - }, - Imks7j68BD: { - data: { - fn: "item", - text: "shopping trolley", - allRequired: false, - }, - type: TYPES.Checklist, - edges: ["EqfqaqZ6CH", "I8DznYCKVg", "pXFKKRG6lE", "7tV1uvR9ng"], - }, - KcLGMm3UWw: { - data: { - color: "#EFEFEF", - title: "🔧", - resetButton: false, - }, - type: TYPES.Notice, - }, - LwozLZdXCA: { - data: { - text: "neither apples nor bread", - }, - type: TYPES.Answer, - edges: ["52ZNXBMLDP"], - }, - OvNhSiRfdL: { - data: { - val: "tool.spanner", - text: "spanner", - }, - type: TYPES.Answer, - }, - U9S73zxy9n: { - data: { - val: "food.fruit.apple,food.bread", - text: "apples and bread", - }, - type: TYPES.Answer, - edges: ["t3SCqQKeUK"], - }, - g0IAKsBVPQ: { - data: { - color: "#EFEFEF", - title: "🥖", - resetButton: false, - }, - type: TYPES.Notice, - }, - hKebzlFQDa: { - data: { - text: "No", - }, - type: TYPES.Answer, - }, - lTosE7Xo1j: { - data: { - val: "food.fruit.apple", - text: "apple", - }, - type: TYPES.Answer, - }, - mOPogpQa7V: { - data: { - color: "#EFEFEF", - title: "🍌🍏🥖", - resetButton: false, - }, - type: TYPES.Notice, - }, - oB2vfxQs4D: { - data: { - val: "food.fruit.apple", - text: "apples", - }, - type: TYPES.Answer, - edges: ["6RR1J1lmrM"], - }, - pXFKKRG6lE: { - data: { - val: "food.bread", - text: "bread", - }, - type: TYPES.Answer, - }, - t3SCqQKeUK: { - data: { - color: "#EFEFEF", - title: "🍏🥖", - resetButton: false, - }, - type: TYPES.Notice, - }, - ykhO0drpaY: { - data: { - val: "food.bread", - text: "bread", - }, - type: TYPES.Answer, - edges: ["g0IAKsBVPQ"], - }, -}; - -beforeEach(() => { - 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..8d0095ec2c --- /dev/null +++ b/editor.planx.uk/src/pages/FlowEditor/lib/__tests__/automations.blanks.test.ts @@ -0,0 +1,15 @@ +import { Store, useStore } from "../store"; + +const { getState, setState } = useStore; +const { resetPreview } = getState(); + +describe("Auto-answering blanks", () => { + beforeEach(() => { + resetPreview(); + setState({ flow }); + }); + + test.todo("TODO"); +}) + +const flow: Store.Flow = {}; 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..1631e5dda7 --- /dev/null +++ b/editor.planx.uk/src/pages/FlowEditor/lib/__tests__/automations.parentChild.test.ts @@ -0,0 +1,206 @@ +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); + }); +}); + +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, + "forceSelection": false + }, + "type": 105, + "edges": [ + "Checklist1ParentA", + "Checklist1ChildAX", + "Checklist1ParentB" + ] + }, + "Checklist2": { + "data": { + "fn": "values", + "tags": [], + "text": "Pick many", + "allRequired": false, + "forceSelection": 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", + "forceSelection": 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.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__/preview/autoAnswerableFlag.test.ts b/editor.planx.uk/src/pages/FlowEditor/lib/__tests__/preview/autoAnswerableFlag.test.ts new file mode 100644 index 0000000000..def2d8a812 --- /dev/null +++ b/editor.planx.uk/src/pages/FlowEditor/lib/__tests__/preview/autoAnswerableFlag.test.ts @@ -0,0 +1,80 @@ +import { Store, useStore } from "../../store"; + +const { getState, setState } = useStore; +const { resetPreview, autoAnswerableFlag } = getState(); + +beforeEach(() => { + resetPreview(); +}); + +describe("Returns undefined and does not auto-answer any flag paths", () => { + 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(); +}); + +describe("Filters", () => { + test.todo("Auto-answer the single highest order flag path when many flags are collected"); + + test.todo("Auto-answer the blank path (no flag result) when no matching flags have been collected"); +}); + +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 index 5ca9ff542e..32901e1f8b 100644 --- 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 @@ -1,32 +1,53 @@ import { useStore } from "../../store"; -const { getState } = useStore; -const { resetPreview } = getState(); +const { getState, setState } = useStore; +const { resetPreview, autoAnswerableOptions } = getState(); beforeEach(() => { resetPreview(); }); +describe("Returns undefined and does not auto-answer any options", () => { + 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.todo("If the node is a 'sticky note' Question without edges"); + + test.todo("If the node does not set a `fn`"); + + test.todo("If we've never seen another node with this `fn` before"); +}); + describe("Questions", () => { - test.todo("Correctly auto-answers the option that exactly matches a passport value"); + test.todo("Auto-answer the option that exactly matches a passport value"); + + test.todo("Auto-answer the less granular option when there's a single more granular passport value"); - test.todo("Correctly auto-answers the less granular option when there's a single more granular passport value"); + test.todo("Auto-answer the single most granular, left-most option when there are many matching passport values"); - test.todo("Correctly auto-answers the single left-most option when there are many matching passport values"); + test.todo("Auto-answer through the blank path when we have seen this node `fn` but there are no matching passport values"); - test.todo("Correctly auto-answers through the blank path when there are no matching passport values but we've seen this passport fn before"); + test.todo("Auto-answer through the blank path when we have not seen this node `fn` but we have seen all possible option `val`"); }); describe("Checklists", () => { - test.todo("Correctly auto-answers all options that exactly match passport values"); + test.todo("Auto-answer all options that exactly match passport values"); - test.todo("Correctly auto-answers all less granular options when there are more granular passport values"); + test.todo("Auto-answer all less granular options when there are more granular passport values"); - test.todo("Correctly auto-answers through the blank path when there are no matching passport values but we've seen this passport fn before"); -}); + test.todo("Auto-answer through the blank path when we have seen thsi node `fn` but there are no matching passport values"); -describe("Filters", () => { - test.todo("Correctly auto-answers the single highest order flag path when many flags are collected"); + test.todo("Auto-answer through the blank path when we have not seen this node `fn` but we have seen all possible option `val`"); +}); - test.todo("Correctly auto-answers the through the blank path (no flag result) when no matching flags have been collected"); +describe("Blanks and `_nots`", () => { + test.todo("TODO"); }); 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/store/preview.ts b/editor.planx.uk/src/pages/FlowEditor/lib/store/preview.ts index 8f286e6c98..4b9a07146c 100644 --- a/editor.planx.uk/src/pages/FlowEditor/lib/store/preview.ts +++ b/editor.planx.uk/src/pages/FlowEditor/lib/store/preview.ts @@ -667,36 +667,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; From 7bba5487593ec515c2022701cf7577eda4c7482b Mon Sep 17 00:00:00 2001 From: Jessica McInchak Date: Mon, 28 Oct 2024 14:27:33 +0100 Subject: [PATCH 18/25] blanks and Calculate tests --- .../@planx/components/Calculate/logic.test.ts | 24 +- .../components/Question/Public/Question.tsx | 2 - .../lib/__tests__/automations.blanks.test.ts | 501 +++++++++++++++++- .../__tests__/automations.parentChild.test.ts | 1 + .../src/pages/FlowEditor/lib/store/preview.ts | 9 +- 5 files changed, 513 insertions(+), 24 deletions(-) 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/Question/Public/Question.tsx b/editor.planx.uk/src/@planx/components/Question/Public/Question.tsx index 74842fd2ce..6054544eab 100644 --- a/editor.planx.uk/src/@planx/components/Question/Public/Question.tsx +++ b/editor.planx.uk/src/@planx/components/Question/Public/Question.tsx @@ -35,8 +35,6 @@ const QuestionComponent: React.FC = (props) => { state.autoAnswerableOptions, ]); - console.log("HERE flow", props); - // 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 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 index 8d0095ec2c..b3546d3ad1 100644 --- 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 @@ -1,7 +1,8 @@ import { Store, useStore } from "../store"; +import { clickContinue } from "./utils"; const { getState, setState } = useStore; -const { resetPreview } = getState(); +const { resetPreview, upcomingCardIds, autoAnswerableOptions, computePassport } = getState(); describe("Auto-answering blanks", () => { beforeEach(() => { @@ -9,7 +10,499 @@ describe("Auto-answering blanks", () => { setState({ flow }); }); - test.todo("TODO"); -}) + 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 }); -const flow: Store.Flow = {}; + // 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 index 1631e5dda7..bf16ab4d29 100644 --- 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 @@ -66,6 +66,7 @@ describe("Parent-child automations and granularity", () => { }); }); +// editor.planx.dev/testing/behaviour-check const flow: Store.Flow = { "_root": { "edges": [ 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 4b9a07146c..fdfc3111b1 100644 --- a/editor.planx.uk/src/pages/FlowEditor/lib/store/preview.ts +++ b/editor.planx.uk/src/pages/FlowEditor/lib/store/preview.ts @@ -447,7 +447,7 @@ export const previewStore: StateCreator< const { breadcrumbs, flow, computePassport } = get(); const { type, data, edges } = flow[id]; - // Only Queston & Checklist nodes that have an fn & edges are eligible for auto-answering + // Only Question & Checklist nodes that have an fn & edges are eligible for auto-answering if (!type || ![TYPES.Question, TYPES.Checklist].includes(type) || !data?.fn || !edges) return; // Only proceed if the user has seen at least one node with this fn before @@ -471,9 +471,10 @@ export const previewStore: StateCreator< const blankOption = options.find((option) => !option.data?.val); let optionsThatCanBeAutoAnswered: Array = []; - // Get existing passport value(s) for this node's fn - let passportValues = computePassport()?.data?.[data.fn]?.sort(); - if (!Array.isArray(passportValues)) passportValues = [passportValues].filter(Boolean); + // Get existing passport value(s) for this node's fn & proceed if eligible format (eg not numbers via Calculate nodes) + let passportValues = computePassport().data?.[data.fn]; + if (typeof passportValues === "number") return; + if (!Array.isArray(passportValues)) passportValues = [passportValues].filter(Boolean).sort(); // If we have an existing passport value for this fn, // then proceed through the matching option(s) or the blank option independent if other vals have been seen before From 9b522e668debbc19c9970302adf067d0e7560f6f Mon Sep 17 00:00:00 2001 From: Jessica McInchak Date: Mon, 28 Oct 2024 15:09:29 +0100 Subject: [PATCH 19/25] tidy up --- .../__tests__/preview/autoAnswerableFlag.test.ts | 8 ++------ .../preview/autoAnswerableOptions.test.ts | 14 +++++++------- .../src/pages/FlowEditor/lib/store/preview.ts | 9 +++------ 3 files changed, 12 insertions(+), 19 deletions(-) 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 index def2d8a812..8fe7424d57 100644 --- 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 @@ -7,6 +7,8 @@ beforeEach(() => { resetPreview(); }); +// 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", () => { test("If the node is not a Filter type", () => { setState({ flow: { @@ -33,12 +35,6 @@ describe("Returns undefined and does not auto-answer any flag paths", () => { expect(autoAnswerableFlag("Filter")).not.toBeDefined(); }); -describe("Filters", () => { - test.todo("Auto-answer the single highest order flag path when many flags are collected"); - - test.todo("Auto-answer the blank path (no flag result) when no matching flags have been collected"); -}); - const flowWithFilter: Store.Flow = { "_root": { "edges": [ 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 index 32901e1f8b..a11cd2d9b1 100644 --- 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 @@ -7,6 +7,10 @@ beforeEach(() => { resetPreview(); }); +// Find additional auto-answering tests at: +// - src/pages/FlowEditor/lib/automations.blanks.test.ts +// - src/pages/FlowEditor/lib/automations.parentChild.test.ts + describe("Returns undefined and does not auto-answer any options", () => { test("If the node is not a Question or Checklist type", () => { setState({ @@ -29,7 +33,7 @@ describe("Returns undefined and does not auto-answer any options", () => { describe("Questions", () => { test.todo("Auto-answer the option that exactly matches a passport value"); - test.todo("Auto-answer the less granular option when there's a single more granular passport value"); + test.todo("Auto-answer the less granular option when there's a single more granular passport value and no more granular options available"); test.todo("Auto-answer the single most granular, left-most option when there are many matching passport values"); @@ -41,13 +45,9 @@ describe("Questions", () => { describe("Checklists", () => { test.todo("Auto-answer all options that exactly match passport values"); - test.todo("Auto-answer all less granular options when there are more granular passport values"); + test.todo("Auto-answer all less granular options when there are more granular passport values and not more granular options available"); - test.todo("Auto-answer through the blank path when we have seen thsi node `fn` but there are no matching passport values"); + test.todo("Auto-answer through the blank path when we have seen this node `fn` but there are no matching passport values"); test.todo("Auto-answer through the blank path when we have not seen this node `fn` but we have seen all possible option `val`"); }); - -describe("Blanks and `_nots`", () => { - test.todo("TODO"); -}); 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 fdfc3111b1..4501feffc8 100644 --- a/editor.planx.uk/src/pages/FlowEditor/lib/store/preview.ts +++ b/editor.planx.uk/src/pages/FlowEditor/lib/store/preview.ts @@ -4,12 +4,11 @@ import type { GovUKPayment, Node, NodeId, - Value, } from "@opensystemslab/planx-core/types"; import { - ComponentType as TYPES, DEFAULT_FLAG_CATEGORY, flatFlags, + ComponentType as TYPES, } from "@opensystemslab/planx-core/types"; import { FileList } from "@planx/components/FileUploadAndLabel/model"; import { SetValue } from "@planx/components/SetValue/model"; @@ -17,8 +16,6 @@ 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 omit from "lodash/omit"; import pick from "lodash/pick"; @@ -26,9 +23,9 @@ 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"; @@ -448,7 +445,7 @@ export const previewStore: StateCreator< const { type, data, edges } = flow[id]; // Only Question & Checklist nodes that have an fn & edges are eligible for auto-answering - if (!type || ![TYPES.Question, TYPES.Checklist].includes(type) || !data?.fn || !edges) return; + if (!type || !SUPPORTED_DECISION_TYPES.includes(type) || !data?.fn || !edges) 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); From efe731dfa5bb0c75a7b0ceaa6d32f6cc9ead6962 Mon Sep 17 00:00:00 2001 From: Jessica McInchak Date: Mon, 28 Oct 2024 22:09:14 +0100 Subject: [PATCH 20/25] planning constraints _nots test suite --- ...automations.planningConstraintNots.test.ts | 154 ++++++++++++++++++ .../src/pages/FlowEditor/lib/store/preview.ts | 10 +- 2 files changed, 158 insertions(+), 6 deletions(-) create mode 100644 editor.planx.uk/src/pages/FlowEditor/lib/__tests__/automations.planningConstraintNots.test.ts 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..6d0afe0915 --- /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?", + "forceSelection": 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?", + "forceSelection": 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?", + "forceSelection": 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/store/preview.ts b/editor.planx.uk/src/pages/FlowEditor/lib/store/preview.ts index 4501feffc8..fc2a4315b9 100644 --- a/editor.planx.uk/src/pages/FlowEditor/lib/store/preview.ts +++ b/editor.planx.uk/src/pages/FlowEditor/lib/store/preview.ts @@ -468,14 +468,12 @@ export const previewStore: StateCreator< const blankOption = options.find((option) => !option.data?.val); let optionsThatCanBeAutoAnswered: Array = []; - // Get existing passport value(s) for this node's fn & proceed if eligible format (eg not numbers via Calculate nodes) + // Get existing passport value(s) for this node's fn let passportValues = computePassport().data?.[data.fn]; - if (typeof passportValues === "number") return; - if (!Array.isArray(passportValues)) passportValues = [passportValues].filter(Boolean).sort(); - // If we have an existing passport value for this 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 (passportValues.length > 0) { + 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) => @@ -530,7 +528,7 @@ export const previewStore: StateCreator< optionsThatCanBeAutoAnswered = optionsThatCanBeAutoAnswered.slice(0, 1); } - return optionsThatCanBeAutoAnswered; + return optionsThatCanBeAutoAnswered.length > 0 ? optionsThatCanBeAutoAnswered : undefined; }, /** From 570cb28ecb383b58918312654357280179cd8404 Mon Sep 17 00:00:00 2001 From: Jessica McInchak Date: Tue, 29 Oct 2024 10:23:12 +0100 Subject: [PATCH 21/25] autoAnswerableOptions test todos --- .../preview/autoAnswerableFlag.test.ts | 78 ++++++++++--------- .../preview/autoAnswerableOptions.test.ts | 70 +++++++++++++++-- 2 files changed, 102 insertions(+), 46 deletions(-) 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 index 8fe7424d57..fbb019a06c 100644 --- 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 @@ -3,18 +3,20 @@ import { Store, useStore } from "../../store"; const { getState, setState } = useStore; const { resetPreview, autoAnswerableFlag } = getState(); -beforeEach(() => { - resetPreview(); -}); - -// Additionally see src/pages/FlowEditor/lib/filters.test.ts for positive autoAnswerableFlag test cases !! +// 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: { + setState({ + flow: { "_root": { "edges": ["SetValue"] }, "SetValue": { "type": 380, "data": { "fn": "projectType", "val": "alter", "operation": "replace" } }, - }}); + } + }); expect(autoAnswerableFlag("SetValue")).not.toBeDefined(); }); @@ -28,49 +30,49 @@ describe("Returns undefined and does not auto-answer any flag paths", () => { }); test("If the node does not have any flag paths (aka options)"); - const alteredFlow = structuredClone(flowWithFilter); - delete alteredFlow["Filter"].edges; - setState({ flow: alteredFlow }); + const alteredFlow = structuredClone(flowWithFilter); + delete alteredFlow["Filter"].edges; + setState({ flow: alteredFlow }); - expect(autoAnswerableFlag("Filter")).not.toBeDefined(); + expect(autoAnswerableFlag("Filter")).not.toBeDefined(); }); const flowWithFilter: Store.Flow = { "_root": { - "edges": [ - "Filter" - ] + "edges": [ + "Filter" + ] }, "Filter": { - "type": 500, - "data": { - "fn": "flag", - "category": "Material change of use" - }, - "edges": [ - "Flag1", - "Flag2", - "Flag3" - ] + "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" - } + "type": 200, + "data": { + "text": "Material change of use", + "val": "MCOU_TRUE" + } }, "Flag2": { - "type": 200, - "data": { - "text": "Not material change of use", - "val": "MCOU_FALSE" - } + "type": 200, + "data": { + "text": "Not material change of use", + "val": "MCOU_FALSE" + } }, "Flag3": { - "type": 200, - "data": { - "text": "No flag result" - } + "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 index a11cd2d9b1..17dd4d107a 100644 --- 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 @@ -1,17 +1,18 @@ -import { useStore } from "../../store"; +import { Store, useStore } from "../../store"; const { getState, setState } = useStore; const { resetPreview, autoAnswerableOptions } = getState(); -beforeEach(() => { - resetPreview(); -}); - // 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 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: { @@ -23,11 +24,30 @@ describe("Returns undefined and does not auto-answer any options", () => { expect(autoAnswerableOptions("SetValue")).not.toBeDefined(); }); - test.todo("If the node is a 'sticky note' Question without edges"); + test("If the node is a 'sticky note' Question without edges", () => { + const alteredFlow = structuredClone(singleNodeFlow); + delete alteredFlow["Question"]?.edges; + setState({ flow: alteredFlow }); - test.todo("If the node does not set a `fn`"); + 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 }); - test.todo("If we've never seen another node with this `fn` before"); + 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", () => { @@ -51,3 +71,37 @@ describe("Checklists", () => { test.todo("Auto-answer through the blank path when we have not seen this node `fn` but we have seen all possible option `val`"); }); + +const singleNodeFlow: Store.Flow = { + "_root": { + "edges": [ + "Question" + ] + }, + "Question": { + "type": 100, + "data": { + "fn": "direction", + "text": "Which direction?", + "forceSelection": 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 From 91c9dfd860f1b73f31ce72ace7747b071b457c44 Mon Sep 17 00:00:00 2001 From: Jessica McInchak Date: Tue, 29 Oct 2024 13:25:13 +0100 Subject: [PATCH 22/25] test suite using SetValue and forceSelection prop --- .../__tests__/automations.setValue.test.ts | 339 ++++++++++++++++++ .../src/pages/FlowEditor/lib/store/preview.ts | 2 +- 2 files changed, 340 insertions(+), 1 deletion(-) create mode 100644 editor.planx.uk/src/pages/FlowEditor/lib/__tests__/automations.setValue.test.ts 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..5feb917086 --- /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 `forceSelection` 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, + "forceSelection": 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 `forceSelection` 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?", + "forceSelection": 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, + "forceSelection": 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?", + "forceSelection": 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?", + "forceSelection": false + }, + "type": 100, + "edges": [ + "OptionFruitTypeLessGranularGrapes", + "OptionFruitTypeLessGranularBlank" + ] + }, + "InitialChecklistOptionBlank": { + "data": { + "text": "None of these" + }, + "type": 200 + }, + "QuestionFruitType": { + "data": { + "fn": "foods", + "tags": [], + "text": "Which fruit?", + "forceSelection": false + }, + "type": 100, + "edges": [ + "OptionFruitTypeBanana", + "OptionFruitTypeRedGrapes", + "OptionFruitTypeGreenGrapes" + ] + } +}; 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 fc2a4315b9..f1e1188248 100644 --- a/editor.planx.uk/src/pages/FlowEditor/lib/store/preview.ts +++ b/editor.planx.uk/src/pages/FlowEditor/lib/store/preview.ts @@ -445,7 +445,7 @@ export const previewStore: StateCreator< const { type, data, edges } = flow[id]; // Only Question & Checklist nodes that have an fn & edges are eligible for auto-answering - if (!type || !SUPPORTED_DECISION_TYPES.includes(type) || !data?.fn || !edges) return; + if (!type || !SUPPORTED_DECISION_TYPES.includes(type) || !data?.fn || !edges || data?.forceSelection) 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); From a60b5aad2761540d1c57139a36aad95a82d3c66f Mon Sep 17 00:00:00 2001 From: Jessica McInchak Date: Tue, 29 Oct 2024 13:46:10 +0100 Subject: [PATCH 23/25] final test suite --- .../preview/autoAnswerableOptions.test.ts | 363 ++++++++++++++++-- 1 file changed, 322 insertions(+), 41 deletions(-) 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 index 17dd4d107a..052fe91482 100644 --- 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 @@ -1,18 +1,20 @@ import { Store, useStore } from "../../store"; +import { clickContinue } from "../utils"; const { getState, setState } = useStore; -const { resetPreview, autoAnswerableOptions } = getState(); +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: { @@ -41,67 +43,346 @@ describe("Returns undefined and does not auto-answer any options", () => { }); test("If we've never seen another node with this `fn` before", () => { - setState({ - flow: singleNodeFlow, - breadcrumbs: {} + setState({ + flow: singleNodeFlow, + breadcrumbs: {} }); - + expect(autoAnswerableOptions("Question")).not.toBeDefined(); }); }); -describe("Questions", () => { - test.todo("Auto-answer the option that exactly matches a passport value"); +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"] }); - test.todo("Auto-answer the less granular option when there's a single more granular passport value and no more granular options available"); + expect(autoAnswerableOptions("QuestionFruit")).toEqual(["QuestionFruitYesOption"]); + }); - test.todo("Auto-answer the single most granular, left-most option when there are many matching passport values"); + 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"] }); - test.todo("Auto-answer through the blank path when we have seen this node `fn` but there are no matching passport values"); + // 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"] }); - test.todo("Auto-answer through the blank path when we have not seen this node `fn` but we have seen all possible option `val`"); -}); + 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({}); -describe("Checklists", () => { - test.todo("Auto-answer all options that exactly match passport values"); + expect(autoAnswerableOptions("QuestionChocolate")).toBeUndefined(); + }); - test.todo("Auto-answer all less granular options when there are more granular passport values and not more granular options available"); + 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"] }); - test.todo("Auto-answer through the blank path when we have seen this node `fn` but there are no matching passport values"); + expect(autoAnswerableOptions("QuestionChocolate")).toEqual(["QuestionChocolateOptionBlank"]); + }); - test.todo("Auto-answer through the blank path when we have not seen this node `fn` but we have seen all possible option `val`"); + 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?", + "forceSelection": 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, + "forceSelection": 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?", + "forceSelection": 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?", + "forceSelection": false + }, + "type": 100, + "edges": [ + "OptionFruitTypeLessGranularGrapes", + "OptionFruitTypeLessGranularBlank" + ] + }, + "InitialChecklistOptionBlank": { + "data": { + "text": "None of these" + }, + "type": 200 + }, + "QuestionFruitType": { + "data": { + "fn": "foods", + "tags": [], + "text": "Which fruit?", + "forceSelection": false + }, + "type": 100, + "edges": [ + "OptionFruitTypeBanana", + "OptionFruitTypeRedGrapes", + "OptionFruitTypeGreenGrapes" + ] + } +}; + const singleNodeFlow: Store.Flow = { "_root": { - "edges": [ - "Question" - ] + "edges": [ + "Question" + ] }, "Question": { - "type": 100, - "data": { - "fn": "direction", - "text": "Which direction?", - "forceSelection": false - }, - "edges": [ - "Option1", - "Option2" - ] + "type": 100, + "data": { + "fn": "direction", + "text": "Which direction?", + "forceSelection": false + }, + "edges": [ + "Option1", + "Option2" + ] }, "Option1": { - "type": 200, - "data": { - "text": "Left", - "val": "left" - } + "type": 200, + "data": { + "text": "Left", + "val": "left" + } }, "Option2": { - "type": 200, - "data": { - "text": "Right", - "val": "right" - } + "type": 200, + "data": { + "text": "Right", + "val": "right" + } } }; \ No newline at end of file From d9c94cfcac9e38564554462f1e95609da510b0da Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dafydd=20Ll=C5=B7r=20Pearson?= Date: Tue, 29 Oct 2024 13:42:19 +0000 Subject: [PATCH 24/25] fix: Apply `wasVisited` class to Filter node (#3869) --- .../src/pages/FlowEditor/components/Flow/components/Filter.tsx | 1 + 1 file changed, 1 insertion(+) 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, })} > Date: Tue, 29 Oct 2024 15:07:40 +0100 Subject: [PATCH 25/25] first round of PR feedback --- .../src/@planx/components/Checklist/Editor.tsx | 10 +++++----- .../src/@planx/components/Checklist/Public.tsx | 18 ++++++++---------- .../src/@planx/components/Checklist/model.ts | 2 +- .../src/@planx/components/Question/Editor.tsx | 10 +++++----- .../components/Question/Public/Question.tsx | 9 ++++----- .../src/@planx/components/Question/model.ts | 2 +- .../__tests__/automations.parentChild.test.ts | 6 +++--- .../automations.planningConstraintNots.test.ts | 6 +++--- .../lib/__tests__/automations.setValue.test.ts | 16 ++++++++-------- .../preview/autoAnswerableOptions.test.ts | 12 ++++++------ .../src/pages/FlowEditor/lib/store/preview.ts | 9 +++++---- 11 files changed, 49 insertions(+), 51 deletions(-) diff --git a/editor.planx.uk/src/@planx/components/Checklist/Editor.tsx b/editor.planx.uk/src/@planx/components/Checklist/Editor.tsx index 9157ecb376..c4585a46da 100644 --- a/editor.planx.uk/src/@planx/components/Checklist/Editor.tsx +++ b/editor.planx.uk/src/@planx/components/Checklist/Editor.tsx @@ -35,7 +35,7 @@ export interface ChecklistProps extends Checklist { node?: { data?: { allRequired?: boolean; - forceSelection?: boolean; + neverAutoAnswer?: boolean; categories?: Array; description?: string; fn?: string; @@ -286,7 +286,7 @@ export const ChecklistComponent: React.FC = (props) => { const formik = useFormik({ initialValues: { allRequired: props.node?.data?.allRequired || false, - forceSelection: props.node?.data?.forceSelection || false, + neverAutoAnswer: props.node?.data?.neverAutoAnswer || false, description: props.node?.data?.description || "", fn: props.node?.data?.fn || "", groupedOptions: props.groupedOptions, @@ -428,11 +428,11 @@ export const ChecklistComponent: React.FC = (props) => { formik.setFieldValue( - "forceSelection", - !formik.values.forceSelection, + "neverAutoAnswer", + !formik.values.neverAutoAnswer, ) } /> diff --git a/editor.planx.uk/src/@planx/components/Checklist/Public.tsx b/editor.planx.uk/src/@planx/components/Checklist/Public.tsx index e887f1c225..50a9827ca3 100644 --- a/editor.planx.uk/src/@planx/components/Checklist/Public.tsx +++ b/editor.planx.uk/src/@planx/components/Checklist/Public.tsx @@ -39,24 +39,23 @@ function toggleInArray(value: T, arr: Array): Array { } const ChecklistComponent: React.FC = (props) => { - if (props.forceSelection) { + if (props.neverAutoAnswer) { return ; } - + const autoAnswerableOptions = useStore( (state) => state.autoAnswerableOptions, ); - + let idsThatCanBeAutoAnswered: string[] | undefined; if (props.id) idsThatCanBeAutoAnswered = autoAnswerableOptions(props.id); - - if (idsThatCanBeAutoAnswered && idsThatCanBeAutoAnswered.length > 0) { + if (idsThatCanBeAutoAnswered) { return ( ); - } else { - return ; } + + return ; }; // An auto-answered Checklist won't be seen by the user, but still leaves a breadcrumb @@ -204,9 +203,8 @@ const VisibleChecklist: 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) => ( ; - forceSelection?: boolean; + neverAutoAnswer?: boolean; } interface ChecklistExpandableProps { diff --git a/editor.planx.uk/src/@planx/components/Question/Editor.tsx b/editor.planx.uk/src/@planx/components/Question/Editor.tsx index af08af92cc..26d2e5d4cc 100644 --- a/editor.planx.uk/src/@planx/components/Question/Editor.tsx +++ b/editor.planx.uk/src/@planx/components/Question/Editor.tsx @@ -26,7 +26,7 @@ interface Props { img?: string; text: string; type?: string; - forceSelection?: boolean; + neverAutoAnswer?: boolean; } & BaseNodeData; }; options?: Option[]; @@ -134,7 +134,7 @@ export const Question: React.FC = (props) => { img: props.node?.data?.img || "", options: props.options || [], text: props.node?.data?.text || "", - forceSelection: props.node?.data?.forceSelection || false, + neverAutoAnswer: props.node?.data?.neverAutoAnswer || false, ...parseBaseNodeData(props.node?.data), }, onSubmit: ({ options, ...values }) => { @@ -208,11 +208,11 @@ export const Question: React.FC = (props) => { formik.setFieldValue( - "forceSelection", - !formik.values.forceSelection, + "neverAutoAnswer", + !formik.values.neverAutoAnswer, ) } /> diff --git a/editor.planx.uk/src/@planx/components/Question/Public/Question.tsx b/editor.planx.uk/src/@planx/components/Question/Public/Question.tsx index 7008310ad1..5d504ddc74 100644 --- a/editor.planx.uk/src/@planx/components/Question/Public/Question.tsx +++ b/editor.planx.uk/src/@planx/components/Question/Public/Question.tsx @@ -26,7 +26,7 @@ export enum QuestionLayout { } const QuestionComponent: React.FC = (props) => { - if (props.forceSelection) { + if (props.neverAutoAnswer) { return ; } @@ -44,14 +44,13 @@ const QuestionComponent: React.FC = (props) => { let idsThatCanBeAutoAnswered: string[] | undefined; if (props.id) idsThatCanBeAutoAnswered = autoAnswerableOptions(props.id); - - if (idsThatCanBeAutoAnswered && idsThatCanBeAutoAnswered.length > 0) { + if (idsThatCanBeAutoAnswered) { return ( ); - } else { - return ; } + + return ; }; // An auto-answered Question won't be seen by the user, but still leaves a breadcrumb diff --git a/editor.planx.uk/src/@planx/components/Question/model.ts b/editor.planx.uk/src/@planx/components/Question/model.ts index 2ccab757e6..c5aef4d74a 100644 --- a/editor.planx.uk/src/@planx/components/Question/model.ts +++ b/editor.planx.uk/src/@planx/components/Question/model.ts @@ -8,7 +8,7 @@ export interface Question extends BaseNodeData { text?: string; description?: string; img?: string; - forceSelection?: boolean; + neverAutoAnswer?: boolean; responses: { id?: string; responseKey: string | number; 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 index bf16ab4d29..b03fb9c12a 100644 --- 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 @@ -102,7 +102,7 @@ const flow: Store.Flow = { "tags": [], "text": "Pick many", "allRequired": false, - "forceSelection": false + "neverAutoAnswer": false }, "type": 105, "edges": [ @@ -117,7 +117,7 @@ const flow: Store.Flow = { "tags": [], "text": "Pick many", "allRequired": false, - "forceSelection": false + "neverAutoAnswer": false }, "type": 105, "edges": [ @@ -173,7 +173,7 @@ const flow: Store.Flow = { "fn": "values", "tags": [], "text": "Pick one", - "forceSelection": false + "neverAutoAnswer": false }, "type": 100, "edges": [ 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 index 6d0afe0915..1f677455d8 100644 --- 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 @@ -79,7 +79,7 @@ const flow: Store.Flow = { "data": { "fn": "property.constraints.planning", "text": "Are you in a conservation area?", - "forceSelection": false, + "neverAutoAnswer": false, "tags": [] }, "edges": [ @@ -105,7 +105,7 @@ const flow: Store.Flow = { "data": { "fn": "property.constraints.planning", "text": "Do any Article 4 directions apply?", - "forceSelection": false + "neverAutoAnswer": false }, "edges": [ "Article4Yes", @@ -131,7 +131,7 @@ const flow: Store.Flow = { "description": "

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

", "fn": "property.constraints.planning", "text": "Are you in flood zone 1?", - "forceSelection": false + "neverAutoAnswer": false }, "edges": [ "FloodZone1Yes", 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 index 5feb917086..8f864e1fec 100644 --- 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 @@ -25,7 +25,7 @@ describe("Auto-answering based on a SetValue", () => { expect(autoAnswerableOptions("LastChecklistFood")).toEqual(["LastChecklistOptionChocolate"]); }); - test("A node using the `forceSelection` prop is not auto-answered and put to the user", () => { + test("A node using the `neverAutoAnswer` prop is not auto-answered and put to the user", () => { const alteredFlow = structuredClone(flow); Object.assign(alteredFlow, { "InitialChecklistFood": { @@ -34,7 +34,7 @@ describe("Auto-answering based on a SetValue", () => { "tags": [], "text": "Which foods do you want?", "allRequired": false, - "forceSelection": true // toggled to `true` + "neverAutoAnswer": true // toggled to `true` }, "type": 105, "edges": [ @@ -51,7 +51,7 @@ describe("Auto-answering based on a SetValue", () => { clickContinue("SetValueChocolate", { data: { "foods": ["chocolate"] }, auto: true }); expect(computePassport()?.data).toHaveProperty("foods"); - // Confirm that the `forceSelection` Checklist is not auto-answerable and manually proceed through + // 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" ] }); @@ -116,7 +116,7 @@ const flow: Store.Flow = { "fn": "foods", "tags": [], "text": "Which bread?", - "forceSelection": false + "neverAutoAnswer": false }, "type": 100, "edges": [ @@ -216,7 +216,7 @@ const flow: Store.Flow = { "tags": [], "text": "Which foods do you want?", "allRequired": false, - "forceSelection": false + "neverAutoAnswer": false }, "type": 105, "edges": [ @@ -265,7 +265,7 @@ const flow: Store.Flow = { "fn": "foods", "tags": [], "text": "Do you have chocolate?", - "forceSelection": false + "neverAutoAnswer": false }, "type": 100, "edges": [ @@ -308,7 +308,7 @@ const flow: Store.Flow = { "fn": "foods", "tags": [], "text": "Which kind of fruit do you have?", - "forceSelection": false + "neverAutoAnswer": false }, "type": 100, "edges": [ @@ -327,7 +327,7 @@ const flow: Store.Flow = { "fn": "foods", "tags": [], "text": "Which fruit?", - "forceSelection": false + "neverAutoAnswer": false }, "type": 100, "edges": [ 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 index 052fe91482..87d2e1d397 100644 --- 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 @@ -139,7 +139,7 @@ const flow: Store.Flow = { "fn": "foods", "tags": [], "text": "Which bread?", - "forceSelection": false + "neverAutoAnswer": false }, "type": 100, "edges": [ @@ -231,7 +231,7 @@ const flow: Store.Flow = { "tags": [], "text": "Which foods do you want?", "allRequired": false, - "forceSelection": false + "neverAutoAnswer": false }, "type": 105, "edges": [ @@ -280,7 +280,7 @@ const flow: Store.Flow = { "fn": "foods", "tags": [], "text": "Do you have chocolate?", - "forceSelection": false + "neverAutoAnswer": false }, "type": 100, "edges": [ @@ -323,7 +323,7 @@ const flow: Store.Flow = { "fn": "foods", "tags": [], "text": "Which kind of fruit do you have?", - "forceSelection": false + "neverAutoAnswer": false }, "type": 100, "edges": [ @@ -342,7 +342,7 @@ const flow: Store.Flow = { "fn": "foods", "tags": [], "text": "Which fruit?", - "forceSelection": false + "neverAutoAnswer": false }, "type": 100, "edges": [ @@ -364,7 +364,7 @@ const singleNodeFlow: Store.Flow = { "data": { "fn": "direction", "text": "Which direction?", - "forceSelection": false + "neverAutoAnswer": false }, "edges": [ "Option1", 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 f1e1188248..89a16a20bb 100644 --- a/editor.planx.uk/src/pages/FlowEditor/lib/store/preview.ts +++ b/editor.planx.uk/src/pages/FlowEditor/lib/store/preview.ts @@ -443,9 +443,10 @@ export const previewStore: StateCreator< 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?.forceSelection) return; + 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); @@ -469,7 +470,7 @@ export const previewStore: StateCreator< let optionsThatCanBeAutoAnswered: Array = []; // Get existing passport value(s) for this node's fn - let passportValues = computePassport().data?.[data.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 @@ -515,7 +516,7 @@ export const previewStore: StateCreator< // 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 = computePassport()?.data?.["_nots"]?.[data.fn]; + const nots: string[] | undefined = passportData?.["_nots"]?.[data.fn]; if (nots) visitedOptionVals = visitedOptionVals.concat(nots); } @@ -532,7 +533,7 @@ export const previewStore: StateCreator< }, /** - * Filters auto-answer based on a heirarchy of collected flags + * 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 */