From 9a9acf019580dbc3d571d3621f3788499e379723 Mon Sep 17 00:00:00 2001 From: Dan G Date: Thu, 31 Oct 2024 16:51:33 +0000 Subject: [PATCH 1/2] route Microsoft auth requests from pizzas to staging API (#3890) --- .github/workflows/pull-request.yml | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/.github/workflows/pull-request.yml b/.github/workflows/pull-request.yml index a4948c5fda..3180d927c9 100644 --- a/.github/workflows/pull-request.yml +++ b/.github/workflows/pull-request.yml @@ -213,8 +213,9 @@ jobs: VITE_APP_HASURA_WEBSOCKET: wss://hasura.${{ env.FULL_DOMAIN }}/v1/graphql VITE_APP_MAPBOX_ACCESS_TOKEN: ${{ secrets.MAPBOX_ACCESS_TOKEN }} VITE_APP_SHAREDB_URL: wss://sharedb.${{ env.FULL_DOMAIN }} - # needed because there's no API to change google's allowed OAuth URLs + # needed because there's no API to change google/microsoft's allowed redirect URIs VITE_APP_GOOGLE_OAUTH_OVERRIDE: https://api.editor.planx.dev + VITE_APP_MICROSOFT_OAUTH_OVERRIDE: https://api.editor.planx.dev VITE_APP_ENV: pizza working-directory: ${{ env.EDITOR_DIRECTORY }} - run: pnpm build-storybook @@ -230,6 +231,7 @@ jobs: VITE_APP_MAPBOX_ACCESS_TOKEN: ${{ secrets.MAPBOX_ACCESS_TOKEN }} VITE_APP_SHAREDB_URL: wss://sharedb.${{ env.FULL_DOMAIN }} VITE_APP_GOOGLE_OAUTH_OVERRIDE: https://api.editor.planx.dev + VITE_APP_MICROSOFT_OAUTH_OVERRIDE: https://api.editor.planx.dev VITE_APP_ENV: pizza pulumi_preview: From 8c5f127f8370ecc8d8b18d8c1d89dcf85117afdd Mon Sep 17 00:00:00 2001 From: Jessica McInchak Date: Fri, 1 Nov 2024 12:56:00 +0100 Subject: [PATCH 2/2] feat: allow options to assign many flags (#3820) --- .../@planx/components/Checklist/Editor.tsx | 41 +- .../src/@planx/components/Question/Editor.tsx | 32 +- .../@planx/components/shared/FlagsSelect.tsx | 87 +++++ .../components/shared/PermissionSelect.tsx | 40 -- .../src/@planx/components/shared/index.ts | 4 +- .../components/Flow/components/FlagBand.tsx | 31 ++ .../components/Flow/components/Option.tsx | 30 +- .../components/Sidebar/DebugConsole.tsx | 6 +- .../src/pages/FlowEditor/floweditor.scss | 5 - .../__tests__/preview/collectedFlags.test.ts | 154 ++++++++ .../__tests__/preview/getResultData.test.ts | 28 -- .../lib/__tests__/preview/resultData.test.ts | 220 +++++++++++ .../src/pages/FlowEditor/lib/store/preview.ts | 358 ++++++++++-------- 13 files changed, 745 insertions(+), 291 deletions(-) create mode 100644 editor.planx.uk/src/@planx/components/shared/FlagsSelect.tsx delete mode 100644 editor.planx.uk/src/@planx/components/shared/PermissionSelect.tsx create mode 100644 editor.planx.uk/src/pages/FlowEditor/components/Flow/components/FlagBand.tsx create mode 100644 editor.planx.uk/src/pages/FlowEditor/lib/__tests__/preview/collectedFlags.test.ts delete mode 100644 editor.planx.uk/src/pages/FlowEditor/lib/__tests__/preview/getResultData.test.ts create mode 100644 editor.planx.uk/src/pages/FlowEditor/lib/__tests__/preview/resultData.test.ts diff --git a/editor.planx.uk/src/@planx/components/Checklist/Editor.tsx b/editor.planx.uk/src/@planx/components/Checklist/Editor.tsx index 48eb498c13..28bbda8ce5 100644 --- a/editor.planx.uk/src/@planx/components/Checklist/Editor.tsx +++ b/editor.planx.uk/src/@planx/components/Checklist/Editor.tsx @@ -24,8 +24,8 @@ import InputRow from "ui/shared/InputRow"; import InputRowItem from "ui/shared/InputRowItem"; import { Option, parseBaseNodeData } from "../shared"; +import { FlagsSelect } from "../shared/FlagsSelect"; import { ICONS } from "../shared/icons"; -import PermissionSelect from "../shared/PermissionSelect"; import type { Checklist, Group } from "./model"; import { toggleExpandableChecklist } from "./model"; import { ChecklistProps, OptionEditorProps } from "./types"; @@ -68,20 +68,6 @@ const OptionEditor: React.FC = (props) => { }} /> - { - props.onChange({ - ...props.value, - data: { - ...props.value.data, - flag: ev.target.value as string, - }, - }); - }} - sx={{ width: { md: "160px" }, maxWidth: "160px" }} - /> - {typeof props.index !== "undefined" && props.groups && props.onMoveToGroup && ( @@ -117,6 +103,23 @@ const OptionEditor: React.FC = (props) => { /> )} + + { + props.onChange({ + ...props.value, + data: { + ...props.value.data, + flag: ev, + }, + }); + }} + /> ); }; @@ -171,7 +174,6 @@ const Options: React.FC<{ formik: FormikHookReturn }> = ({ formik }) => { text: "", description: "", val: "", - flag: "", }, }) as Option } @@ -245,7 +247,6 @@ const Options: React.FC<{ formik: FormikHookReturn }> = ({ formik }) => { text: "", description: "", val: "", - flag: "", }, }) as Option } @@ -295,9 +296,9 @@ export const ChecklistComponent: React.FC = (props) => { ...values, ...(groupedOptions ? { - categories: groupedOptions.map((gr) => ({ - title: gr.title, - count: gr.children.length, + categories: groupedOptions.map((group) => ({ + title: group.title, + count: group.children.length, })), } : { diff --git a/editor.planx.uk/src/@planx/components/Question/Editor.tsx b/editor.planx.uk/src/@planx/components/Question/Editor.tsx index a5855342e3..d9a33237e0 100644 --- a/editor.planx.uk/src/@planx/components/Question/Editor.tsx +++ b/editor.planx.uk/src/@planx/components/Question/Editor.tsx @@ -17,8 +17,8 @@ import InputRowItem from "ui/shared/InputRowItem"; import { InternalNotes } from "../../../ui/editor/InternalNotes"; import { MoreInformation } from "../../../ui/editor/MoreInformation/MoreInformation"; import { BaseNodeData, Option, parseBaseNodeData } from "../shared"; +import { FlagsSelect } from "../shared/FlagsSelect"; import { ICONS } from "../shared/icons"; -import PermissionSelect from "../shared/PermissionSelect"; interface Props { node: { @@ -62,7 +62,6 @@ const OptionEditor: React.FC<{ placeholder="Option" /> - { @@ -75,20 +74,6 @@ const OptionEditor: React.FC<{ }); }} /> - - { - props.onChange({ - ...props.value, - data: { - ...props.value.data, - flag: ev.target.value as string, - }, - }); - }} - sx={{ width: { md: "160px" }, maxWidth: "160px" }} - /> )} + { + props.onChange({ + ...props.value, + data: { + ...props.value.data, + flag: ev, + }, + }); + }} + /> ); @@ -154,7 +151,7 @@ export const Question: React.FC = (props) => { alert(JSON.stringify({ type, ...values, children }, null, 2)); } }, - validate: () => {}, + validate: () => { }, }); const focusRef = useRef(null); @@ -237,7 +234,6 @@ export const Question: React.FC = (props) => { text: "", description: "", val: "", - flag: "", }, }) as Option } diff --git a/editor.planx.uk/src/@planx/components/shared/FlagsSelect.tsx b/editor.planx.uk/src/@planx/components/shared/FlagsSelect.tsx new file mode 100644 index 0000000000..46832c0a39 --- /dev/null +++ b/editor.planx.uk/src/@planx/components/shared/FlagsSelect.tsx @@ -0,0 +1,87 @@ +import { + AutocompleteChangeReason, + AutocompleteProps, +} from "@mui/material/Autocomplete"; +import Chip from "@mui/material/Chip"; +import ListItem from "@mui/material/ListItem"; +import { Flag, flatFlags } from "@opensystemslab/planx-core/types"; +import React, { useMemo } from "react"; +import InputRow from "ui/shared/InputRow"; +import { CustomCheckbox, SelectMultiple } from "ui/shared/SelectMultiple"; + +interface Props { + value?: Array; + onChange: (values: Array) => void; +} + +const renderOptions: AutocompleteProps< + Flag, + true, + true, + false, + "div" +>["renderOption"] = (props, flag, { selected }) => ( + + +); + +const renderTags: AutocompleteProps< + Flag, + true, + true, + false, + "div" +>["renderTags"] = (value, getFlagProps) => + value.map((flag, index) => ( + + )); + +export const FlagsSelect: React.FC = (props) => { + const { value: initialFlagValues } = props; + + const value: Flag[] | undefined = useMemo( + () => + initialFlagValues?.flatMap((initialFlagValue) => + flatFlags.filter((flag) => flag.value === initialFlagValue), + ), + [initialFlagValues], + ); + + const handleChange = ( + _event: React.SyntheticEvent, + value: Flag[], + _reason: AutocompleteChangeReason, + ) => { + const selectedFlags = value.map((flag) => flag.value); + props.onChange(selectedFlags); + }; + + return ( + + flag.text} + groupBy={(flag) => flag.category} + onChange={handleChange} + isOptionEqualToValue={(flag, value) => flag.value === value.value} + value={value} + renderOption={renderOptions} + renderTags={renderTags} + /> + + ); +}; diff --git a/editor.planx.uk/src/@planx/components/shared/PermissionSelect.tsx b/editor.planx.uk/src/@planx/components/shared/PermissionSelect.tsx deleted file mode 100644 index a3fc31422c..0000000000 --- a/editor.planx.uk/src/@planx/components/shared/PermissionSelect.tsx +++ /dev/null @@ -1,40 +0,0 @@ -import MenuItem from "@mui/material/MenuItem"; -import { flatFlags } from "@opensystemslab/planx-core/types"; -import groupBy from "lodash/groupBy"; -import React from "react"; -import type { Props as SelectInputProps } from "ui/editor/SelectInput/SelectInput"; -import SelectInput from "ui/editor/SelectInput/SelectInput"; - -const flags = groupBy(flatFlags, (f) => f.category); - -const PermissionSelect: React.FC = (props) => { - // Material-ui doesn't like Fragments so this needs to be an array - const flagMenuItems = Object.entries(flags).flatMap( - ([category, categoryFlags]) => [ - - {category} - , - categoryFlags.map((flag) => ( - - {flag.text} - - )), - ], - ); - - return ( - - {Boolean(props.value) && Remove Flag} - {flagMenuItems} - - ); -}; - -export default PermissionSelect; diff --git a/editor.planx.uk/src/@planx/components/shared/index.ts b/editor.planx.uk/src/@planx/components/shared/index.ts index a33c5040d2..357548ee1e 100644 --- a/editor.planx.uk/src/@planx/components/shared/index.ts +++ b/editor.planx.uk/src/@planx/components/shared/index.ts @@ -1,4 +1,4 @@ -import { NodeTags } from "@opensystemslab/planx-core/types"; +import { Flag, NodeTags } from "@opensystemslab/planx-core/types"; import trim from "lodash/trim"; import { Store } from "pages/FlowEditor/lib/store"; @@ -28,7 +28,7 @@ export interface Option { id: string; data: { description?: string; - flag?: string; + flag?: Array; img?: string; text: string; val?: string; diff --git a/editor.planx.uk/src/pages/FlowEditor/components/Flow/components/FlagBand.tsx b/editor.planx.uk/src/pages/FlowEditor/components/Flow/components/FlagBand.tsx new file mode 100644 index 0000000000..c6aa613389 --- /dev/null +++ b/editor.planx.uk/src/pages/FlowEditor/components/Flow/components/FlagBand.tsx @@ -0,0 +1,31 @@ +import Box from "@mui/material/Box"; +import { Flag } from "@opensystemslab/planx-core/types"; +import React from "react"; + +export const FlagBand: React.FC<{ + flag: Flag; +}> = ({ flag }) => { + return ( + ({ + backgroundColor: `${flag?.bgColor || theme.palette.grey[800]}`, + borderBottom: `1px solid ${theme.palette.grey[400]}`, + width: "100%", + height: "12px", + })} + /> + ); +}; + +export const NoFlagBand: React.FC = () => { + return ( + ({ + backgroundColor: theme.palette.grey[700], + borderBottom: `1px solid ${theme.palette.grey[400]}`, + width: "100%", + height: "12px", + })} + /> + ); +} diff --git a/editor.planx.uk/src/pages/FlowEditor/components/Flow/components/Option.tsx b/editor.planx.uk/src/pages/FlowEditor/components/Flow/components/Option.tsx index 8f1802dd70..f3b9e0128d 100644 --- a/editor.planx.uk/src/pages/FlowEditor/components/Flow/components/Option.tsx +++ b/editor.planx.uk/src/pages/FlowEditor/components/Flow/components/Option.tsx @@ -1,4 +1,4 @@ -import { flatFlags } from "@opensystemslab/planx-core/types"; +import { Flag, flatFlags } from "@opensystemslab/planx-core/types"; import classNames from "classnames"; import React from "react"; import { Link } from "react-navi"; @@ -8,21 +8,31 @@ import { DataField } from "./DataField"; import Hanger from "./Hanger"; import Node from "./Node"; import { Thumbnail } from "./Thumbnail"; +import { FlagBand, NoFlagBand } from "./FlagBand"; const Option: React.FC = (props) => { const childNodes = useStore((state) => state.childNodesOf(props.id)); const href = ""; - - let background = "#666"; // no flag color - let color = "#000"; + let flags: Flag[] | undefined; try { - const flag = flatFlags.find(({ value }) => - [props.data?.flag, props.data?.val].filter(Boolean).includes(value), - ); - background = flag?.bgColor || background; - color = flag?.color || color; + // Question & Checklist Options set zero or many flag values under "data.flag" + if (props.data?.flag) { + if (Array.isArray(props.data?.flag)) { + flags = flatFlags.filter(({ value }) => props.data?.flag?.includes(value)); + } else { + flags = flatFlags.filter(({ value }) => props.data?.flag === value); + } + } + + // Filter Options set single flag value under "data.val" (Questions & Checklists use this same field for passport values) + if (props.data?.val) { + const flagValues = flatFlags.map((flag) => flag.value).filter(Boolean); + if (flagValues.includes(props.data.val)) { + flags = flatFlags.filter(({ value }) => props.data.val === value); + } + } } catch (e) {} return ( @@ -36,7 +46,7 @@ const Option: React.FC = (props) => { imageAltText={props.data.text} /> )} -
+ {flags ? flags.map((flag) => ) : }
{props.data.text}
{props.data?.val && } diff --git a/editor.planx.uk/src/pages/FlowEditor/components/Sidebar/DebugConsole.tsx b/editor.planx.uk/src/pages/FlowEditor/components/Sidebar/DebugConsole.tsx index 7e9448e1e6..3930a61365 100644 --- a/editor.planx.uk/src/pages/FlowEditor/components/Sidebar/DebugConsole.tsx +++ b/editor.planx.uk/src/pages/FlowEditor/components/Sidebar/DebugConsole.tsx @@ -15,9 +15,10 @@ const Console = styled(Box)(({ theme }) => ({ })); export const DebugConsole = () => { - const [passport, breadcrumbs, flowId, cachedBreadcrumbs] = useStore( + const [passport, flags, breadcrumbs, flowId, cachedBreadcrumbs] = useStore( (state) => [ state.computePassport(), + state.collectedFlags(), state.breadcrumbs, state.id, state.cachedBreadcrumbs, @@ -27,8 +28,9 @@ export const DebugConsole = () => {
{ + beforeEach(() => { + resetPreview(); + }); + + test("Correctly collects flags when an option's `data.flag` prop is the new array format", () => { + setState({ flow: flowWithNewFlags }); + + // Manually proceed through a Question that sets multiple flags on a single option + clickContinue("Question", { answers: ["YesOption"], auto: false }); + + expect(collectedFlags()).toEqual({ + "Community infrastructure levy": ["Relief"], // Flag value translated to display text + "Demolition in a conservation area": [], + "Listed building consent": [], + "Material change of use": [], + "Planning permission": ["Prior approval", "Notice"], // Many flags in same category are ordered highest to lowest, even if selected in opposite order + "Planning policy": ["Edge case"], + "Works to trees & hedges": [], + }); + }); + + test("Correctly collects flags when an option's `data.flag` prop is the legacy string format", () => { + setState({ flow: flowWithLegacyFlags }); + + // Manually proceed a Checklist and select every option that sets a single flag + clickContinue("Checklist", { + answers: [ + "PPImmuneOption", + "PPImmune2Option", + "PPPDOption", + "WTTRequiredOption", + "MCUYesOption", + ], + auto: false, + }); + + expect(collectedFlags()).toEqual({ + "Community infrastructure levy": [], + "Demolition in a conservation area": [], + "Listed building consent": [], + "Material change of use": ["Material change of use"], + "Planning permission": ["Immune", "Permitted development"], // Multiple flags of same value have been de-deduplicated + "Planning policy": [], + "Works to trees & hedges": ["Required"], + }); + }); + + test("Returns empty arrays for each category if no flags have been collected", () => { + setState({ flow: flowWithNewFlags }); + + // Manually proceed through a Question option without flags + clickContinue("Question", { answers: ["NoOption"], auto: false }); + + expect(collectedFlags()).toEqual({ + "Community infrastructure levy": [], + "Demolition in a conservation area": [], + "Listed building consent": [], + "Material change of use": [], + "Planning permission": [], + "Planning policy": [], + "Works to trees & hedges": [], + }); + }); +}); + +const flowWithNewFlags: Store.Flow = { + _root: { + edges: ["Question"], + }, + Question: { + type: 100, + data: { + text: "Pick up flags?", + neverAutoAnswer: false, + }, + edges: ["YesOption", "NoOption"], + }, + YesOption: { + type: 200, + data: { + text: "Yes", + flag: ["PP-NOTICE", "EDGE_CASE", "CO_RELIEF", "PRIOR_APPROVAL"], // `flag` is an array for freshly created/updated Question & Checklist options + }, + }, + NoOption: { + type: 200, + data: { + text: "No", + }, + }, +}; + +const flowWithLegacyFlags: Store.Flow = { + _root: { + edges: ["Checklist"], + }, + Checklist: { + type: 105, + data: { + allRequired: false, + neverAutoAnswer: false, + text: "Pick up flags?", + }, + edges: [ + "PPImmuneOption", + "PPImmune2Option", + "PPPDOption", + "WTTRequiredOption", + "MCUYesOption", + ], + }, + PPImmuneOption: { + data: { + text: "PP Immune", + flag: "IMMUNE", + }, + type: 200, + }, + PPImmune2Option: { + data: { + text: "PP Immune again", + flag: "IMMUNE", + }, + type: 200, + }, + PPPDOption: { + data: { + text: "PP Permitted dev", + flag: "NO_APP_REQUIRED", + }, + type: 200, + }, + WTTRequiredOption: { + data: { + text: "WTT Required", + flag: "TR-REQUIRED", + }, + type: 200, + }, + MCUYesOption: { + data: { + text: "MCU Yes", + flag: "MCOU_TRUE", + }, + type: 200, + }, +}; diff --git a/editor.planx.uk/src/pages/FlowEditor/lib/__tests__/preview/getResultData.test.ts b/editor.planx.uk/src/pages/FlowEditor/lib/__tests__/preview/getResultData.test.ts deleted file mode 100644 index 47f4342b22..0000000000 --- a/editor.planx.uk/src/pages/FlowEditor/lib/__tests__/preview/getResultData.test.ts +++ /dev/null @@ -1,28 +0,0 @@ -import { getResultData } from "../../store/preview"; - -test("Default result data", () => { - const result = getResultData({}, {}); - - expect(result).toEqual({ - "Planning permission": { - displayText: { description: "Planning permission", heading: "No result" }, - flag: { - bgColor: "#EEEEEE", - category: "Planning permission", - color: "#000000", - text: "No result", - value: undefined, - description: "", - }, - responses: [], - }, - }); -}); - -test.todo("Returns correct result based on collected flags"); - -test.todo( - "Returns correct, custom text based on collected flags and overrides", -); - -test.todo("Returns result data for flagsets beyond `Planning permission`"); diff --git a/editor.planx.uk/src/pages/FlowEditor/lib/__tests__/preview/resultData.test.ts b/editor.planx.uk/src/pages/FlowEditor/lib/__tests__/preview/resultData.test.ts new file mode 100644 index 0000000000..5cc4e36b03 --- /dev/null +++ b/editor.planx.uk/src/pages/FlowEditor/lib/__tests__/preview/resultData.test.ts @@ -0,0 +1,220 @@ +import { Store, useStore } from "../../store"; +import { clickContinue } from "../utils"; + +const { getState, setState } = useStore; +const { resultData, resetPreview, collectedFlags } = getState(); + +describe("Default result data when no flags have been collected", () => { + test("Returns the `No result` path for the `Planning permission` flagset category", () => { + expect(resultData()).toEqual({ + "Planning permission": { + displayText: { + description: "Planning permission", + heading: "No result", + }, + flag: { + bgColor: "#EEEEEE", + category: "Planning permission", + color: "#000000", + text: "No result", + value: undefined, + description: "", + }, + responses: [], + }, + }); + }); +}); + +describe("Computing result data based on collected flags", () => { + beforeEach(() => { + resetPreview(); + setState({ flow }); + }); + + test("Result is correct when many flags in the same category have been collected", () => { + // Manually proceed through two Questions plus a TextInput + clickContinue("ListedBuildingQuestion", { + answers: ["ListedBuildingOptionYes"], + auto: false, + }); + clickContinue("MaterialQuestion", { + answers: ["MaterialOptionNo"], + auto: false, + }); + clickContinue("TextInput", { data: { name: "Test" }, auto: false }); + + const category = "Planning permission"; + const collectedPPFlags = collectedFlags()?.[category]; + expect(collectedPPFlags).toEqual([ + "Permission needed", + "Permitted development", + ]); + + // The result should be the first flag + expect(resultData(category)?.[category]?.displayText).toEqual({ + description: category, + heading: collectedPPFlags[0], + }); + expect(resultData(category)?.[category]?.flag).toEqual({ + bgColor: "#A8A8A8", + category: category, + color: "#000000", + text: collectedPPFlags[0], + value: "PLANNING_PERMISSION_REQUIRED", + description: + "It looks like the proposed changes may require planning permission.", + }); + expect(resultData(category)?.[category]?.responses).toHaveLength(2); // TextInput is omitted + }); + + test("Result is correct when a single flag in the category has been collected", () => { + // Manually proceed through two Questions plus a TextInput + clickContinue("ListedBuildingQuestion", { + answers: ["ListedBuildingOptionYes"], + auto: false, + }); + clickContinue("MaterialQuestion", { + answers: ["MaterialOptionNo"], + auto: false, + }); + clickContinue("TextInput", { data: { name: "Test" }, auto: false }); + + const category = "Listed building consent"; + const collectedLBCFlags = collectedFlags()?.[category]; + expect(collectedLBCFlags).toEqual(["Required"]); + + // The result should be the first flag + expect(resultData(category)?.[category]?.displayText).toEqual({ + description: category, + heading: collectedLBCFlags[0], + }); + expect(resultData(category)?.[category]?.flag).toEqual({ + bgColor: "#76B4E5", + category: category, + color: "#000000", + text: collectedLBCFlags[0], + value: "LB-REQUIRED", + }); + expect(resultData(category)?.[category]?.responses).toHaveLength(2); // TextInput is omitted + }); + + test("Result displays editor overrides if configured", () => { + // Manually proceed through two Questions plus a TextInput + clickContinue("ListedBuildingQuestion", { + answers: ["ListedBuildingOptionYes"], + auto: false, + }); + clickContinue("MaterialQuestion", { + answers: ["MaterialOptionNo"], + auto: false, + }); + clickContinue("TextInput", { data: { name: "Test" }, auto: false }); + + const category = "Listed building consent"; + const collectedLBCFlags = collectedFlags()?.[category]; + expect(collectedLBCFlags).toEqual(["Required"]); + + // The result should be the first flag + const editorOverrides = { + "LB-REQUIRED": { + description: "This is a custom description", + }, + }; + expect( + resultData(category, editorOverrides)?.[category]?.displayText, + ).toEqual({ + description: "This is a custom description", + heading: collectedLBCFlags[0], + }); + }); +}); + +const flow: Store.Flow = { + _root: { + edges: [ + "ListedBuildingQuestion", + "MaterialQuestion", + "TextInput", + "PlanningPermissionResult", + "ListedBuildingConsentResult", + "MaterialChangeOfUseResult", + ], + }, + MaterialOptionNo: { + data: { + flag: ["MCOU_FALSE", "NO_APP_REQUIRED"], + text: "No", + }, + type: 200, + }, + PlanningPermissionResult: { + data: { + flagSet: "Planning permission", + }, + type: 3, + }, + ListedBuildingOptionNo: { + data: { + flag: ["LB-NOT_REQUIRED"], + text: "No", + }, + type: 200, + }, + MaterialChangeOfUseResult: { + data: { + flagSet: "Material change of use", + }, + type: 3, + }, + TextInput: { + data: { + fn: "name", + type: "short", + title: "What's your name? ", + }, + type: 110, + }, + MaterialQuestion: { + data: { + tags: [], + text: "Are you changing the external cladding?", + neverAutoAnswer: false, + }, + type: 100, + edges: ["MaterialOptionYes", "MaterialOptionNo"], + }, + ListedBuildingConsentResult: { + data: { + flagSet: "Listed building consent", + overrides: { + "LB-REQUIRED": { + description: "This is a custom description", + }, + }, + }, + type: 3, + }, + ListedBuildingQuestion: { + data: { + text: "Is it a Listed Building?", + neverAutoAnswer: false, + }, + type: 100, + edges: ["ListedBuildingOptionYes", "ListedBuildingOptionNo"], + }, + MaterialOptionYes: { + data: { + flag: ["MCOU_TRUE", "PLANNING_PERMISSION_REQUIRED"], + text: "Yes", + }, + type: 200, + }, + ListedBuildingOptionYes: { + data: { + flag: ["PLANNING_PERMISSION_REQUIRED", "LB-REQUIRED"], + text: "Yes", + }, + type: 200, + }, +}; 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 8a3c67343b..17df0db068 100644 --- a/editor.planx.uk/src/pages/FlowEditor/lib/store/preview.ts +++ b/editor.planx.uk/src/pages/FlowEditor/lib/store/preview.ts @@ -6,9 +6,9 @@ import type { NodeId, } 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"; @@ -23,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"; @@ -39,11 +39,20 @@ export interface Response { hidden: boolean; } +interface ResultData { + [category: string]: { + flag: Flag; + responses: Array; + displayText: { heading: string; description: string }; + }; +} + +interface CollectedFlags { + [category: string]: Array; +} + export interface PreviewStore extends Store.Store { - collectedFlags: ( - upToNodeId: NodeId, - visited?: Array, - ) => Array; + collectedFlags: () => CollectedFlags; currentCard: ({ id: NodeId } & Store.Node) | null; setCurrentCard: () => void; getCurrentCard: () => ({ id: NodeId } & Store.Node) | null; @@ -61,13 +70,7 @@ export interface PreviewStore extends Store.Store { overrides?: { [flagId: string]: { heading?: string; description?: string }; }, - ) => { - [category: string]: { - flag: Flag; - responses: Array; - displayText: { heading: string; description: string }; - }; - }; + ) => ResultData; resumeSession: (session: Session) => void; sessionId: string; upcomingCardIds: () => NodeId[]; @@ -110,39 +113,28 @@ export const previewStore: StateCreator< record(id, undefined); }, - collectedFlags(upToNodeId, visited = []) { + collectedFlags() { const { breadcrumbs, flow } = get(); + const collectedFlags: CollectedFlags = {}; - const possibleFlags = flatFlags.filter( - (f) => f.category === DEFAULT_FLAG_CATEGORY, - ); - const flagKeys: string[] = possibleFlags - .map((flag) => flag.value) - .filter((value): value is string => Boolean(value)); - - const breadcrumbIds = Object.keys(breadcrumbs); - - const idx = breadcrumbIds.indexOf(upToNodeId); - - let ids: Array = []; - - if (idx >= 0) { - ids = breadcrumbIds.slice(0, idx + 1); - } else if (visited.length > 1 && visited.includes(upToNodeId)) { - ids = breadcrumbIds.filter((id) => visited.includes(id)); - } - - const res = ids - .reduce((acc, k) => { - breadcrumbs[k].answers?.forEach((id: string) => { - acc.push(flow[id]?.data?.flag); - }); - return acc; - }, [] as Array) - .filter((flag) => flag && flagKeys.includes(flag)) - .sort((a, b) => flagKeys.indexOf(a) - flagKeys.indexOf(b)); + const categories = [...new Set(flatFlags.map((flag) => flag.category))]; + categories.forEach((category) => { + const flagValues = collectedFlagValuesByCategory( + category, + breadcrumbs, + flow, + ); + let flagText: (string | undefined)[] = []; + if (flagValues.length > 0) { + flagText = flagValues.map( + (flagValue) => + flatFlags.find((flag) => flag.value === flagValue)?.text, + ); + } + collectedFlags[category] = flagText; + }); - return res; + return collectedFlags; }, setCurrentCard() { @@ -382,7 +374,74 @@ export const previewStore: StateCreator< resultData(flagSet, overrides) { const { breadcrumbs, flow } = get(); - return getResultData(breadcrumbs, flow, flagSet, overrides); + const category = flagSet || DEFAULT_FLAG_CATEGORY; + + const possibleFlags: Flag[] = flatFlags.filter( + (flag) => flag.category === category, + ); + const collectedFlags = collectedFlagValuesByCategory( + category, + breadcrumbs, + flow, + ); + + // The highest order flag collected in this category is our result, else "No result" + const flag: Flag = possibleFlags.find( + (f) => f.value === collectedFlags[0], + ) || { + value: undefined, + text: "No result", + category: category as FlagSet, + bgColor: "#EEEEEE", + color: "#000000", + description: "", + }; + + // Get breadcrumb nodes that set the result flag value (limited to Question & Checklist types) + const responses = Object.entries(breadcrumbs) + .map(([nodeId, { answers = [] }]) => { + const question = { id: nodeId, ...flow[nodeId] }; + const questionType = question?.type; + if (!questionType || !SUPPORTED_DECISION_TYPES.includes(questionType)) + return null; + + const selections = answers.map((answerId) => ({ + id: answerId, + ...flow[answerId], + })); + const hidden = !selections.some( + (selection) => + selection.data?.flag && + // Account for both new flag values (array) and legacy flag value (string) + ((Array.isArray(selection.data.flag) && + selection.data.flag.includes(flag?.value)) || + selection.data.flag === flag?.value), + ); + + return { + question, + selections, + hidden, + }; + }) + .filter(Boolean); + + // Get the heading & description for this result flag + const heading = + (flag.value && overrides && overrides[flag.value]?.heading) || flag.text; + const description = + (flag.value && overrides && overrides[flag.value]?.description) || + category; + + return { + [category]: { + flag, + displayText: { heading, description }, + responses: responses.every((response) => Boolean(response?.hidden)) + ? responses.map((response) => ({ ...response, hidden: false })) + : responses, + }, + } as ResultData; }, resumeSession(session: Session) { @@ -438,7 +497,7 @@ export const previewStore: StateCreator< /** * 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) + * @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(); @@ -446,10 +505,19 @@ export const previewStore: StateCreator< const { data: passportData } = computePassport(); // Only Question & Checklist nodes that have an fn & edges are eligible for auto-answering - if (!type || !SUPPORTED_DECISION_TYPES.includes(type) || !data?.fn || !edges || data?.neverAutoAnswer) return; + 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); + 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 @@ -472,14 +540,15 @@ export const previewStore: StateCreator< // Get existing passport value(s) for this node's fn const 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), + // If we have existing passport value(s) for this fn in an eligible automation format (eg not numbers or plain strings), // then proceed through the matching option(s) or the blank option independent if other vals have been seen before if (Array.isArray(passportValues) && passportValues.length > 0) { // Check if the existing passport value(s) startsWith at least one option's val (eg passport retains most granular values only) - const matchingPassportValues = passportValues.filter((passportValue: any) => - sortedOptions.some((option) => - passportValue?.startsWith(option.data?.val), - ), + const matchingPassportValues = passportValues.filter( + (passportValue: any) => + sortedOptions.some( + (option) => passportValue?.startsWith(option.data?.val), + ), ); if (matchingPassportValues.length > 0) { @@ -487,12 +556,15 @@ export const previewStore: StateCreator< 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); foundExactMatch = true; - } else if (passportValue.startsWith(option.data?.val) && !foundExactMatch) { + } else if ( + passportValue.startsWith(option.data?.val) && + !foundExactMatch + ) { if (option.id) optionsThatCanBeAutoAnswered.push(option.id); } }); @@ -503,25 +575,35 @@ export const previewStore: StateCreator< } 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); + const sortedOptionVals: string[] = sortedOptions.map( + (option) => option.data?.val, + ); let visitedOptionVals: string[] = []; visitedFns.forEach(([nodeId, _breadcrumb]) => { flow[nodeId].edges?.map((edgeId) => { if (flow[edgeId].type === TYPES.Answer && flow[edgeId].data?.val) { visitedOptionVals.push(flow[edgeId].data.val); } - }) + }); }); // Planning Constraints use a bespoke "_nots" data structure to describe all option vals returned via GIS API // Concat these onto other visitedOptionVals so that questions about constraints we haven't fetched are put to user exactly once - if (visitedFns.some(([nodeId, _breadcrumb]) => flow[nodeId].type === TYPES.PlanningConstraints)) { + if ( + visitedFns.some( + ([nodeId, _breadcrumb]) => + flow[nodeId].type === TYPES.PlanningConstraints, + ) + ) { const nots: string[] | undefined = passportData?.["_nots"]?.[data.fn]; if (nots) visitedOptionVals = visitedOptionVals.concat(nots); } - const hasVisitedEveryOption = sortedOptionVals.every(value => visitedOptionVals.includes(value)); - if (blankOption?.id && hasVisitedEveryOption) optionsThatCanBeAutoAnswered.push(blankOption.id); + const hasVisitedEveryOption = sortedOptionVals.every((value) => + visitedOptionVals.includes(value), + ); + if (blankOption?.id && hasVisitedEveryOption) + optionsThatCanBeAutoAnswered.push(blankOption.id); } // Questions 'select one' and therefore can only auto-answer the single left-most matching option @@ -529,7 +611,9 @@ export const previewStore: StateCreator< optionsThatCanBeAutoAnswered = optionsThatCanBeAutoAnswered.slice(0, 1); } - return optionsThatCanBeAutoAnswered.length > 0 ? optionsThatCanBeAutoAnswered : undefined; + return optionsThatCanBeAutoAnswered.length > 0 + ? optionsThatCanBeAutoAnswered + : undefined; }, /** @@ -553,24 +637,13 @@ export const previewStore: StateCreator< // "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 collectedFlags = collectedFlagValuesByCategory( + filterCategory, + breadcrumbs, + flow, ); - 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 + // Starting from the left of the Filter options, check for matches against collectedFlags options.forEach((option) => { collectedFlags.forEach((flag) => { if (option.data?.val === flag && option.id) { @@ -706,93 +779,6 @@ export const removeOrphansFromBreadcrumbs = ({ ); }; -export const getResultData = ( - breadcrumbs: Store.Breadcrumbs, - flow: Store.Flow, - flagSet: Parameters[0] = DEFAULT_FLAG_CATEGORY, - overrides?: Parameters[1], -) => { - const categories = [flagSet]; - - return categories.reduce( - ( - acc: { - [category: string]: { - flag: Flag; - responses: any[]; - displayText: { heading: string; description: string }; - }; - }, - category: string, - ) => { - // might DRY this up with preceding collectedFlags function - const possibleFlags: Flag[] = flatFlags.filter( - (f) => f.category === category, - ); - const keys = possibleFlags.map((f) => f.value); - const collectedFlags = Object.values(breadcrumbs).flatMap( - ({ answers = [] }) => answers.map((id) => flow[id]?.data?.flag), - ); - - const filteredCollectedFlags = collectedFlags - .filter((flag) => flag && keys.includes(flag)) - .sort((a, b) => keys.indexOf(a) - keys.indexOf(b)); - - const flag: Flag = possibleFlags.find( - (f) => f.value === filteredCollectedFlags[0], - ) || { - // value: "PP-NO_RESULT", - value: undefined, - text: "No result", - category: category as FlagSet, - bgColor: "#EEEEEE", - color: "#000000", - description: "", - }; - - const responses = Object.entries(breadcrumbs) - .map(([k, { answers = [] }]) => { - const question = { id: k, ...flow[k] }; - - const questionType = question?.type; - - if (!questionType || !SUPPORTED_DECISION_TYPES.includes(questionType)) - return null; - - const selections = answers.map((id) => ({ id, ...flow[id] })); - const hidden = !selections.some( - (r) => r.data?.flag && r.data.flag === flag?.value, - ); - - return { - question, - selections, - hidden, - }; - }) - .filter(Boolean); - - const heading = - (flag.value && overrides && overrides[flag.value]?.heading) || - flag.text; - const description = - (flag.value && overrides && overrides[flag.value]?.description) || - flagSet; - - acc[category] = { - flag, - displayText: { heading, description }, - responses: responses.every((r: any) => r.hidden) - ? responses.map((r: any) => ({ ...r, hidden: false })) - : responses, - }; - - return acc; - }, - {}, - ); -}; - export const sortBreadcrumbs = ( nextBreadcrumbs: Store.Breadcrumbs, flow: Store.Flow, @@ -801,9 +787,9 @@ export const sortBreadcrumbs = ( return editingNodes?.length ? nextBreadcrumbs : sortIdsDepthFirst(flow)(new Set(Object.keys(nextBreadcrumbs))).reduce( - (acc, id) => ({ ...acc, [id]: nextBreadcrumbs[id] }), - {} as Store.Breadcrumbs, - ); + (acc, id) => ({ ...acc, [id]: nextBreadcrumbs[id] }), + {} as Store.Breadcrumbs, + ); }; function handleNodesWithPassport({ @@ -885,3 +871,43 @@ export const removeNodesDependentOnPassport = ( }, [] as string[]); return { removedNodeIds, breadcrumbsWithoutPassportData: newBreadcrumbs }; }; + +const collectedFlagValuesByCategory = ( + category: Parameters[0] = DEFAULT_FLAG_CATEGORY, + breadcrumbs: Store.Breadcrumbs, + flow: Store.Flow, +): Array => { + // Get all possible flag values for this flagset category + const possibleFlags = flatFlags.filter((flag) => flag.category === category); + 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: Array = []; + Object.entries(breadcrumbs).forEach(([_nodeId, breadcrumb]) => { + if (breadcrumb.answers) { + breadcrumb.answers.forEach((answerId) => { + const node = flow[answerId]; + // Account for both new flag values (array) and legacy flag value (string) + if (node.data?.flag && Array.isArray(node.data.flag)) { + node.data.flag.forEach((flag) => { + if (possibleFlagValues.includes(flag)) collectedFlags.push(flag); + }); + } else if ( + node.data?.flag && + possibleFlagValues.includes(node.data.flag) + ) { + collectedFlags.push(node.data.flag); + } + }); + } + }); + + // Return de-duplicated collected flags in hierarchical order + return [ + ...new Set( + collectedFlags.sort( + (a, b) => possibleFlagValues.indexOf(a) - possibleFlagValues.indexOf(b), + ), + ), + ]; +};