diff --git a/editor.planx.uk/.eslintrc b/editor.planx.uk/.eslintrc index 98bfbe6b35..b60021606e 100644 --- a/editor.planx.uk/.eslintrc +++ b/editor.planx.uk/.eslintrc @@ -87,7 +87,15 @@ } } ], - "no-nested-ternary": "error" + "no-nested-ternary": "error", + "@vitest/expect-expect": [ + "error", + { + "assertFunctionNames": [ + "expect" + ] + } + ] }, "overrides": [ { diff --git a/editor.planx.uk/package.json b/editor.planx.uk/package.json index 72ed189a52..28beeb4d1a 100644 --- a/editor.planx.uk/package.json +++ b/editor.planx.uk/package.json @@ -14,7 +14,7 @@ "@mui/lab": "5.0.0-alpha.170", "@mui/material": "^5.15.10", "@mui/utils": "^5.15.11", - "@opensystemslab/map": "1.0.0-alpha.3", + "@opensystemslab/map": "1.0.0-alpha.4", "@opensystemslab/planx-core": "git+https://github.com/theopensystemslab/planx-core#54be9e0", "@tiptap/core": "^2.4.0", "@tiptap/extension-bold": "^2.0.3", diff --git a/editor.planx.uk/pnpm-lock.yaml b/editor.planx.uk/pnpm-lock.yaml index 771b217c4a..87ae1a3022 100644 --- a/editor.planx.uk/pnpm-lock.yaml +++ b/editor.planx.uk/pnpm-lock.yaml @@ -44,8 +44,8 @@ dependencies: specifier: ^5.15.11 version: 5.15.11(@types/react@18.2.45)(react@18.2.0) '@opensystemslab/map': - specifier: 1.0.0-alpha.3 - version: 1.0.0-alpha.3 + specifier: 1.0.0-alpha.4 + version: 1.0.0-alpha.4 '@opensystemslab/planx-core': specifier: git+https://github.com/theopensystemslab/planx-core#54be9e0 version: github.com/theopensystemslab/planx-core/54be9e0(@types/react@18.2.45) @@ -3842,8 +3842,8 @@ packages: resolution: {integrity: sha512-XuySG1E38YScSJoMlqovLru4KTUNSjgVTIjyh7qMX6aNN5HY5Ct5LhRJdxO79JtTzKfzV/bnWpz+zquYrISsvw==} dev: true - /@opensystemslab/map@1.0.0-alpha.3: - resolution: {integrity: sha512-mwOBKiq0DWNOvYv7XiSkTYbBFdoRZD6jXnk1Kh9FPSFeP2FftDDusJzSK25k0rSMd7bqnnQ0O0l7X9hBucBzIg==} + /@opensystemslab/map@1.0.0-alpha.4: + resolution: {integrity: sha512-HNvJkw43hmVqCeXIhZhkuT2YWQEWE/yXKmohATv9TNMRf1e9rOi4JtNg094vrtiZ9L5Od+OfM+8tahlCkV9RNw==} dependencies: '@turf/union': 7.1.0 accessible-autocomplete: 2.0.4 diff --git a/editor.planx.uk/src/@planx/components/AddressInput/Editor.tsx b/editor.planx.uk/src/@planx/components/AddressInput/Editor.tsx index a118beeafe..e7bfef588d 100644 --- a/editor.planx.uk/src/@planx/components/AddressInput/Editor.tsx +++ b/editor.planx.uk/src/@planx/components/AddressInput/Editor.tsx @@ -1,15 +1,12 @@ import { ComponentType as TYPES } from "@opensystemslab/planx-core/types"; -import { - EditorProps, - ICONS, -} from "@planx/components/ui"; +import { EditorProps, ICONS } from "@planx/components/ui"; import { useFormik } from "formik"; import React from "react"; import { ModalFooter } from "ui/editor/ModalFooter"; import ModalSection from "ui/editor/ModalSection"; import ModalSectionContent from "ui/editor/ModalSectionContent"; -import RichTextInput from "ui/editor/RichTextInput"; -import Input from "ui/shared/Input"; +import RichTextInput from "ui/editor/RichTextInput/RichTextInput"; +import Input from "ui/shared/Input/Input"; import InputRow from "ui/shared/InputRow"; import { AddressInput, parseAddressInput } from "./model"; diff --git a/editor.planx.uk/src/@planx/components/AddressInput/Public.tsx b/editor.planx.uk/src/@planx/components/AddressInput/Public.tsx index 94f4bc6204..c48adc4897 100644 --- a/editor.planx.uk/src/@planx/components/AddressInput/Public.tsx +++ b/editor.planx.uk/src/@planx/components/AddressInput/Public.tsx @@ -4,7 +4,7 @@ import type { PublicProps } from "@planx/components/ui"; import { useFormik } from "formik"; import React from "react"; import InputLabel from "ui/public/InputLabel"; -import Input from "ui/shared/Input"; +import Input from "ui/shared/Input/Input"; import InputRowItem from "ui/shared/InputRowItem"; import { ERROR_MESSAGE } from "../shared/constants"; diff --git a/editor.planx.uk/src/@planx/components/Calculate/Editor.tsx b/editor.planx.uk/src/@planx/components/Calculate/Editor.tsx index ef45074716..7937956891 100644 --- a/editor.planx.uk/src/@planx/components/Calculate/Editor.tsx +++ b/editor.planx.uk/src/@planx/components/Calculate/Editor.tsx @@ -10,7 +10,7 @@ import InputGroup from "ui/editor/InputGroup"; import { ModalFooter } from "ui/editor/ModalFooter"; import ModalSection from "ui/editor/ModalSection"; import ModalSectionContent from "ui/editor/ModalSectionContent"; -import Input from "ui/shared/Input"; +import Input from "ui/shared/Input/Input"; import InputRow from "ui/shared/InputRow"; import type { Calculate } from "./model"; diff --git a/editor.planx.uk/src/@planx/components/Calculate/logic.test.ts b/editor.planx.uk/src/@planx/components/Calculate/logic.test.ts index d8d96d0260..3194ae5a77 100644 --- a/editor.planx.uk/src/@planx/components/Calculate/logic.test.ts +++ b/editor.planx.uk/src/@planx/components/Calculate/logic.test.ts @@ -1,11 +1,9 @@ import { ComponentType as TYPES } from "@opensystemslab/planx-core/types"; +import { clickContinue, visitedNodes } from "pages/FlowEditor/lib/__tests__/utils"; import { Store, useStore } from "pages/FlowEditor/lib/store"; const { getState, setState } = useStore; -const { upcomingCardIds, resetPreview, record } = getState(); - -// Helper method -const visitedNodes = () => Object.keys(getState().breadcrumbs); +const { upcomingCardIds, resetPreview, autoAnswerableOptions } = getState(); beforeEach(() => { resetPreview(); @@ -17,13 +15,12 @@ test("When formatOutputForAutomations is true, Calculate writes an array and fut expect(upcomingCardIds()).toEqual(["Calculate", "Question"]); // Step forwards through the Calculate - record("Calculate", { data: { testGroup: ["2"] }, auto: true }); - upcomingCardIds(); - - // The Question has been auto-answered - expect(visitedNodes()).toEqual(["Calculate", "Question"]); + clickContinue("Calculate", { data: { testGroup: ["2"] }, auto: true }); - expect(upcomingCardIds()).toEqual(["Group2Notice"]); + // The Question can be auto-answered + expect(visitedNodes()).toEqual(["Calculate"]); + expect(upcomingCardIds()).toEqual(["Question"]) + expect(autoAnswerableOptions("Question")).toEqual(["Group2Response"]); }); test("When formatOutputForAutomations is false, Calculate writes a number and future questions are not auto-answered", () => { @@ -32,13 +29,12 @@ test("When formatOutputForAutomations is false, Calculate writes a number and fu expect(upcomingCardIds()).toEqual(["Calculate", "Question"]); // Step forwards through the Calculate - record("Calculate", { data: { testGroup: 2 }, auto: true }); - upcomingCardIds(); + clickContinue("Calculate", { data: { testGroup: 2 }, auto: true }); - // The Question has NOT been auto-answered + // The Question cannot be auto-answered expect(visitedNodes()).toEqual(["Calculate"]); - expect(upcomingCardIds()).toEqual(["Question"]); + expect(autoAnswerableOptions("Question")).toBeUndefined(); }); const flowWithAutomation: Store.Flow = { diff --git a/editor.planx.uk/src/@planx/components/Checklist/Editor.tsx b/editor.planx.uk/src/@planx/components/Checklist/Editor.tsx index 094a2e48f6..c4585a46da 100644 --- a/editor.planx.uk/src/@planx/components/Checklist/Editor.tsx +++ b/editor.planx.uk/src/@planx/components/Checklist/Editor.tsx @@ -11,15 +11,15 @@ import compose from "ramda/src/compose"; import remove from "ramda/src/remove"; import React, { useEffect, useRef } from "react"; import { FormikHookReturn } from "types"; -import ImgInput from "ui/editor/ImgInput"; +import ImgInput from "ui/editor/ImgInput/ImgInput"; import InputGroup from "ui/editor/InputGroup"; -import ListManager from "ui/editor/ListManager"; +import ListManager from "ui/editor/ListManager/ListManager"; import { ModalFooter } from "ui/editor/ModalFooter"; import ModalSection from "ui/editor/ModalSection"; import ModalSectionContent from "ui/editor/ModalSectionContent"; -import RichTextInput from "ui/editor/RichTextInput"; +import RichTextInput from "ui/editor/RichTextInput/RichTextInput"; import SimpleMenu from "ui/editor/SimpleMenu"; -import Input from "ui/shared/Input"; +import Input from "ui/shared/Input/Input"; import InputRow from "ui/shared/InputRow"; import InputRowItem from "ui/shared/InputRowItem"; @@ -35,6 +35,7 @@ export interface ChecklistProps extends Checklist { node?: { data?: { allRequired?: boolean; + neverAutoAnswer?: boolean; categories?: Array; description?: string; fn?: string; @@ -285,6 +286,7 @@ export const ChecklistComponent: React.FC = (props) => { const formik = useFormik({ initialValues: { allRequired: props.node?.data?.allRequired || false, + neverAutoAnswer: props.node?.data?.neverAutoAnswer || false, description: props.node?.data?.description || "", fn: props.node?.data?.fn || "", groupedOptions: props.groupedOptions, @@ -422,6 +424,22 @@ export const ChecklistComponent: React.FC = (props) => { label="All required" /> + + + formik.setFieldValue( + "neverAutoAnswer", + !formik.values.neverAutoAnswer, + ) + } + /> + } + label="Always put to user (forgo automation)" + /> + diff --git a/editor.planx.uk/src/@planx/components/Checklist/Public.tsx b/editor.planx.uk/src/@planx/components/Checklist/Public.tsx index 190511ad70..bbc4cf7b02 100644 --- a/editor.planx.uk/src/@planx/components/Checklist/Public.tsx +++ b/editor.planx.uk/src/@planx/components/Checklist/Public.tsx @@ -12,11 +12,12 @@ import ImageButton from "@planx/components/shared/Buttons/ImageButton"; import Card from "@planx/components/shared/Preview/Card"; import { CardHeader } from "@planx/components/shared/Preview/CardHeader/CardHeader"; import { getIn, useFormik } from "formik"; -import React, { useState } from "react"; +import { useStore } from "pages/FlowEditor/lib/store"; +import React, { useEffect, useState } from "react"; import { ExpandableList, ExpandableListItem } from "ui/public/ExpandableList"; import FormWrapper from "ui/public/FormWrapper"; import FullWidthWrapper from "ui/public/FullWidthWrapper"; -import ChecklistItem from "ui/shared/ChecklistItem"; +import ChecklistItem from "ui/shared/ChecklistItem/ChecklistItem"; import ErrorWrapper from "ui/shared/ErrorWrapper"; import { object } from "yup"; @@ -38,6 +39,40 @@ function toggleInArray(value: T, arr: Array): Array { } const ChecklistComponent: React.FC = (props) => { + const autoAnswerableOptions = useStore( + (state) => state.autoAnswerableOptions, + ); + + if (props.neverAutoAnswer) { + return ; + } + + let idsThatCanBeAutoAnswered: string[] | undefined; + if (props.id) idsThatCanBeAutoAnswered = autoAnswerableOptions(props.id); + if (idsThatCanBeAutoAnswered) { + return ( + + ); + } + + return ; +}; + +// An auto-answered Checklist won't be seen by the user, but still leaves a breadcrumb +const AutoAnsweredChecklist: React.FC = ( + props, +) => { + useEffect(() => { + props.handleSubmit?.({ + answers: props.answerIds, + auto: true, + }); + }, []); + + return null; +}; + +const VisibleChecklist: React.FC = (props) => { const { description = "", groupedOptions, @@ -168,9 +203,8 @@ const ChecklistComponent: React.FC = (props) => { pb={2} aria-labelledby={`group-${index}-heading`} id={`group-${index}-content`} - data-testid={`group-${index}${ - isExpanded ? "-expanded" : "" - }`} + data-testid={`group-${index}${isExpanded ? "-expanded" : "" + }`} > {group.children.map((option) => ( ; + neverAutoAnswer?: boolean; } interface ChecklistExpandableProps { diff --git a/editor.planx.uk/src/@planx/components/Confirmation/Editor.tsx b/editor.planx.uk/src/@planx/components/Confirmation/Editor.tsx index d2159d3049..75da2069f9 100644 --- a/editor.planx.uk/src/@planx/components/Confirmation/Editor.tsx +++ b/editor.planx.uk/src/@planx/components/Confirmation/Editor.tsx @@ -5,11 +5,11 @@ import { useFormik } from "formik"; import React, { ChangeEvent } from "react"; import ListManager, { EditorProps as ListManagerEditorProps, -} from "ui/editor/ListManager"; +} from "ui/editor/ListManager/ListManager"; import ModalSection from "ui/editor/ModalSection"; import ModalSectionContent from "ui/editor/ModalSectionContent"; -import RichTextInput from "ui/editor/RichTextInput"; -import Input from "ui/shared/Input"; +import RichTextInput from "ui/editor/RichTextInput/RichTextInput"; +import Input from "ui/shared/Input/Input"; import InputRow from "ui/shared/InputRow"; import { Confirmation, parseNextSteps, Step } from "./model"; diff --git a/editor.planx.uk/src/@planx/components/Confirmation/Public.tsx b/editor.planx.uk/src/@planx/components/Confirmation/Public.tsx index 25f2e19a7f..e6316ad774 100644 --- a/editor.planx.uk/src/@planx/components/Confirmation/Public.tsx +++ b/editor.planx.uk/src/@planx/components/Confirmation/Public.tsx @@ -11,7 +11,7 @@ import React, { useEffect, useState } from "react"; import Banner from "ui/public/Banner"; import FileDownload from "ui/public/FileDownload"; import NumberedList from "ui/public/NumberedList"; -import ReactMarkdownOrHtml from "ui/shared/ReactMarkdownOrHtml"; +import ReactMarkdownOrHtml from "ui/shared/ReactMarkdownOrHtml/ReactMarkdownOrHtml"; import type { Confirmation } from "./model"; diff --git a/editor.planx.uk/src/@planx/components/ContactInput/Editor.tsx b/editor.planx.uk/src/@planx/components/ContactInput/Editor.tsx index fc9049b38d..962d0330bf 100644 --- a/editor.planx.uk/src/@planx/components/ContactInput/Editor.tsx +++ b/editor.planx.uk/src/@planx/components/ContactInput/Editor.tsx @@ -1,15 +1,12 @@ import { ComponentType as TYPES } from "@opensystemslab/planx-core/types"; -import { - EditorProps, - ICONS, -} from "@planx/components/ui"; +import { EditorProps, ICONS } from "@planx/components/ui"; import { useFormik } from "formik"; import React from "react"; import { ModalFooter } from "ui/editor/ModalFooter"; import ModalSection from "ui/editor/ModalSection"; import ModalSectionContent from "ui/editor/ModalSectionContent"; -import RichTextInput from "ui/editor/RichTextInput"; -import Input from "ui/shared/Input"; +import RichTextInput from "ui/editor/RichTextInput/RichTextInput"; +import Input from "ui/shared/Input/Input"; import InputRow from "ui/shared/InputRow"; import { ContactInput, parseContactInput } from "./model"; diff --git a/editor.planx.uk/src/@planx/components/ContactInput/Public.tsx b/editor.planx.uk/src/@planx/components/ContactInput/Public.tsx index 8f83d999c7..340f124957 100644 --- a/editor.planx.uk/src/@planx/components/ContactInput/Public.tsx +++ b/editor.planx.uk/src/@planx/components/ContactInput/Public.tsx @@ -4,7 +4,7 @@ import type { PublicProps } from "@planx/components/ui"; import { useFormik } from "formik"; import React from "react"; import InputLabel from "ui/public/InputLabel"; -import Input from "ui/shared/Input"; +import Input from "ui/shared/Input/Input"; import InputRowItem from "ui/shared/InputRowItem"; import { ERROR_MESSAGE } from "../shared/constants"; diff --git a/editor.planx.uk/src/@planx/components/Content/Editor.tsx b/editor.planx.uk/src/@planx/components/Content/Editor.tsx index 6286052fa6..c361ddee4a 100644 --- a/editor.planx.uk/src/@planx/components/Content/Editor.tsx +++ b/editor.planx.uk/src/@planx/components/Content/Editor.tsx @@ -1,17 +1,14 @@ import { ComponentType as TYPES } from "@opensystemslab/planx-core/types"; import type { Content } from "@planx/components/Content/model"; import { parseContent } from "@planx/components/Content/model"; -import { - EditorProps, - ICONS, -} from "@planx/components/ui"; +import { EditorProps, ICONS } from "@planx/components/ui"; import { useFormik } from "formik"; import React from "react"; -import ColorPicker from "ui/editor/ColorPicker"; +import ColorPicker from "ui/editor/ColorPicker/ColorPicker"; import { ModalFooter } from "ui/editor/ModalFooter"; import ModalSection from "ui/editor/ModalSection"; import ModalSectionContent from "ui/editor/ModalSectionContent"; -import RichTextInput from "ui/editor/RichTextInput"; +import RichTextInput from "ui/editor/RichTextInput/RichTextInput"; import InputRow from "ui/shared/InputRow"; export type Props = EditorProps; diff --git a/editor.planx.uk/src/@planx/components/Content/Public.tsx b/editor.planx.uk/src/@planx/components/Content/Public.tsx index c7f2260cb7..3f3fba2c55 100644 --- a/editor.planx.uk/src/@planx/components/Content/Public.tsx +++ b/editor.planx.uk/src/@planx/components/Content/Public.tsx @@ -9,8 +9,8 @@ import { PublicProps } from "@planx/components/ui"; import { useAnalyticsTracking } from "pages/FlowEditor/lib/analytics/provider"; import React from "react"; import { getContrastTextColor } from "styleUtils"; -import { emptyContent } from "ui/editor/RichTextInput"; -import ReactMarkdownOrHtml from "ui/shared/ReactMarkdownOrHtml"; +import { emptyContent } from "ui/editor/RichTextInput/RichTextInput"; +import ReactMarkdownOrHtml from "ui/shared/ReactMarkdownOrHtml/ReactMarkdownOrHtml"; import { HelpButton, Image } from "../shared/Preview/CardHeader/styled"; import MoreInfo from "../shared/Preview/MoreInfo"; diff --git a/editor.planx.uk/src/@planx/components/DateInput/Editor.tsx b/editor.planx.uk/src/@planx/components/DateInput/Editor.tsx index 926fb10136..50cd6d1d63 100644 --- a/editor.planx.uk/src/@planx/components/DateInput/Editor.tsx +++ b/editor.planx.uk/src/@planx/components/DateInput/Editor.tsx @@ -4,20 +4,17 @@ import { DateInput, editorValidationSchema, paddedDate, + parseDateInput, } from "@planx/components/DateInput/model"; -import { parseDateInput } from "@planx/components/DateInput/model"; -import { - EditorProps, - ICONS, -} from "@planx/components/ui"; +import { EditorProps, ICONS } from "@planx/components/ui"; import { useFormik } from "formik"; import React from "react"; import { ModalFooter } from "ui/editor/ModalFooter"; import ModalSection from "ui/editor/ModalSection"; import ModalSectionContent from "ui/editor/ModalSectionContent"; -import RichTextInput from "ui/editor/RichTextInput"; -import DateInputUi from "ui/shared/DateInput"; -import Input from "ui/shared/Input"; +import RichTextInput from "ui/editor/RichTextInput/RichTextInput"; +import DateInputUi from "ui/shared/DateInput/DateInput"; +import Input from "ui/shared/Input/Input"; import InputRow from "ui/shared/InputRow"; export type Props = EditorProps; diff --git a/editor.planx.uk/src/@planx/components/DateInput/Public.tsx b/editor.planx.uk/src/@planx/components/DateInput/Public.tsx index 63bcef5363..2d8db2ff69 100644 --- a/editor.planx.uk/src/@planx/components/DateInput/Public.tsx +++ b/editor.planx.uk/src/@planx/components/DateInput/Public.tsx @@ -10,7 +10,7 @@ import { CardHeader } from "@planx/components/shared/Preview/CardHeader/CardHead import { PublicProps } from "@planx/components/ui"; import { useFormik } from "formik"; import React from "react"; -import DateInputComponent from "ui/shared/DateInput"; +import DateInputComponent from "ui/shared/DateInput/DateInput"; import InputRow from "ui/shared/InputRow"; import { object } from "yup"; diff --git a/editor.planx.uk/src/@planx/components/DrawBoundary/Editor.tsx b/editor.planx.uk/src/@planx/components/DrawBoundary/Editor.tsx index 913be645dc..2b6050db9a 100644 --- a/editor.planx.uk/src/@planx/components/DrawBoundary/Editor.tsx +++ b/editor.planx.uk/src/@planx/components/DrawBoundary/Editor.tsx @@ -1,18 +1,15 @@ import FormControlLabel from "@mui/material/FormControlLabel"; import Switch from "@mui/material/Switch"; import { ComponentType as TYPES } from "@opensystemslab/planx-core/types"; -import { - EditorProps, - ICONS, -} from "@planx/components/ui"; +import { EditorProps, ICONS } from "@planx/components/ui"; import { useFormik } from "formik"; import React from "react"; import InputGroup from "ui/editor/InputGroup"; import { ModalFooter } from "ui/editor/ModalFooter"; import ModalSection from "ui/editor/ModalSection"; import ModalSectionContent from "ui/editor/ModalSectionContent"; -import RichTextInput from "ui/editor/RichTextInput"; -import Input from "ui/shared/Input"; +import RichTextInput from "ui/editor/RichTextInput/RichTextInput"; +import Input from "ui/shared/Input/Input"; import InputRow from "ui/shared/InputRow"; import type { DrawBoundary } from "./model"; diff --git a/editor.planx.uk/src/@planx/components/Feedback/Feedback.stories.tsx b/editor.planx.uk/src/@planx/components/Feedback/Feedback.stories.tsx new file mode 100644 index 0000000000..c976a46c5f --- /dev/null +++ b/editor.planx.uk/src/@planx/components/Feedback/Feedback.stories.tsx @@ -0,0 +1,19 @@ +import { Meta, StoryObj } from "@storybook/react"; + +import Public from "./Public"; + +const meta = { + title: "PlanX Components/Feedback", + component: Public, +} satisfies Meta; + +type Story = StoryObj; + +export default meta; + +export const Basic = { + args: { + title: "Tell us what you think", + privacyPolicyLink: "https://www.planx.uk/", + }, +} satisfies Story; diff --git a/editor.planx.uk/src/@planx/components/Feedback/Public.test.tsx b/editor.planx.uk/src/@planx/components/Feedback/Public.test.tsx new file mode 100644 index 0000000000..0b18fd6151 --- /dev/null +++ b/editor.planx.uk/src/@planx/components/Feedback/Public.test.tsx @@ -0,0 +1,32 @@ +import { screen } from "@testing-library/react"; +import React from "react"; +import { setup } from "testUtils"; +import { vi } from "vitest"; +import { axe } from "vitest-axe"; + +import FeedbackComponent from "./Public"; + +const handleSubmit = vi.fn(); + +describe("when the Feedback component is rendered", async () => { + it("should not have any accessibility violations", async () => { + const { container } = setup( + , + ); + const results = await axe(container); + expect(results).toHaveNoViolations(); + }); + it("should call handleSubmit when the continue button is pressed", async () => { + const { user } = setup( + , + ); + + await user.click(screen.getByTestId("feedback-button-terrible")); + await user.click(screen.getByTestId("continue-button")); + + expect(handleSubmit).toHaveBeenCalled(); + }); +}); diff --git a/editor.planx.uk/src/@planx/components/Feedback/Public.tsx b/editor.planx.uk/src/@planx/components/Feedback/Public.tsx new file mode 100644 index 0000000000..a26a2d7fe5 --- /dev/null +++ b/editor.planx.uk/src/@planx/components/Feedback/Public.tsx @@ -0,0 +1,119 @@ +import Box from "@mui/material/Box"; +import Grid from "@mui/material/Grid"; +import Link from "@mui/material/Link"; +import Typography from "@mui/material/Typography"; +import Card from "@planx/components/shared/Preview/Card"; +import { CardHeader } from "@planx/components/shared/Preview/CardHeader/CardHeader"; +import type { PublicProps } from "@planx/components/ui"; +import { useFormik } from "formik"; +import React from "react"; +import RichTextInput from "ui/editor/RichTextInput/RichTextInput"; +import TerribleFace from "ui/images/feedback_filled-01.svg"; +import PoorFace from "ui/images/feedback_filled-02.svg"; +import NeutralFace from "ui/images/feedback_filled-03.svg"; +import GoodFace from "ui/images/feedback_filled-04.svg"; +import ExcellentFace from "ui/images/feedback_filled-05.svg"; +import InputLabel from "ui/public/InputLabel"; + +import { getPreviouslySubmittedData, makeData } from "../shared/utils"; +import { FaceBox } from "./components/FaceBox"; +import { StyledToggleButtonGroup } from "./styled"; +import { FeedbackComponentProps, FormProps } from "./types"; + +const FeedbackComponent = ( + props: PublicProps, +): FCReturn => { + const formik = useFormik({ + initialValues: getPreviouslySubmittedData(props) ?? { + feedbackScore: "", + feedback: "", + }, + onSubmit: (values) => { + props.handleSubmit?.(makeData(props, values)); + }, + }); + + const handleFeedbackChange = ( + event: React.MouseEvent, + newValue: string | null, + ) => { + if (newValue !== null) { + formik.setFieldValue("feedbackScore", newValue); + } + }; + + return ( + + + + This service is a work in progress, any feedback you share about your + experience will help us to improve it. + + + Don't share any personal or financial information in your feedback. If + you do we will act according to our{" "} + privacy policy. + + + + + + + + + + + + + + {/* */} + + + + + + The information collected here isn't monitored by planning officers. + Don't use it to give extra information about your project or submission. + If you do, it cannot be used to assess your project. + + + ); +}; + +export default FeedbackComponent; diff --git a/editor.planx.uk/src/@planx/components/Feedback/components/FaceBox.tsx b/editor.planx.uk/src/@planx/components/Feedback/components/FaceBox.tsx new file mode 100644 index 0000000000..a12a1186a9 --- /dev/null +++ b/editor.planx.uk/src/@planx/components/Feedback/components/FaceBox.tsx @@ -0,0 +1,52 @@ +import Box from "@mui/material/Box"; +import Grid from "@mui/material/Grid"; +import ToggleButton from "@mui/material/ToggleButton"; +import Typography from "@mui/material/Typography"; +import React, { ReactElement } from "react"; + +interface FaceBoxProps { + icon: string; + label: string; + altText: string; + testId?: string; + value: string; +} + +export const FaceBox = ({ + icon, + label, + altText, + testId, + value, +}: FaceBoxProps): ReactElement => { + return ( + + + ({ + p: theme.spacing(2), + border: `2px solid ${theme.palette.border.main} `, + width: "120px", + maxHeight: "120px", + display: "flex", + flexDirection: "column", + justifyContent: "center", + alignItems: "center", + })} + > + {altText} + + {label} + + + + + ); +}; diff --git a/editor.planx.uk/src/@planx/components/Feedback/styled.ts b/editor.planx.uk/src/@planx/components/Feedback/styled.ts new file mode 100644 index 0000000000..5439945726 --- /dev/null +++ b/editor.planx.uk/src/@planx/components/Feedback/styled.ts @@ -0,0 +1,15 @@ +import { styled } from "@mui/material/styles"; +import ToggleButtonGroup, { + toggleButtonGroupClasses, +} from "@mui/material/ToggleButtonGroup"; + +export const StyledToggleButtonGroup = styled(ToggleButtonGroup)( + ({ theme }) => ({ + [`& .${toggleButtonGroupClasses.grouped}`]: { + border: 0, + padding: 0, + marginTop: theme.spacing(1), + }, + paddingBottom: theme.spacing(3.5), + }), +); diff --git a/editor.planx.uk/src/@planx/components/Feedback/types.ts b/editor.planx.uk/src/@planx/components/Feedback/types.ts new file mode 100644 index 0000000000..d39b88080a --- /dev/null +++ b/editor.planx.uk/src/@planx/components/Feedback/types.ts @@ -0,0 +1,11 @@ +import { BaseNodeData } from "../shared"; + +export interface FeedbackComponentProps extends BaseNodeData { + title: string; + privacyPolicyLink?: string; + fn?: string; +} +export interface FormProps { + feedbackScore: string; + feedback: string; +} diff --git a/editor.planx.uk/src/@planx/components/FileUpload/Editor.tsx b/editor.planx.uk/src/@planx/components/FileUpload/Editor.tsx index f7ed9d2999..3e6b69c6cc 100644 --- a/editor.planx.uk/src/@planx/components/FileUpload/Editor.tsx +++ b/editor.planx.uk/src/@planx/components/FileUpload/Editor.tsx @@ -5,8 +5,8 @@ import React from "react"; import { ModalFooter } from "ui/editor/ModalFooter"; import ModalSection from "ui/editor/ModalSection"; import ModalSectionContent from "ui/editor/ModalSectionContent"; -import RichTextInput from "ui/editor/RichTextInput"; -import Input from "ui/shared/Input"; +import RichTextInput from "ui/editor/RichTextInput/RichTextInput"; +import Input from "ui/shared/Input/Input"; import InputRow from "ui/shared/InputRow"; function Component(props: any) { diff --git a/editor.planx.uk/src/@planx/components/FileUploadAndLabel/Editor.tsx b/editor.planx.uk/src/@planx/components/FileUploadAndLabel/Editor.tsx index 28fbface52..3635853e62 100644 --- a/editor.planx.uk/src/@planx/components/FileUploadAndLabel/Editor.tsx +++ b/editor.planx.uk/src/@planx/components/FileUploadAndLabel/Editor.tsx @@ -7,24 +7,21 @@ import { styled } from "@mui/material/styles"; import Switch from "@mui/material/Switch"; import Typography from "@mui/material/Typography"; import { ComponentType as TYPES } from "@opensystemslab/planx-core/types"; -import { - EditorProps, - ICONS, -} from "@planx/components/ui"; +import { EditorProps, ICONS } from "@planx/components/ui"; import { useFormik } from "formik"; import { lowerCase, merge, upperFirst } from "lodash"; import React from "react"; -import ImgInput from "ui/editor/ImgInput"; +import ImgInput from "ui/editor/ImgInput/ImgInput"; import ListManager, { EditorProps as ListManagerEditorProps, -} from "ui/editor/ListManager"; +} from "ui/editor/ListManager/ListManager"; import { ModalFooter } from "ui/editor/ModalFooter"; import ModalSection from "ui/editor/ModalSection"; import ModalSectionContent from "ui/editor/ModalSectionContent"; import { ModalSubtitle } from "ui/editor/ModalSubtitle"; -import RichTextInput from "ui/editor/RichTextInput"; -import SelectInput from "ui/editor/SelectInput"; -import Input from "ui/shared/Input"; +import RichTextInput from "ui/editor/RichTextInput/RichTextInput"; +import SelectInput from "ui/editor/SelectInput/SelectInput"; +import Input from "ui/shared/Input/Input"; import InputRow from "ui/shared/InputRow"; import InputRowItem from "ui/shared/InputRowItem"; diff --git a/editor.planx.uk/src/@planx/components/FileUploadAndLabel/Public.tsx b/editor.planx.uk/src/@planx/components/FileUploadAndLabel/Public.tsx index 50d7f4bb48..a661f1ad28 100644 --- a/editor.planx.uk/src/@planx/components/FileUploadAndLabel/Public.tsx +++ b/editor.planx.uk/src/@planx/components/FileUploadAndLabel/Public.tsx @@ -14,10 +14,10 @@ import { HelpClickMetadata } from "pages/FlowEditor/lib/analytics/types"; import { useStore } from "pages/FlowEditor/lib/store"; import React, { useEffect, useState } from "react"; import { usePrevious } from "react-use"; -import { emptyContent } from "ui/editor/RichTextInput"; +import { emptyContent } from "ui/editor/RichTextInput/RichTextInput"; import FullWidthWrapper from "ui/public/FullWidthWrapper"; import ErrorWrapper from "ui/shared/ErrorWrapper"; -import ReactMarkdownOrHtml from "ui/shared/ReactMarkdownOrHtml"; +import ReactMarkdownOrHtml from "ui/shared/ReactMarkdownOrHtml/ReactMarkdownOrHtml"; import { FileUploadSlot } from "../FileUpload/model"; import { MoreInformation } from "../shared"; diff --git a/editor.planx.uk/src/@planx/components/Filter/Editor.tsx b/editor.planx.uk/src/@planx/components/Filter/Editor.tsx index b7b84f8ec3..835c3c4abb 100644 --- a/editor.planx.uk/src/@planx/components/Filter/Editor.tsx +++ b/editor.planx.uk/src/@planx/components/Filter/Editor.tsx @@ -63,13 +63,12 @@ const Filter: React.FC = (props) => { through the left-most matching flag option only. - + = (props) => { onChange={formik.handleChange} /> + + + formik.setFieldValue( + "neverAutoAnswer", + !formik.values.neverAutoAnswer, + ) + } + /> + } + label="Always put to user (forgo automation)" + /> + @@ -227,7 +244,6 @@ export const Question: React.FC = (props) => { /> - { + act(() => setState({ flow })); +}); + const responses: { [key in QuestionLayout]: Question["responses"] } = { [QuestionLayout.Basic]: [ { @@ -58,9 +96,10 @@ describe("Question component", () => { describe(`${QuestionLayout[type]} layout`, () => { it(`renders the layout correctly`, async () => { const handleSubmit = vi.fn(); - + const { user, getByTestId, getByRole, getByText } = setup( { const handleSubmit = vi.fn(); const { user, getByRole, getByTestId } = setup( { const handleSubmit = vi.fn(); const { container } = setup( { const { user, getByTestId, getByText, queryByText } = setup( = (props) => { + const [flow, autoAnswerableOptions] = useStore((state) => [ + state.flow, + state.autoAnswerableOptions, + ]); + + if (props.neverAutoAnswer) { + return ; + } + + // Questions without edges act like "sticky notes" in the graph for editors only & can be immediately auto-answered + let edges: Edges | undefined; + if (props.id) edges = flow[props.id]?.edges + if (!edges || edges.length === 0) { + return ; + } + + let idsThatCanBeAutoAnswered: string[] | undefined; + if (props.id) idsThatCanBeAutoAnswered = autoAnswerableOptions(props.id); + if (idsThatCanBeAutoAnswered) { + return ( + + ); + } + + return ; +}; + +// An auto-answered Question won't be seen by the user, but still leaves a breadcrumb +const AutoAnsweredQuestion: React.FC< + Question & { answerIds: string[] | undefined } +> = (props) => { + useEffect(() => { + props.handleSubmit?.({ + answers: props.answerIds, + auto: true, + }); + }, []); + + return null; +}; + +const VisibleQuestion: React.FC = (props) => { const previousResponseId = props?.previouslySubmittedData?.answers?.[0]; const previousResponseKey = props.responses.find( (response) => response.id === previousResponseId, diff --git a/editor.planx.uk/src/@planx/components/Question/model.ts b/editor.planx.uk/src/@planx/components/Question/model.ts index 908b5bc2bb..c5aef4d74a 100644 --- a/editor.planx.uk/src/@planx/components/Question/model.ts +++ b/editor.planx.uk/src/@planx/components/Question/model.ts @@ -8,6 +8,7 @@ export interface Question extends BaseNodeData { text?: string; description?: string; img?: string; + neverAutoAnswer?: boolean; responses: { id?: string; responseKey: string | number; diff --git a/editor.planx.uk/src/@planx/components/Result/Editor.tsx b/editor.planx.uk/src/@planx/components/Result/Editor.tsx index b6a88e4471..87c8ff7dde 100644 --- a/editor.planx.uk/src/@planx/components/Result/Editor.tsx +++ b/editor.planx.uk/src/@planx/components/Result/Editor.tsx @@ -1,14 +1,17 @@ import Box from "@mui/material/Box"; import Typography from "@mui/material/Typography"; -import { Flag, flatFlags } from "@opensystemslab/planx-core/types"; -import { ComponentType as TYPES } from "@opensystemslab/planx-core/types"; +import { + ComponentType as TYPES, + Flag, + flatFlags, +} from "@opensystemslab/planx-core/types"; import { useFormik } from "formik"; import groupBy from "lodash/groupBy"; import React from "react"; import ModalSection from "ui/editor/ModalSection"; import ModalSectionContent from "ui/editor/ModalSectionContent"; import InputLabel from "ui/public/InputLabel"; -import Input from "ui/shared/Input"; +import Input from "ui/shared/Input/Input"; import InputRow from "ui/shared/InputRow"; import { EditorProps, ICONS } from "../ui"; diff --git a/editor.planx.uk/src/@planx/components/Result/Public/ResultReason.tsx b/editor.planx.uk/src/@planx/components/Result/Public/ResultReason.tsx index 17d342e36a..5343147cb2 100644 --- a/editor.planx.uk/src/@planx/components/Result/Public/ResultReason.tsx +++ b/editor.planx.uk/src/@planx/components/Result/Public/ResultReason.tsx @@ -12,7 +12,7 @@ import { useAnalyticsTracking } from "pages/FlowEditor/lib/analytics/provider"; import { Store, useStore } from "pages/FlowEditor/lib/store"; import React, { useLayoutEffect, useRef, useState } from "react"; import Caret from "ui/icons/Caret"; -import ReactMarkdownOrHtml from "ui/shared/ReactMarkdownOrHtml"; +import ReactMarkdownOrHtml from "ui/shared/ReactMarkdownOrHtml/ReactMarkdownOrHtml"; interface IResultReason { id: string; diff --git a/editor.planx.uk/src/@planx/components/Review/Editor.tsx b/editor.planx.uk/src/@planx/components/Review/Editor.tsx index 0ece7df2e4..463d70c56a 100644 --- a/editor.planx.uk/src/@planx/components/Review/Editor.tsx +++ b/editor.planx.uk/src/@planx/components/Review/Editor.tsx @@ -4,8 +4,8 @@ import React from "react"; import { ModalFooter } from "ui/editor/ModalFooter"; import ModalSection from "ui/editor/ModalSection"; import ModalSectionContent from "ui/editor/ModalSectionContent"; -import RichTextInput from "ui/editor/RichTextInput"; -import Input from "ui/shared/Input"; +import RichTextInput from "ui/editor/RichTextInput/RichTextInput"; +import Input from "ui/shared/Input/Input"; import InputRow from "ui/shared/InputRow"; import { EditorProps, ICONS } from "../ui"; diff --git a/editor.planx.uk/src/@planx/components/Section/Editor.tsx b/editor.planx.uk/src/@planx/components/Section/Editor.tsx index ae366c66f8..fcf6e3292c 100644 --- a/editor.planx.uk/src/@planx/components/Section/Editor.tsx +++ b/editor.planx.uk/src/@planx/components/Section/Editor.tsx @@ -5,8 +5,8 @@ import React from "react"; import { ModalFooter } from "ui/editor/ModalFooter"; import ModalSection from "ui/editor/ModalSection"; import ModalSectionContent from "ui/editor/ModalSectionContent"; -import RichTextInput from "ui/editor/RichTextInput"; -import Input from "ui/shared/Input"; +import RichTextInput from "ui/editor/RichTextInput/RichTextInput"; +import Input from "ui/shared/Input/Input"; import InputRow from "ui/shared/InputRow"; import { parseSection, Section } from "./model"; diff --git a/editor.planx.uk/src/@planx/components/Section/Public.tsx b/editor.planx.uk/src/@planx/components/Section/Public.tsx index 70e373895a..48c8fb6b65 100644 --- a/editor.planx.uk/src/@planx/components/Section/Public.tsx +++ b/editor.planx.uk/src/@planx/components/Section/Public.tsx @@ -9,7 +9,7 @@ import { useAnalyticsTracking } from "pages/FlowEditor/lib/analytics/provider"; import { Store, useStore } from "pages/FlowEditor/lib/store"; import React from "react"; import { SectionNode, SectionStatus } from "types"; -import ReactMarkdownOrHtml from "ui/shared/ReactMarkdownOrHtml"; +import ReactMarkdownOrHtml from "ui/shared/ReactMarkdownOrHtml/ReactMarkdownOrHtml"; import Card from "../shared/Preview/Card"; import { CardHeader } from "../shared/Preview/CardHeader/CardHeader"; diff --git a/editor.planx.uk/src/@planx/components/Send/Editor.tsx b/editor.planx.uk/src/@planx/components/Send/Editor.tsx index 0747a656e3..867e55c9a6 100644 --- a/editor.planx.uk/src/@planx/components/Send/Editor.tsx +++ b/editor.planx.uk/src/@planx/components/Send/Editor.tsx @@ -2,14 +2,17 @@ import Warning from "@mui/icons-material/Warning"; import Box from "@mui/material/Box"; import Grid from "@mui/material/Grid"; import Typography from "@mui/material/Typography"; -import { ComponentType as TYPES, SendIntegration } from "@opensystemslab/planx-core/types"; +import { + ComponentType as TYPES, + SendIntegration, +} from "@opensystemslab/planx-core/types"; import { getIn, useFormik } from "formik"; import React from "react"; import ModalSection from "ui/editor/ModalSection"; import ModalSectionContent from "ui/editor/ModalSectionContent"; -import ChecklistItem from "ui/shared/ChecklistItem"; +import ChecklistItem from "ui/shared/ChecklistItem/ChecklistItem"; import ErrorWrapper from "ui/shared/ErrorWrapper"; -import Input from "ui/shared/Input"; +import Input from "ui/shared/Input/Input"; import InputRow from "ui/shared/InputRow"; import { array, object } from "yup"; diff --git a/editor.planx.uk/src/@planx/components/Send/Public.test.tsx b/editor.planx.uk/src/@planx/components/Send/Public.test.tsx index 00ba3d8ac1..da34e1765b 100644 --- a/editor.planx.uk/src/@planx/components/Send/Public.test.tsx +++ b/editor.planx.uk/src/@planx/components/Send/Public.test.tsx @@ -19,9 +19,10 @@ let initialState: FullStore; /** * Adds a small tick to allow MUI to render (e.g. card transitions) */ -const tick = () => act(async () => { - await new Promise(resolve => setTimeout(resolve, 0)); -}); +const tick = () => + act(async () => { + await new Promise((resolve) => setTimeout(resolve, 0)); + }); vi.mock("axios"); const mockAxios = vi.mocked(axios, true); @@ -36,21 +37,18 @@ const originalLocation = window.location.pathname; beforeAll(() => (initialState = getState())); -beforeEach(() => (act(() => setState({ teamSlug: "testTeam" })))); +beforeEach(() => act(() => setState({ teamSlug: "testTeam" }))); afterEach(() => { vi.clearAllMocks(); window.history.pushState({}, "", originalLocation); - act(() => setState(initialState)) + act(() => setState(initialState)); }); it("displays a warning at /draft URLs", () => { window.history.pushState({}, "", "/draft"); const { getByText } = setup( - , + , ); expect(getByText(/You can only test submissions on/)).toBeVisible(); @@ -59,10 +57,7 @@ it("displays a warning at /draft URLs", () => { it("displays a warning at /preview URLs", () => { window.history.pushState({}, "", "/preview"); const { getByText } = setup( - , + , ); expect(getByText(/You can only test submissions on/)).toBeVisible(); @@ -77,10 +72,7 @@ it("displays loading messages to the user", async () => { mockAxios.post.mockImplementationOnce(() => promise); const { getByText } = setup( - , + , ); await tick(); @@ -104,12 +96,7 @@ it("displays loading messages to the user", async () => { }); it("calls the /create-send-event endpoint", async () => { - setup( - , - ); + setup(); await waitFor(() => expect(mockAxios.post).toHaveBeenCalledTimes(1)); @@ -119,44 +106,41 @@ it("calls the /create-send-event endpoint", async () => { it("generates a valid payload for the API", async () => { const destinations: SendIntegration[] = ["bops", "uniform"]; - setup( - , - ); + setup(); await waitFor(() => expect(mockAxios.post).toHaveBeenCalledTimes(1)); const apiPayload = mockAxios.post.mock.calls[0][1]; - destinations.forEach(destination => { + destinations.forEach((destination) => { expect(apiPayload).toHaveProperty(destination); - expect((apiPayload as Record)[destination]).toHaveProperty("localAuthority", "testTeam"); + expect( + (apiPayload as Record)[destination], + ).toHaveProperty("localAuthority", "testTeam"); }); }); describe("Uniform overrides for Buckinghamshire", () => { it("converts property.localAuthorityDistrict to the correct format", async () => { - act(() => setState({ - teamSlug: "buckinghamshire", - flow, - breadcrumbs: { - findProperty: { - data: { - "property.localAuthorityDistrict": ["Buckinghamshire", "Historic district name"] - } - } - } - })); - - setup( - , + act(() => + setState({ + teamSlug: "buckinghamshire", + flow, + breadcrumbs: { + findProperty: { + data: { + "property.localAuthorityDistrict": [ + "Buckinghamshire", + "Historic district name", + ], + }, + }, + }, + }), ); + setup(); + await waitFor(() => expect(mockAxios.post).toHaveBeenCalledTimes(1)); const apiPayload = mockAxios.post.mock.calls[0][1] as any; @@ -165,36 +149,35 @@ describe("Uniform overrides for Buckinghamshire", () => { expect(apiPayload?.bops?.localAuthority).toEqual("buckinghamshire"); // Uniform event has read property.localAuthorityDistrict from the passport - expect(apiPayload?.uniform?.localAuthority).toEqual("historic-district-name"); + expect(apiPayload?.uniform?.localAuthority).toEqual( + "historic-district-name", + ); }); - + it("maps requests for South Bucks to Chiltern", async () => { - act(() => setState({ - teamSlug: "buckinghamshire", - flow, - breadcrumbs: { - findProperty: { - data: { - "property.localAuthorityDistrict": ["South Bucks"] - } - } - } - })); - - setup( - , + act(() => + setState({ + teamSlug: "buckinghamshire", + flow, + breadcrumbs: { + findProperty: { + data: { + "property.localAuthorityDistrict": ["South Bucks"], + }, + }, + }, + }), ); + setup(); + await waitFor(() => expect(mockAxios.post).toHaveBeenCalledTimes(1)); const apiPayload = mockAxios.post.mock.calls[0][1] as any; expect(apiPayload?.uniform?.localAuthority).toEqual("chiltern"); }); -}) +}); it("generates a valid breadcrumb", async () => { const handleSubmit = vi.fn(); @@ -212,18 +195,17 @@ it("generates a valid breadcrumb", async () => { const breadcrumb = handleSubmit.mock.calls[0][0]; - expect(breadcrumb.data).toEqual(expect.objectContaining({ - bopsSendEventId: hasuraEventsResponseMock.bops.event_id, - uniformSendEventId: hasuraEventsResponseMock.uniform.event_id, - })); + expect(breadcrumb.data).toEqual( + expect.objectContaining({ + bopsSendEventId: hasuraEventsResponseMock.bops.event_id, + uniformSendEventId: hasuraEventsResponseMock.uniform.event_id, + }), + ); }); it("should not have any accessibility violations", async () => { const { container } = setup( - , + , ); const results = await axe(container); expect(results).toHaveNoViolations(); diff --git a/editor.planx.uk/src/@planx/components/Send/Public.tsx b/editor.planx.uk/src/@planx/components/Send/Public.tsx index 3e1ceedc93..42cb18de02 100644 --- a/editor.planx.uk/src/@planx/components/Send/Public.tsx +++ b/editor.planx.uk/src/@planx/components/Send/Public.tsx @@ -2,7 +2,7 @@ import ErrorOutline from "@mui/icons-material/ErrorOutline"; import Typography from "@mui/material/Typography"; import { SendIntegration } from "@opensystemslab/planx-core/types"; import axios, { AxiosResponse } from "axios"; -import DelayedLoadingIndicator from "components/DelayedLoadingIndicator"; +import DelayedLoadingIndicator from "components/DelayedLoadingIndicator/DelayedLoadingIndicator"; import { useStore } from "pages/FlowEditor/lib/store"; import React, { useEffect } from "react"; import { useAsync } from "react-use"; @@ -11,17 +11,13 @@ import { AsyncState } from "react-use/lib/useAsyncFn"; import Card from "../shared/Preview/Card"; import { WarningContainer } from "../shared/Preview/WarningContainer"; import { PublicProps } from "../ui"; -import { - DEFAULT_DESTINATION, - getCombinedEventsPayload, - Send, -} from "./model"; +import { DEFAULT_DESTINATION, getCombinedEventsPayload, Send } from "./model"; /** Response returned by /create-send-events endpoint */ -type SendResponse = Record; +type SendResponse = Record; /** State generated by useAsync to hold SendResponse */ -type SendRequestState = AsyncState> +type SendRequestState = AsyncState>; export type Props = PublicProps; @@ -67,8 +63,9 @@ const CreateSendEvents: React.FC = ({ ]); // Send makes a single request to create scheduled events in Hasura, then those events make the actual submission requests with retries etc - const url = `${import.meta.env.VITE_APP_API_URL - }/create-send-events/${sessionId}`; + const url = `${ + import.meta.env.VITE_APP_API_URL + }/create-send-events/${sessionId}`; const { loading, error, value }: SendRequestState = useAsync(async () => { const combinedEventsPayload = getCombinedEventsPayload({ destinations, @@ -86,10 +83,10 @@ const CreateSendEvents: React.FC = ({ // Construct breadcrumb containing IDs of each send event generated const data = Object.fromEntries( - destinations.map(destination => [ + destinations.map((destination) => [ `${destination}SendEventId`, - value.data[destination]?.event_id - ]) + value.data[destination]?.event_id, + ]), ); props.handleSubmit && props?.handleSubmit({ data }); @@ -104,7 +101,7 @@ const CreateSendEvents: React.FC = ({ ); - }; + } return ( diff --git a/editor.planx.uk/src/@planx/components/Send/mocks/simpleFlow.ts b/editor.planx.uk/src/@planx/components/Send/mocks/simpleFlow.ts index 2199b4b9db..281b1aed04 100644 --- a/editor.planx.uk/src/@planx/components/Send/mocks/simpleFlow.ts +++ b/editor.planx.uk/src/@planx/components/Send/mocks/simpleFlow.ts @@ -24,4 +24,4 @@ export const flow: Graph = { }, type: 9, }, -}; \ No newline at end of file +}; diff --git a/editor.planx.uk/src/@planx/components/Send/model.ts b/editor.planx.uk/src/@planx/components/Send/model.ts index 8a85dcfe0a..f370430ca7 100644 --- a/editor.planx.uk/src/@planx/components/Send/model.ts +++ b/editor.planx.uk/src/@planx/components/Send/model.ts @@ -27,7 +27,7 @@ export const parseContent = (data: Record | undefined): Send => ({ }); const isSendingToUniform = ( - payload: CombinedEventsPayload + payload: CombinedEventsPayload, ): payload is CombinedEventsPayload & { uniform: EventPayload } => "uniform" in payload; diff --git a/editor.planx.uk/src/@planx/components/SetValue/Editor.tsx b/editor.planx.uk/src/@planx/components/SetValue/Editor.tsx index cc82ddfbb3..a05222118a 100644 --- a/editor.planx.uk/src/@planx/components/SetValue/Editor.tsx +++ b/editor.planx.uk/src/@planx/components/SetValue/Editor.tsx @@ -9,7 +9,7 @@ import React from "react"; import { ModalFooter } from "ui/editor/ModalFooter"; import ModalSection from "ui/editor/ModalSection"; import ModalSectionContent from "ui/editor/ModalSectionContent"; -import Input from "ui/shared/Input"; +import Input from "ui/shared/Input/Input"; import InputRow from "ui/shared/InputRow"; import { parseSetValue, SetValue } from "./model"; @@ -139,7 +139,7 @@ function SetValueComponent(props: Props) { - + ); } diff --git a/editor.planx.uk/src/@planx/components/TaskList/Editor.tsx b/editor.planx.uk/src/@planx/components/TaskList/Editor.tsx index f8f3b7a297..5eab90d175 100644 --- a/editor.planx.uk/src/@planx/components/TaskList/Editor.tsx +++ b/editor.planx.uk/src/@planx/components/TaskList/Editor.tsx @@ -2,20 +2,17 @@ import Box from "@mui/material/Box"; import { ComponentType as TYPES } from "@opensystemslab/planx-core/types"; import type { Task, TaskList } from "@planx/components/TaskList/model"; import { parseTaskList } from "@planx/components/TaskList/model"; -import { - EditorProps, - ICONS, -} from "@planx/components/ui"; +import { EditorProps, ICONS } from "@planx/components/ui"; import { useFormik } from "formik"; import React, { ChangeEvent } from "react"; import ListManager, { EditorProps as ListManagerEditorProps, -} from "ui/editor/ListManager"; +} from "ui/editor/ListManager/ListManager"; import { ModalFooter } from "ui/editor/ModalFooter"; import ModalSection from "ui/editor/ModalSection"; import ModalSectionContent from "ui/editor/ModalSectionContent"; -import RichTextInput from "ui/editor/RichTextInput"; -import Input from "ui/shared/Input"; +import RichTextInput from "ui/editor/RichTextInput/RichTextInput"; +import Input from "ui/shared/Input/Input"; import InputRow from "ui/shared/InputRow"; export type Props = EditorProps; diff --git a/editor.planx.uk/src/@planx/components/TextInput/Editor.tsx b/editor.planx.uk/src/@planx/components/TextInput/Editor.tsx index f48ffba379..769c36e57d 100644 --- a/editor.planx.uk/src/@planx/components/TextInput/Editor.tsx +++ b/editor.planx.uk/src/@planx/components/TextInput/Editor.tsx @@ -2,17 +2,14 @@ import FormControl from "@mui/material/FormControl"; import RadioGroup from "@mui/material/RadioGroup"; import { ComponentType as TYPES } from "@opensystemslab/planx-core/types"; import BasicRadio from "@planx/components/shared/Radio/BasicRadio"; -import { - EditorProps, - ICONS, -} from "@planx/components/ui"; +import { EditorProps, ICONS } from "@planx/components/ui"; import { useFormik } from "formik"; import React from "react"; import { ModalFooter } from "ui/editor/ModalFooter"; import ModalSection from "ui/editor/ModalSection"; import ModalSectionContent from "ui/editor/ModalSectionContent"; -import RichTextInput from "ui/editor/RichTextInput"; -import Input from "ui/shared/Input"; +import RichTextInput from "ui/editor/RichTextInput/RichTextInput"; +import Input from "ui/shared/Input/Input"; import InputRow from "ui/shared/InputRow"; import { parseTextInput, TextInput } from "./model"; diff --git a/editor.planx.uk/src/@planx/components/TextInput/Public.tsx b/editor.planx.uk/src/@planx/components/TextInput/Public.tsx index 161172f31e..ff97a75d91 100644 --- a/editor.planx.uk/src/@planx/components/TextInput/Public.tsx +++ b/editor.planx.uk/src/@planx/components/TextInput/Public.tsx @@ -5,7 +5,7 @@ import { useFormik } from "formik"; import React from "react"; import InputLabel from "ui/public/InputLabel"; import { CharacterCounter, isLongTextType } from "ui/shared/CharacterCounter"; -import Input from "ui/shared/Input"; +import Input from "ui/shared/Input/Input"; import InputRow from "ui/shared/InputRow"; import { object } from "yup"; diff --git a/editor.planx.uk/src/@planx/components/shared/Buttons/ImageButton.tsx b/editor.planx.uk/src/@planx/components/shared/Buttons/ImageButton.tsx index afa7eb2ac0..bcf0bf1dc2 100644 --- a/editor.planx.uk/src/@planx/components/shared/Buttons/ImageButton.tsx +++ b/editor.planx.uk/src/@planx/components/shared/Buttons/ImageButton.tsx @@ -3,7 +3,7 @@ import Box from "@mui/material/Box"; import { styled, Theme } from "@mui/material/styles"; import Typography from "@mui/material/Typography"; import React, { useLayoutEffect, useRef, useState } from "react"; -import Checkbox from "ui/shared/Checkbox"; +import Checkbox from "ui/shared/Checkbox/Checkbox"; import { Props as ButtonBaseProps } from "./ButtonBase"; diff --git a/editor.planx.uk/src/@planx/components/shared/PermissionSelect.tsx b/editor.planx.uk/src/@planx/components/shared/PermissionSelect.tsx index 4b4a7eea1c..a3fc31422c 100644 --- a/editor.planx.uk/src/@planx/components/shared/PermissionSelect.tsx +++ b/editor.planx.uk/src/@planx/components/shared/PermissionSelect.tsx @@ -2,8 +2,8 @@ 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"; -import SelectInput from "ui/editor/SelectInput"; +import type { Props as SelectInputProps } from "ui/editor/SelectInput/SelectInput"; +import SelectInput from "ui/editor/SelectInput/SelectInput"; const flags = groupBy(flatFlags, (f) => f.category); diff --git a/editor.planx.uk/src/@planx/components/shared/Preview/CardHeader/CardHeader.tsx b/editor.planx.uk/src/@planx/components/shared/Preview/CardHeader/CardHeader.tsx index 6ea8461174..a96ea6fbfa 100644 --- a/editor.planx.uk/src/@planx/components/shared/Preview/CardHeader/CardHeader.tsx +++ b/editor.planx.uk/src/@planx/components/shared/Preview/CardHeader/CardHeader.tsx @@ -2,8 +2,8 @@ import HelpIcon from "@mui/icons-material/Help"; import Typography from "@mui/material/Typography"; import { useAnalyticsTracking } from "pages/FlowEditor/lib/analytics/provider"; import React from "react"; -import { emptyContent } from "ui/editor/RichTextInput"; -import ReactMarkdownOrHtml from "ui/shared/ReactMarkdownOrHtml"; +import { emptyContent } from "ui/editor/RichTextInput/RichTextInput"; +import ReactMarkdownOrHtml from "ui/shared/ReactMarkdownOrHtml/ReactMarkdownOrHtml"; import { DESCRIPTION_TEXT } from "../../constants"; import MoreInfo from "../MoreInfo"; diff --git a/editor.planx.uk/src/@planx/components/shared/Preview/MoreInfo.tsx b/editor.planx.uk/src/@planx/components/shared/Preview/MoreInfo.tsx index 45c8768c11..6bf538e3d5 100644 --- a/editor.planx.uk/src/@planx/components/shared/Preview/MoreInfo.tsx +++ b/editor.planx.uk/src/@planx/components/shared/Preview/MoreInfo.tsx @@ -4,7 +4,7 @@ import Container from "@mui/material/Container"; import Drawer, { DrawerProps } from "@mui/material/Drawer"; import IconButton from "@mui/material/IconButton"; import { styled } from "@mui/material/styles"; -import MoreInfoFeedbackComponent from "components/Feedback/MoreInfoFeedback"; +import MoreInfoFeedbackComponent from "components/Feedback/MoreInfoFeedback/MoreInfoFeedback"; import React from "react"; const PREFIX = "MoreInfo"; diff --git a/editor.planx.uk/src/@planx/components/shared/Schema/InputFields/ChecklistFieldInput.tsx b/editor.planx.uk/src/@planx/components/shared/Schema/InputFields/ChecklistFieldInput.tsx index 33c2f16b93..de5cb0fc20 100644 --- a/editor.planx.uk/src/@planx/components/shared/Schema/InputFields/ChecklistFieldInput.tsx +++ b/editor.planx.uk/src/@planx/components/shared/Schema/InputFields/ChecklistFieldInput.tsx @@ -3,7 +3,7 @@ import { visuallyHidden } from "@mui/utils"; import type { ChecklistField } from "@planx/components/shared/Schema/model"; import React from "react"; import InputLabel from "ui/public/InputLabel"; -import ChecklistItem from "ui/shared/ChecklistItem"; +import ChecklistItem from "ui/shared/ChecklistItem/ChecklistItem"; import ErrorWrapper from "ui/shared/ErrorWrapper"; import { getFieldProps, Props } from "."; diff --git a/editor.planx.uk/src/@planx/components/shared/Schema/InputFields/DateFieldInput.tsx b/editor.planx.uk/src/@planx/components/shared/Schema/InputFields/DateFieldInput.tsx index 298ecd1ef2..f572700438 100644 --- a/editor.planx.uk/src/@planx/components/shared/Schema/InputFields/DateFieldInput.tsx +++ b/editor.planx.uk/src/@planx/components/shared/Schema/InputFields/DateFieldInput.tsx @@ -4,7 +4,7 @@ import { paddedDate } from "@planx/components/DateInput/model"; import type { DateField } from "@planx/components/shared/Schema/model"; import React from "react"; import InputLegend from "ui/editor/InputLegend"; -import DateInput from "ui/shared/DateInput"; +import DateInput from "ui/shared/DateInput/DateInput"; import { getFieldProps, Props } from "."; import { FieldInputDescription } from "./shared"; diff --git a/editor.planx.uk/src/@planx/components/shared/Schema/InputFields/MapFieldInput.tsx b/editor.planx.uk/src/@planx/components/shared/Schema/InputFields/MapFieldInput.tsx index 780c9c3729..1698223214 100644 --- a/editor.planx.uk/src/@planx/components/shared/Schema/InputFields/MapFieldInput.tsx +++ b/editor.planx.uk/src/@planx/components/shared/Schema/InputFields/MapFieldInput.tsx @@ -84,6 +84,7 @@ export const MapFieldInput: React.FC> = (props) => { }) } drawMany={mapOptions?.drawMany} + hideDrawLabels={mapOptions?.drawMany} drawColor={mapOptions?.drawColor} drawType={mapOptions?.drawType} drawPointer="crosshair" diff --git a/editor.planx.uk/src/@planx/components/shared/Schema/InputFields/NumberFieldInput.tsx b/editor.planx.uk/src/@planx/components/shared/Schema/InputFields/NumberFieldInput.tsx index daa264e3d7..ef8f2293f1 100644 --- a/editor.planx.uk/src/@planx/components/shared/Schema/InputFields/NumberFieldInput.tsx +++ b/editor.planx.uk/src/@planx/components/shared/Schema/InputFields/NumberFieldInput.tsx @@ -2,7 +2,7 @@ import Box from "@mui/material/Box"; import type { NumberField } from "@planx/components/shared/Schema/model"; import React from "react"; import InputLabel from "ui/public/InputLabel"; -import Input from "ui/shared/Input"; +import Input from "ui/shared/Input/Input"; import InputRowLabel from "ui/shared/InputRowLabel"; import { DESCRIPTION_TEXT, ERROR_MESSAGE } from "../../constants"; diff --git a/editor.planx.uk/src/@planx/components/shared/Schema/InputFields/SelectInputField.tsx b/editor.planx.uk/src/@planx/components/shared/Schema/InputFields/SelectInputField.tsx index bc2b4e27ab..1e8101aab4 100644 --- a/editor.planx.uk/src/@planx/components/shared/Schema/InputFields/SelectInputField.tsx +++ b/editor.planx.uk/src/@planx/components/shared/Schema/InputFields/SelectInputField.tsx @@ -1,7 +1,7 @@ import MenuItem from "@mui/material/MenuItem"; import type { QuestionField } from "@planx/components/shared/Schema/model"; import React from "react"; -import SelectInput from "ui/editor/SelectInput"; +import SelectInput from "ui/editor/SelectInput/SelectInput"; import InputLabel from "ui/public/InputLabel"; import ErrorWrapper from "ui/shared/ErrorWrapper"; diff --git a/editor.planx.uk/src/@planx/components/shared/Schema/InputFields/TextFieldInput.tsx b/editor.planx.uk/src/@planx/components/shared/Schema/InputFields/TextFieldInput.tsx index a19ed460b6..45e7273d74 100644 --- a/editor.planx.uk/src/@planx/components/shared/Schema/InputFields/TextFieldInput.tsx +++ b/editor.planx.uk/src/@planx/components/shared/Schema/InputFields/TextFieldInput.tsx @@ -3,7 +3,7 @@ import { TextInputType } from "@planx/components/TextInput/model"; import React from "react"; import InputLabel from "ui/public/InputLabel"; import { CharacterCounter, isLongTextType } from "ui/shared/CharacterCounter"; -import Input from "ui/shared/Input"; +import Input from "ui/shared/Input/Input"; import { DESCRIPTION_TEXT, ERROR_MESSAGE } from "../../constants"; import { getFieldProps, Props } from "."; diff --git a/editor.planx.uk/src/@planx/components/ui.tsx b/editor.planx.uk/src/@planx/components/ui.tsx index b2048d7104..7018d6ed96 100644 --- a/editor.planx.uk/src/@planx/components/ui.tsx +++ b/editor.planx.uk/src/@planx/components/ui.tsx @@ -32,13 +32,13 @@ import { ComponentType as TYPES } from "@opensystemslab/planx-core/types"; import { Store } from "pages/FlowEditor/lib/store"; import type { HandleSubmit } from "pages/Preview/Node"; import React, { ChangeEvent } from "react"; -import ImgInput from "ui/editor/ImgInput"; +import ImgInput from "ui/editor/ImgInput/ImgInput"; import InputGroup from "ui/editor/InputGroup"; import InputLabel from "ui/editor/InputLabel"; import ModalSection from "ui/editor/ModalSection"; import ModalSectionContent from "ui/editor/ModalSectionContent"; -import RichTextInput from "ui/editor/RichTextInput"; -import Input from "ui/shared/Input"; +import RichTextInput from "ui/editor/RichTextInput/RichTextInput"; +import Input from "ui/shared/Input/Input"; import InputRow from "ui/shared/InputRow"; export interface EditorProps { diff --git a/editor.planx.uk/src/components/AnalyticsDisabledBanner.stories.tsx b/editor.planx.uk/src/components/AnalyticsDisabled/AnalyticsDisabledBanner.stories.tsx similarity index 100% rename from editor.planx.uk/src/components/AnalyticsDisabledBanner.stories.tsx rename to editor.planx.uk/src/components/AnalyticsDisabled/AnalyticsDisabledBanner.stories.tsx diff --git a/editor.planx.uk/src/components/AnalyticsDisabledBanner.tsx b/editor.planx.uk/src/components/AnalyticsDisabled/AnalyticsDisabledBanner.tsx similarity index 100% rename from editor.planx.uk/src/components/AnalyticsDisabledBanner.tsx rename to editor.planx.uk/src/components/AnalyticsDisabled/AnalyticsDisabledBanner.tsx diff --git a/editor.planx.uk/src/components/DelayedLoadingIndicator.stories.tsx b/editor.planx.uk/src/components/DelayedLoadingIndicator/DelayedLoadingIndicator.stories.tsx similarity index 100% rename from editor.planx.uk/src/components/DelayedLoadingIndicator.stories.tsx rename to editor.planx.uk/src/components/DelayedLoadingIndicator/DelayedLoadingIndicator.stories.tsx diff --git a/editor.planx.uk/src/components/DelayedLoadingIndicator.tsx b/editor.planx.uk/src/components/DelayedLoadingIndicator/DelayedLoadingIndicator.tsx similarity index 100% rename from editor.planx.uk/src/components/DelayedLoadingIndicator.tsx rename to editor.planx.uk/src/components/DelayedLoadingIndicator/DelayedLoadingIndicator.tsx diff --git a/editor.planx.uk/src/components/Feedback/FeedbackForm.stories.tsx b/editor.planx.uk/src/components/Feedback/FeedbackForm/FeedbackForm.stories.tsx similarity index 100% rename from editor.planx.uk/src/components/Feedback/FeedbackForm.stories.tsx rename to editor.planx.uk/src/components/Feedback/FeedbackForm/FeedbackForm.stories.tsx diff --git a/editor.planx.uk/src/components/Feedback/FeedbackForm.test.tsx b/editor.planx.uk/src/components/Feedback/FeedbackForm/FeedbackForm.test.tsx similarity index 97% rename from editor.planx.uk/src/components/Feedback/FeedbackForm.test.tsx rename to editor.planx.uk/src/components/Feedback/FeedbackForm/FeedbackForm.test.tsx index 2cf9be519c..3d73243b9e 100644 --- a/editor.planx.uk/src/components/Feedback/FeedbackForm.test.tsx +++ b/editor.planx.uk/src/components/Feedback/FeedbackForm/FeedbackForm.test.tsx @@ -3,7 +3,7 @@ import { setup } from "testUtils"; import { vi } from "vitest"; import { axe } from "vitest-axe"; -import { FeedbackFormInput } from "."; +import { FeedbackFormInput } from "../types"; import FeedbackForm from "./FeedbackForm"; const mockHandleSubmit = vi.fn(); diff --git a/editor.planx.uk/src/components/Feedback/FeedbackForm.tsx b/editor.planx.uk/src/components/Feedback/FeedbackForm/FeedbackForm.tsx similarity index 95% rename from editor.planx.uk/src/components/Feedback/FeedbackForm.tsx rename to editor.planx.uk/src/components/Feedback/FeedbackForm/FeedbackForm.tsx index 34bb1edab2..e8cce41b6f 100644 --- a/editor.planx.uk/src/components/Feedback/FeedbackForm.tsx +++ b/editor.planx.uk/src/components/Feedback/FeedbackForm/FeedbackForm.tsx @@ -7,9 +7,9 @@ import React from "react"; import FeedbackDisclaimer from "ui/public/FeedbackDisclaimer"; import InputLabel from "ui/public/InputLabel"; import ErrorWrapper from "ui/shared/ErrorWrapper"; -import Input from "ui/shared/Input"; +import Input from "ui/shared/Input/Input"; -import { FeedbackFormInput, FormProps, UserFeedback } from "."; +import { FeedbackFormInput, FormProps, UserFeedback } from "../types"; const StyledForm = styled(Form)(({ theme }) => ({ "& > *": contentFlowSpacing(theme), diff --git a/editor.planx.uk/src/components/Feedback/MoreInfoFeedback.stories.tsx b/editor.planx.uk/src/components/Feedback/MoreInfoFeedback/MoreInfoFeedback.stories.tsx similarity index 100% rename from editor.planx.uk/src/components/Feedback/MoreInfoFeedback.stories.tsx rename to editor.planx.uk/src/components/Feedback/MoreInfoFeedback/MoreInfoFeedback.stories.tsx diff --git a/editor.planx.uk/src/components/Feedback/MoreInfoFeedback.test.tsx b/editor.planx.uk/src/components/Feedback/MoreInfoFeedback/MoreInfoFeedback.test.tsx similarity index 100% rename from editor.planx.uk/src/components/Feedback/MoreInfoFeedback.test.tsx rename to editor.planx.uk/src/components/Feedback/MoreInfoFeedback/MoreInfoFeedback.test.tsx diff --git a/editor.planx.uk/src/components/Feedback/MoreInfoFeedback.tsx b/editor.planx.uk/src/components/Feedback/MoreInfoFeedback/MoreInfoFeedback.tsx similarity index 97% rename from editor.planx.uk/src/components/Feedback/MoreInfoFeedback.tsx rename to editor.planx.uk/src/components/Feedback/MoreInfoFeedback/MoreInfoFeedback.tsx index b20401f812..e8f961b5ef 100644 --- a/editor.planx.uk/src/components/Feedback/MoreInfoFeedback.tsx +++ b/editor.planx.uk/src/components/Feedback/MoreInfoFeedback/MoreInfoFeedback.tsx @@ -12,8 +12,8 @@ import { useAnalyticsTracking } from "pages/FlowEditor/lib/analytics/provider"; import React, { useEffect, useRef, useState } from "react"; import FeedbackOption from "ui/public/FeedbackOption"; -import { FeedbackFormInput, UserFeedback } from "."; -import FeedbackForm from "./FeedbackForm"; +import FeedbackForm from "../FeedbackForm/FeedbackForm"; +import { FeedbackFormInput, UserFeedback } from "../types"; const MoreInfoFeedback = styled(Box)(({ theme }) => ({ borderTop: `2px solid ${theme.palette.border.main}`, diff --git a/editor.planx.uk/src/components/Feedback/index.tsx b/editor.planx.uk/src/components/Feedback/index.tsx index 2a0c01bba0..8262fa1f56 100644 --- a/editor.planx.uk/src/components/Feedback/index.tsx +++ b/editor.planx.uk/src/components/Feedback/index.tsx @@ -7,8 +7,6 @@ import WarningIcon from "@mui/icons-material/Warning"; import Box from "@mui/material/Box"; import Container from "@mui/material/Container"; import IconButton from "@mui/material/IconButton"; -import { styled } from "@mui/material/styles"; -import SvgIcon from "@mui/material/SvgIcon"; import Typography from "@mui/material/Typography"; import { getInternalFeedbackMetadata, @@ -20,73 +18,25 @@ import React, { useEffect, useRef, useState } from "react"; import { usePrevious } from "react-use"; import FeedbackOption from "ui/public/FeedbackOption"; -import FeedbackForm from "./FeedbackForm"; +import FeedbackForm from "./FeedbackForm/FeedbackForm"; import FeedbackPhaseBanner from "./FeedbackPhaseBanner"; - -const FeedbackWrapper = styled(Box)(({ theme }) => ({ - backgroundColor: theme.palette.background.paper, - borderTop: `1px solid ${theme.palette.border.main}`, -})); - -const FeedbackRow = styled(Box)(({ theme }) => ({ - maxWidth: theme.breakpoints.values.formWrap, - padding: theme.spacing(2, 0, 4), -})); - -const FeedbackHeader = styled(Box)(({ theme }) => ({ - padding: theme.spacing(1, 0), - position: "relative", - display: "flex", - justifyContent: "space-between", - alignItems: "center", -})); - -const FeedbackTitle = styled(Box)(({ theme }) => ({ - position: "relative", - display: "flex", - alignItems: "center", - "& svg": { - width: "28px", - height: "auto", - color: theme.palette.primary.dark, - marginRight: theme.spacing(1), - }, -})); - -const CloseButton = styled("div")(({ theme }) => ({ - display: "flex", - alignItems: "center", - justifyContent: "flex-end", - color: theme.palette.text.primary, -})); - -const FeedbackBody = styled(Box)(({ theme }) => ({ - maxWidth: theme.breakpoints.values.formWrap, -})); - -export type UserFeedback = { - userContext?: string; - userComment: string; -}; - -export interface FormProps { - inputs: FeedbackFormInput[]; - handleSubmit: (values: UserFeedback) => void; -} - -export type FeedbackFormInput = { - name: keyof UserFeedback; - label: string; - id: string; -}; - -export type FeedbackCategory = "issue" | "idea" | "comment" | "inaccuracy"; +import { + CloseButton, + FeedbackBody, + FeedbackHeader, + FeedbackRow, + FeedbackTitle, + FeedbackWrapper, +} from "./styled"; +import { + ClickEvents, + FeedbackFormInput, + TitleAndCloseProps, + UserFeedback, + View, +} from "./types"; const Feedback: React.FC = () => { - type View = "banner" | "triage" | FeedbackCategory | "thanks"; - - type ClickEvents = "close" | "back" | "triage" | FeedbackCategory; - const [currentFeedbackView, setCurrentFeedbackView] = useState("banner"); const previousFeedbackView = usePrevious(currentFeedbackView); @@ -163,11 +113,6 @@ const Feedback: React.FC = () => { ); } - interface TitleAndCloseProps { - title: string; - Icon?: typeof SvgIcon; - } - function TitleAndCloseFeedbackHeader(props: TitleAndCloseProps): FCReturn { return ( diff --git a/editor.planx.uk/src/components/Feedback/styled.ts b/editor.planx.uk/src/components/Feedback/styled.ts new file mode 100644 index 0000000000..90671e5dfc --- /dev/null +++ b/editor.planx.uk/src/components/Feedback/styled.ts @@ -0,0 +1,43 @@ +import Box from "@mui/material/Box"; +import { styled } from "@mui/material/styles"; + +export const FeedbackWrapper = styled(Box)(({ theme }) => ({ + backgroundColor: theme.palette.background.paper, + borderTop: `1px solid ${theme.palette.border.main}`, +})); + +export const FeedbackRow = styled(Box)(({ theme }) => ({ + maxWidth: theme.breakpoints.values.formWrap, + padding: theme.spacing(2, 0, 4), +})); + +export const FeedbackHeader = styled(Box)(({ theme }) => ({ + padding: theme.spacing(1, 0), + position: "relative", + display: "flex", + justifyContent: "space-between", + alignItems: "center", +})); + +export const FeedbackTitle = styled(Box)(({ theme }) => ({ + position: "relative", + display: "flex", + alignItems: "center", + "& svg": { + width: "28px", + height: "auto", + color: theme.palette.primary.dark, + marginRight: theme.spacing(1), + }, +})); + +export const CloseButton = styled("div")(({ theme }) => ({ + display: "flex", + alignItems: "center", + justifyContent: "flex-end", + color: theme.palette.text.primary, +})); + +export const FeedbackBody = styled(Box)(({ theme }) => ({ + maxWidth: theme.breakpoints.values.formWrap, +})); diff --git a/editor.planx.uk/src/components/Feedback/types.ts b/editor.planx.uk/src/components/Feedback/types.ts new file mode 100644 index 0000000000..1983db838d --- /dev/null +++ b/editor.planx.uk/src/components/Feedback/types.ts @@ -0,0 +1,25 @@ +import SvgIcon from "@mui/material/SvgIcon"; + +export type UserFeedback = { + userContext?: string; + userComment: string; +}; + +export interface FormProps { + inputs: FeedbackFormInput[]; + handleSubmit: (values: UserFeedback) => void; +} + +export type FeedbackFormInput = { + name: keyof UserFeedback; + label: string; + id: string; +}; +export type FeedbackCategory = "issue" | "idea" | "comment" | "inaccuracy"; +export type View = "banner" | "triage" | FeedbackCategory | "thanks"; +export type ClickEvents = "close" | "back" | "triage" | FeedbackCategory; + +export interface TitleAndCloseProps { + title: string; + Icon?: typeof SvgIcon; +} diff --git a/editor.planx.uk/src/components/Footer.stories.tsx b/editor.planx.uk/src/components/Footer/Footer.stories.tsx similarity index 100% rename from editor.planx.uk/src/components/Footer.stories.tsx rename to editor.planx.uk/src/components/Footer/Footer.stories.tsx diff --git a/editor.planx.uk/src/components/Footer.tsx b/editor.planx.uk/src/components/Footer/Footer.tsx similarity index 100% rename from editor.planx.uk/src/components/Footer.tsx rename to editor.planx.uk/src/components/Footer/Footer.tsx diff --git a/editor.planx.uk/src/components/Header.stories.tsx b/editor.planx.uk/src/components/Header/Header.stories.tsx similarity index 100% rename from editor.planx.uk/src/components/Header.stories.tsx rename to editor.planx.uk/src/components/Header/Header.stories.tsx diff --git a/editor.planx.uk/src/components/Header.test.tsx b/editor.planx.uk/src/components/Header/Header.test.tsx similarity index 97% rename from editor.planx.uk/src/components/Header.test.tsx rename to editor.planx.uk/src/components/Header/Header.test.tsx index 8c5f303976..b4504e9cd6 100644 --- a/editor.planx.uk/src/components/Header.test.tsx +++ b/editor.planx.uk/src/components/Header/Header.test.tsx @@ -8,8 +8,8 @@ import { setup } from "testUtils"; import { vi } from "vitest"; import { axe } from "vitest-axe"; -import flowWithoutSections from "../pages/FlowEditor/lib/__tests__/mocks/flowWithClones.json"; -import flowWithThreeSections from "../pages/FlowEditor/lib/__tests__/mocks/flowWithThreeSections.json"; +import flowWithoutSections from "../../pages/FlowEditor/lib/__tests__/mocks/flowWithClones.json"; +import flowWithThreeSections from "../../pages/FlowEditor/lib/__tests__/mocks/flowWithThreeSections.json"; import Header from "./Header"; const { setState, getState } = useStore; diff --git a/editor.planx.uk/src/components/Header.tsx b/editor.planx.uk/src/components/Header/Header.tsx similarity index 97% rename from editor.planx.uk/src/components/Header.tsx rename to editor.planx.uk/src/components/Header/Header.tsx index 2b9e3648b6..b2e6863861 100644 --- a/editor.planx.uk/src/components/Header.tsx +++ b/editor.planx.uk/src/components/Header/Header.tsx @@ -41,11 +41,11 @@ import { import { ApplicationPath } from "types"; import Reset from "ui/icons/Reset"; -import { useStore } from "../pages/FlowEditor/lib/store"; -import { rootFlowPath } from "../routes/utils"; -import AnalyticsDisabledBanner from "./AnalyticsDisabledBanner"; -import { ConfirmationDialog } from "./ConfirmationDialog"; -import TestEnvironmentBanner from "./TestEnvironmentBanner"; +import { useStore } from "../../pages/FlowEditor/lib/store"; +import { rootFlowPath } from "../../routes/utils"; +import AnalyticsDisabledBanner from "../AnalyticsDisabled/AnalyticsDisabledBanner"; +import { ConfirmationDialog } from "../ConfirmationDialog"; +import TestEnvironmentBanner from "../TestEnvironmentBanner/TestEnvironmentBanner"; const HEADER_HEIGHT_PUBLIC = 74; export const HEADER_HEIGHT_EDITOR = 56; diff --git a/editor.planx.uk/src/components/TestEnvironmentBanner.stories.tsx b/editor.planx.uk/src/components/TestEnvironmentBanner/TestEnvironmentBanner.stories.tsx similarity index 100% rename from editor.planx.uk/src/components/TestEnvironmentBanner.stories.tsx rename to editor.planx.uk/src/components/TestEnvironmentBanner/TestEnvironmentBanner.stories.tsx diff --git a/editor.planx.uk/src/components/TestEnvironmentBanner.tsx b/editor.planx.uk/src/components/TestEnvironmentBanner/TestEnvironmentBanner.tsx similarity index 96% rename from editor.planx.uk/src/components/TestEnvironmentBanner.tsx rename to editor.planx.uk/src/components/TestEnvironmentBanner/TestEnvironmentBanner.tsx index bcd17aec95..046ce92133 100644 --- a/editor.planx.uk/src/components/TestEnvironmentBanner.tsx +++ b/editor.planx.uk/src/components/TestEnvironmentBanner/TestEnvironmentBanner.tsx @@ -8,7 +8,7 @@ import { visuallyHidden } from "@mui/utils"; import { useStore } from "pages/FlowEditor/lib/store"; import React from "react"; -import { HEADER_HEIGHT_EDITOR } from "./Header"; +import { HEADER_HEIGHT_EDITOR } from "../Header/Header"; const TestEnvironmentWarning = styled(Box)(({ theme }) => ({ backgroundColor: theme.palette.background.paper, diff --git a/editor.planx.uk/src/pages/FlowEditor/components/Flow/FeedbackPage.tsx b/editor.planx.uk/src/pages/FlowEditor/components/Flow/FeedbackPage.tsx index bab2e090b8..a07dccf763 100644 --- a/editor.planx.uk/src/pages/FlowEditor/components/Flow/FeedbackPage.tsx +++ b/editor.planx.uk/src/pages/FlowEditor/components/Flow/FeedbackPage.tsx @@ -25,8 +25,8 @@ import { client } from "lib/graphql"; import React, { useState } from "react"; import { Feedback } from "routes/feedback"; import SettingsSection from "ui/editor/SettingsSection"; -import ErrorSummary from "ui/shared/ErrorSummary"; -import ReactMarkdownOrHtml from "ui/shared/ReactMarkdownOrHtml"; +import ErrorSummary from "ui/shared/ErrorSummary/ErrorSummary"; +import ReactMarkdownOrHtml from "ui/shared/ReactMarkdownOrHtml/ReactMarkdownOrHtml"; interface Props { feedback: Feedback[]; 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, })} > = ({ const user = await lastPublisher(flowId); setLastPublishedTitle(formatLastPublishMessage(date, user)); - }); + }, [flowId]); const _validateAndDiffRequest = useAsync(async () => { const newChanges = await validateAndDiffFlow(flowId); setAlteredNodes( newChanges?.data.alteredNodes ? newChanges.data.alteredNodes : [], ); - }); + }, [flowId]); // useStore.getState().getTeam().slug undefined here, use window instead const teamSlug = window.location.pathname.split("/")[1]; diff --git a/editor.planx.uk/src/pages/FlowEditor/components/Sidebar/Search/SearchHeader.tsx b/editor.planx.uk/src/pages/FlowEditor/components/Sidebar/Search/SearchHeader.tsx index 0e0aae6fa7..be9bebc53f 100644 --- a/editor.planx.uk/src/pages/FlowEditor/components/Sidebar/Search/SearchHeader.tsx +++ b/editor.planx.uk/src/pages/FlowEditor/components/Sidebar/Search/SearchHeader.tsx @@ -3,8 +3,8 @@ import CircularProgress from "@mui/material/CircularProgress"; import Typography from "@mui/material/Typography"; import React, { useEffect } from "react"; import { Components } from "react-virtuoso"; -import ChecklistItem from "ui/shared/ChecklistItem"; -import Input from "ui/shared/Input"; +import ChecklistItem from "ui/shared/ChecklistItem/ChecklistItem"; +import Input from "ui/shared/Input/Input"; import { Context, Data } from "."; import { ALL_FACETS, DATA_FACETS } from "./facets"; diff --git a/editor.planx.uk/src/pages/FlowEditor/components/Sidebar/Search/SearchResultCard/getDisplayDetailsForResult.tsx b/editor.planx.uk/src/pages/FlowEditor/components/Sidebar/Search/SearchResultCard/getDisplayDetailsForResult.tsx index a19e7cd7e5..1ec69a97fd 100644 --- a/editor.planx.uk/src/pages/FlowEditor/components/Sidebar/Search/SearchResultCard/getDisplayDetailsForResult.tsx +++ b/editor.planx.uk/src/pages/FlowEditor/components/Sidebar/Search/SearchResultCard/getDisplayDetailsForResult.tsx @@ -68,8 +68,7 @@ const keyFormatters: KeyMap = { "data.info": { getDisplayKey: () => "Why it matters", }, - "data.categories.title": { - }, + "data.categories.title": {}, "data.steps.title": { getDisplayKey: () => "Title (step)", }, diff --git a/editor.planx.uk/src/pages/FlowEditor/components/Sidebar/Search/mocks/allFacetFlow.ts b/editor.planx.uk/src/pages/FlowEditor/components/Sidebar/Search/mocks/allFacetFlow.ts index 682cc4c642..7ceba417b2 100644 --- a/editor.planx.uk/src/pages/FlowEditor/components/Sidebar/Search/mocks/allFacetFlow.ts +++ b/editor.planx.uk/src/pages/FlowEditor/components/Sidebar/Search/mocks/allFacetFlow.ts @@ -568,7 +568,7 @@ export const mockConfirmationResult: SearchResult = { key: "data.heading", matchIndices: [[0, 4]], refIndex: 0, - matchValue: "Snake", + matchValue: "Snake", }; export const mockFindPropertyResult: SearchResult = { diff --git a/editor.planx.uk/src/pages/FlowEditor/components/Team/components/EditorUpsertModal.tsx b/editor.planx.uk/src/pages/FlowEditor/components/Team/components/EditorUpsertModal.tsx index b0818de316..b9c74de535 100644 --- a/editor.planx.uk/src/pages/FlowEditor/components/Team/components/EditorUpsertModal.tsx +++ b/editor.planx.uk/src/pages/FlowEditor/components/Team/components/EditorUpsertModal.tsx @@ -10,7 +10,7 @@ import React, { useState } from "react"; import InputGroup from "ui/editor/InputGroup"; import InputLabel from "ui/editor/InputLabel"; import ErrorWrapper from "ui/shared/ErrorWrapper"; -import Input from "ui/shared/Input"; +import Input from "ui/shared/Input/Input"; import { AddNewEditorErrors, @@ -26,6 +26,8 @@ import { optimisticallyUpdateExistingMember, } from "./lib/optimisticallyUpdateMembersTable"; +export const DEMO_TEAM_ID = 32; + export const EditorUpsertModal = ({ setShowModal, showModal, @@ -34,6 +36,8 @@ export const EditorUpsertModal = ({ }: EditorModalProps) => { const [showUserAlreadyExistsError, setShowUserAlreadyExistsError] = useState(false); + const [ teamId, teamSlug ] = useStore(state => [state.teamId, state.teamSlug]) + const isDemoTeam = teamId === DEMO_TEAM_ID; const toast = useToast(); @@ -56,15 +60,11 @@ export const EditorUpsertModal = ({ }; const handleSubmitToAddNewUser = async () => { - const { teamId, teamSlug } = useStore.getState(); - - const createUserResult = await createAndAddUserToTeam( - formik.values.email, - formik.values.firstName, - formik.values.lastName, + const createUserResult = await createAndAddUserToTeam({ + newUser: formik.values, teamId, teamSlug, - ).catch((err) => { + }).catch((err) => { if (isUserAlreadyExistsError(err.message)) { setShowUserAlreadyExistsError(true); } @@ -114,6 +114,8 @@ export const EditorUpsertModal = ({ firstName: initialValues?.firstName || "", lastName: initialValues?.lastName || "", email: initialValues?.email || "", + // Users within the Demo team are granted a role with a restricted permission set + role: isDemoTeam ? "demoUser" : "teamEditor", }, validationSchema: upsertEditorSchema, onSubmit: handleSubmit, diff --git a/editor.planx.uk/src/pages/FlowEditor/components/Team/components/MembersTable.tsx b/editor.planx.uk/src/pages/FlowEditor/components/Team/components/MembersTable.tsx index 989a73eee0..cc4341328b 100644 --- a/editor.planx.uk/src/pages/FlowEditor/components/Team/components/MembersTable.tsx +++ b/editor.planx.uk/src/pages/FlowEditor/components/Team/components/MembersTable.tsx @@ -9,6 +9,7 @@ import TableCell from "@mui/material/TableCell"; import TableContainer from "@mui/material/TableContainer"; import TableHead from "@mui/material/TableHead"; import TableRow from "@mui/material/TableRow"; +import { Role, UserRole } from "@opensystemslab/planx-core/types"; import { AddButton } from "pages/Team"; import React, { useState } from "react"; import Permission from "ui/editor/Permission"; @@ -50,10 +51,12 @@ export const MembersTable = ({ const [actionType, setActionType] = useState("add"); const [initialValues, setInitialValues] = useState(); - const roleLabels: Record = { + const roleLabels: Record = { platformAdmin: "Admin", teamEditor: "Editor", teamViewer: "Viewer", + demoUser: "Demo User", + public: "Public", }; const editUser = (member: TeamMember) => { @@ -72,7 +75,7 @@ export const MembersTable = ({ setInitialValues(undefined); }; - const getRoleLabel = (role: string) => { + const getRoleLabel = (role: Role) => { return roleLabels[role] || role; }; @@ -88,17 +91,19 @@ export const MembersTable = ({ {showAddMemberButton && ( - - - { - addUser(); - }} - > - Add a new editor - - - + + + + { + addUser(); + }} + > + Add a new editor + + + + )} {showModal && ( @@ -127,7 +132,7 @@ export const MembersTable = ({ Email - {" "} + { // empty table cells for styling across buttons } @@ -138,7 +143,7 @@ export const MembersTable = ({ - {members.map((member, i) => ( + {members.map((member) => ( ; -export const createAndAddUserToTeam = async ( - email: string, - firstName: string, - lastName: string, - teamId: number, - teamSlug: string +export const createAndAddUserToTeam = async ({ + newUser, + teamId, + teamSlug, +}: { newUser: AddNewEditorFormValues, teamId: number, teamSlug: string } ) => { - // NB: the user is hard-coded with the 'teamEditor' role for now const response: CreateAndAddUserResponse = await client.mutate({ mutation: gql` mutation CreateAndAddUserToTeam( @@ -22,13 +21,14 @@ export const createAndAddUserToTeam = async ( $firstName: String! $lastName: String! $teamId: Int! + $role: user_roles_enum! ) { - insert_users_one( + insertUsersOne: insert_users_one( object: { email: $email first_name: $firstName last_name: $lastName - teams: { data: { role: teamEditor, team_id: $teamId } } + teams: { data: { role: $role, team_id: $teamId } } } ) { id @@ -36,9 +36,7 @@ export const createAndAddUserToTeam = async ( } `, variables: { - email, - firstName, - lastName, + ...newUser, teamId, }, refetchQueries: [ @@ -47,7 +45,7 @@ export const createAndAddUserToTeam = async ( }); if (response.data) { - return response.data.insert_users_one; + return response.data.insertUsersOne; } throw new Error("Unable to create user"); }; diff --git a/editor.planx.uk/src/pages/FlowEditor/components/Team/tests/TeamMembers.addNewEditor.demoTeam.test.tsx b/editor.planx.uk/src/pages/FlowEditor/components/Team/tests/TeamMembers.addNewEditor.demoTeam.test.tsx new file mode 100644 index 0000000000..2ff56d43bd --- /dev/null +++ b/editor.planx.uk/src/pages/FlowEditor/components/Team/tests/TeamMembers.addNewEditor.demoTeam.test.tsx @@ -0,0 +1,57 @@ +import { waitFor, within } from "@testing-library/react"; +import { useStore } from "pages/FlowEditor/lib/store"; +import { vi } from "vitest"; + +import { DEMO_TEAM_ID } from "../components/EditorUpsertModal"; +import { setupTeamMembersScreen } from "./helpers/setupTeamMembersScreen"; +import { userTriesToAddNewEditor } from "./helpers/userTriesToAddNewEditor"; +import { mockTeamMembersData } from "./mocks/mockTeamMembersData"; +import { mockPlatformAdminUser } from "./mocks/mockUsers"; + +const { setState, getState } = useStore; + +vi.mock( + "pages/FlowEditor/components/Team/queries/createAndAddUserToTeam.tsx", + async () => ({ + createAndAddUserToTeam: vi.fn().mockResolvedValue({ + id: 1, + __typename: "users", + }), + }) +); + +describe("adding a new user to the Demo team", () => { + beforeEach(async () => { + setState({ + user: mockPlatformAdminUser, + teamMembers: mockTeamMembersData, + teamId: DEMO_TEAM_ID, + }); + }); + + it("assigns the `demoUser` role automatically", async () => { + let currentUsers = getState().teamMembers + expect(currentUsers).toHaveLength(3); + + const { user, getByTestId } = await setupTeamMembersScreen(); + await userTriesToAddNewEditor(user); + + const membersTable = getByTestId("members-table-add-editor"); + + await waitFor(() => { + expect( + within(membersTable).getByText(/Mickey Mouse/), + ).toBeInTheDocument(); + }); + + currentUsers = getState().teamMembers + expect(currentUsers).toHaveLength(4); + + // Role correctly assigned to user + const newUser = getState().teamMembers[3]; + expect(newUser.role).toBe("demoUser"); + + // Use role tag displayed in table + expect(within(membersTable).getByText("Demo User")).toBeVisible(); + }); +}); diff --git a/editor.planx.uk/src/pages/FlowEditor/components/Team/tests/TeamMembers.addNewEditor.test.tsx b/editor.planx.uk/src/pages/FlowEditor/components/Team/tests/TeamMembers.addNewEditor.test.tsx index 2707a882df..d740f810d8 100644 --- a/editor.planx.uk/src/pages/FlowEditor/components/Team/tests/TeamMembers.addNewEditor.test.tsx +++ b/editor.planx.uk/src/pages/FlowEditor/components/Team/tests/TeamMembers.addNewEditor.test.tsx @@ -35,6 +35,7 @@ describe("when a user presses 'add a new editor'", () => { teamMembers: mockTeamMembersData, user: mockPlatformAdminUser, teamSlug: "planx", + teamId: 1, }); const { user } = await setupTeamMembersScreen(); @@ -59,6 +60,7 @@ describe("when a user fills in the 'add a new editor' form correctly", () => { teamMembers: mockTeamMembersData, user: mockPlatformAdminUser, teamSlug: "planx", + teamId: 1, }); const { user } = await setupTeamMembersScreen(); await userTriesToAddNewEditor(user); @@ -88,6 +90,13 @@ describe("when a user fills in the 'add a new editor' form correctly", () => { }); describe("when the addNewEditor modal is rendered", () => { + beforeEach(async () => { + useStore.setState({ + teamSlug: "planx", + teamId: 1, + }); + }); + it("should not have any accessibility issues", async () => { const { container } = setup( @@ -112,6 +121,7 @@ describe("'add a new editor' button is hidden from Templates team", () => { teamMembers: mockTeamMembersData, user: mockPlatformAdminUser, teamSlug: "templates", + teamId: 2, }); }); @@ -130,6 +140,7 @@ describe("when a user is not a platform admin", () => { teamMembers: mockTeamMembersData, user: mockPlainUser, teamSlug: "trumptonshire", + teamId: 3, }); }); diff --git a/editor.planx.uk/src/pages/FlowEditor/components/Team/tests/TeamMembers.removeUser.test.tsx b/editor.planx.uk/src/pages/FlowEditor/components/Team/tests/TeamMembers.removeUser.test.tsx index e07a0c74c9..555c39f0ed 100644 --- a/editor.planx.uk/src/pages/FlowEditor/components/Team/tests/TeamMembers.removeUser.test.tsx +++ b/editor.planx.uk/src/pages/FlowEditor/components/Team/tests/TeamMembers.removeUser.test.tsx @@ -25,6 +25,7 @@ describe("when a user presses 'remove' button", () => { teamMembers: mockTeamMembersData, user: mockPlatformAdminUser, teamSlug: "planx", + teamId: 1, }); const { user, container } = await setupTeamMembersScreen(); @@ -74,6 +75,7 @@ describe("when a user clicks 'Remove user' button", () => { teamMembers: mockTeamMembersData, user: mockPlatformAdminUser, teamSlug: "planx", + teamId: 1, }); const { user } = await setupTeamMembersScreen(); @@ -119,6 +121,7 @@ describe("'remove' button is hidden from Templates team", () => { teamMembers: mockTeamMembersData, user: mockPlatformAdminUser, teamSlug: "templates", + teamId: 2, }); }); @@ -137,6 +140,7 @@ describe("when a user is not a platform admin", () => { teamMembers: mockTeamMembersData, user: mockPlainUser, team: "planx", + teamId: 1, }); await setupTeamMembersScreen(); diff --git a/editor.planx.uk/src/pages/FlowEditor/components/Team/tests/TeamMembers.updateEditor.test.tsx b/editor.planx.uk/src/pages/FlowEditor/components/Team/tests/TeamMembers.updateEditor.test.tsx index b43b6478bc..3e4c01497b 100644 --- a/editor.planx.uk/src/pages/FlowEditor/components/Team/tests/TeamMembers.updateEditor.test.tsx +++ b/editor.planx.uk/src/pages/FlowEditor/components/Team/tests/TeamMembers.updateEditor.test.tsx @@ -18,6 +18,7 @@ describe("when a user presses 'edit button'", () => { teamMembers: mockTeamMembersData, user: mockPlatformAdminUser, teamSlug: "planx", + teamId: 1, }); const { user } = await setupTeamMembersScreen(); @@ -171,6 +172,7 @@ describe("'edit' button is hidden from Templates team", () => { teamMembers: mockTeamMembersData, user: mockPlatformAdminUser, teamSlug: "templates", + teamId: 3, }); }); @@ -188,6 +190,7 @@ describe("when a user is not a platform admin", () => { teamMembers: mockTeamMembersData, user: mockPlainUser, team: "planx", + teamId: 1, }); await setupTeamMembersScreen(); diff --git a/editor.planx.uk/src/pages/FlowEditor/components/Team/types.ts b/editor.planx.uk/src/pages/FlowEditor/components/Team/types.ts index 5ad87a8a6e..a2ccdfdcf7 100644 --- a/editor.planx.uk/src/pages/FlowEditor/components/Team/types.ts +++ b/editor.planx.uk/src/pages/FlowEditor/components/Team/types.ts @@ -1,4 +1,4 @@ -import { Role, User } from "@opensystemslab/planx-core/types"; +import { Role, TeamRole, User } from "@opensystemslab/planx-core/types"; import React, { SetStateAction } from "react"; export type TeamMember = ActiveTeamMember | ArchivedTeamMember; @@ -25,6 +25,7 @@ export interface AddNewEditorFormValues { email: string; firstName: string; lastName: string; + role: TeamRole; } export interface UpdateEditorFormValues { diff --git a/editor.planx.uk/src/pages/FlowEditor/index.tsx b/editor.planx.uk/src/pages/FlowEditor/index.tsx index f582340893..90c32cae45 100644 --- a/editor.planx.uk/src/pages/FlowEditor/index.tsx +++ b/editor.planx.uk/src/pages/FlowEditor/index.tsx @@ -3,9 +3,9 @@ import "./floweditor.scss"; import Box from "@mui/material/Box"; import ButtonGroup from "@mui/material/ButtonGroup"; import { styled } from "@mui/material/styles"; -import { HEADER_HEIGHT_EDITOR } from "components/Header"; +import { HEADER_HEIGHT_EDITOR } from "components/Header/Header"; import React, { useRef } from "react"; -import { useCurrentRoute } from "react-navi"; +import { rootFlowPath } from "routes/utils"; import Flow from "./components/Flow"; import { ToggleDataFieldsButton } from "./components/FlowEditor/ToggleDataFieldsButton"; @@ -40,8 +40,8 @@ const EditorVisualControls = styled(ButtonGroup)(({ theme }) => ({ })); const FlowEditor = () => { - const [flow, ...breadcrumbs] = - useCurrentRoute().url.pathname.split("/").at(-1)?.split(",") || []; + const flowPath = rootFlowPath(true).split("/")[2]; + const [flow, ...breadcrumbs] = flowPath.split(","); const scrollContainerRef = useRef(null); useScrollControlsAndRememberPosition(scrollContainerRef); 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..0fd9f03ca6 --- /dev/null +++ b/editor.planx.uk/src/pages/FlowEditor/lib/__tests__/automations.blanks.test.ts @@ -0,0 +1,508 @@ +import { Store, useStore } from "../store"; +import { clickContinue } from "./utils"; + +const { getState, setState } = useStore; +const { resetPreview, upcomingCardIds, autoAnswerableOptions, computePassport } = getState(); + +describe("Auto-answering blanks", () => { + beforeEach(() => { + resetPreview(); + setState({ flow }); + }); + + test("Checklists with the same options auto-answer the blank", () => { + // Manually select the test path + expect(upcomingCardIds()).toEqual(["TestPathSelection"]); + clickContinue("TestPathSelection", { answers: ["TestPath1"], auto: false }); + + // Manually proceed through the first blank + expect(upcomingCardIds()).toEqual(["Path1Checklist1", "Path1Checklist2"]); + clickContinue("Path1Checklist1", { answers: ["Path1Checklist1Blank"], auto: false }); + + // The second blank is auto-answered because we do not have a passport value but we've seen all options before + expect(computePassport()?.data).not.toHaveProperty("option"); + expect(autoAnswerableOptions("Path1Checklist2")).toEqual(["Path1Checklist2Blank"]); + }); + + test("Checklists with different options do not auto-answer the blank", () => { + // Manually select the test path + expect(upcomingCardIds()).toEqual(["TestPathSelection"]); + clickContinue("TestPathSelection", { answers: ["TestPath2"], auto: false }); + + // Manually proceed through the first blank + expect(upcomingCardIds()).toEqual(["Path2Checklist1", "Path2Checklist2"]); + clickContinue("Path2Checklist1", { answers: ["Path2Checklist1Blank"], auto: false }); + + // The second blank is put to the user because we do not have a passport value and we have NOT seen all options before + expect(computePassport()?.data).not.toHaveProperty("option"); + expect(autoAnswerableOptions("Path2Checklist2")).toBeUndefined(); + }); + + test("Questions with the same options auto-answer the blank", () => { + // Manually select the test path + expect(upcomingCardIds()).toEqual(["TestPathSelection"]); + clickContinue("TestPathSelection", { answers: ["TestPath3"], auto: false }); + + // Manually proceed through the first blank + expect(upcomingCardIds()).toEqual(["Path3Question1", "Path3Question2"]); + clickContinue("Path3Question1", { answers: ["Path3Question1Blank"], auto: false }); + + // The second blank is auto-answered because we do not have a passport value but we've seen all options before + expect(computePassport()?.data).not.toHaveProperty("option"); + expect(autoAnswerableOptions("Path3Question1")).toEqual(["Path3Question1Blank"]); + }); + + test("Questions with different options do not auto-answer the blank", () => { + // Manually select the test path + expect(upcomingCardIds()).toEqual(["TestPathSelection"]); + clickContinue("TestPathSelection", { answers: ["TestPath4"], auto: false }); + + // Manually proceed through the first blank + expect(upcomingCardIds()).toEqual(["Path4Question1", "Path4Question2", "Path4Question3"]); + clickContinue("Path4Question1", { answers: ["Path4Question1Blank"], auto: false }); + + // The second blank is put to the user because we do not have a passport value and we have NOT seen all options before + expect(computePassport()?.data).not.toHaveProperty("option"); + expect(autoAnswerableOptions("Path4Question2")).toBeUndefined(); + + // Manually proceed through the second blank + clickContinue("Path4Question2", { answers: ["Path4Question2Blank"], auto: false }); + + // The third blank is auto-answered because we do not have a passport value but we've seen all options before now + expect(computePassport()?.data).not.toHaveProperty("option"); + expect(autoAnswerableOptions("Path4Question3")).toEqual(["Path4Question3Blank"]); + }); + + test("Checklist then a Question with the same options auto-answer the blank", () => { + // Manually select the test path + expect(upcomingCardIds()).toEqual(["TestPathSelection"]); + clickContinue("TestPathSelection", { answers: ["TestPath5"], auto: false }); + + // Manually proceed through the first blank + expect(upcomingCardIds()).toEqual(["Path5Checklist", "Path5Question"]); + clickContinue("Path5Checklist", { answers: ["Path5ChecklistBlank"], auto: false }); + + // The second blank is auto-answered because we do not have a passport value but we've seen all options before + expect(computePassport()?.data).not.toHaveProperty("option"); + expect(autoAnswerableOptions("Path5Question")).toEqual(["Path5QuestionBlank"]); + }); +}); + +// editor.planx.dev/testing/automate-blanks-test +const flow: Store.Flow = { + "_root": { + "edges": [ + "TestPathSelection" + ] + }, + "Path5Checklist1OptionA": { + "data": { + "val": "a", + "text": "A" + }, + "type": 200 + }, + "Path5Checklist": { + "data": { + "fn": "option", + "text": "Options 1", + "allRequired": false + }, + "type": 105, + "edges": [ + "Path5ChecklistOptionA", + "Path5ChecklistOptionB", + "Path5ChecklistBlank" + ] + }, + "Path4Question2Blank": { + "data": { + "text": "None" + }, + "type": 200 + }, + "Path3Question2": { + "data": { + "fn": "option", + "text": "Options 2" + }, + "type": 100, + "edges": [ + "Path3Question2OptionA", + "Path3Question2Blank" + ] + }, + "Path1Checklist1": { + "data": { + "fn": "option", + "text": "Options 1", + "allRequired": false + }, + "type": 105, + "edges": [ + "Path5Checklist1OptionA", + "Path5Checklist1OptionB", + "Path5Checklist1Blank" + ] + }, + "Path5ChecklistOptionA": { + "data": { + "val": "a", + "text": "A" + }, + "type": 200 + }, + "TestPathSelection": { + "data": { + "text": "Which flow?" + }, + "type": 100, + "edges": [ + "TestPath1", + "TestPath2", + "TestPath3", + "TestPath4", + "TestPath5" + ] + }, + "Path3Question1": { + "data": { + "fn": "option", + "text": "Options 1" + }, + "type": 100, + "edges": [ + "Path3Question1OptionA", + "Path3Question1Blank" + ] + }, + "Path5Question": { + "data": { + "fn": "option", + "text": "Options 2" + }, + "type": 100, + "edges": [ + "Path5QuestionOptionA", + "Path5QuestionOptionB", + "Path5QuestionBlank" + ] + }, + "Path3Question1OptionA": { + "data": { + "val": "a", + "text": "A" + }, + "type": 200 + }, + "Path3Question2OptionA": { + "data": { + "val": "a", + "text": "A" + }, + "type": 200 + }, + "Path1Checklist2OptionA": { + "data": { + "val": "a", + "text": "A" + }, + "type": 200 + }, + "Path2Checklist1": { + "data": { + "fn": "option", + "text": "Options 1", + "allRequired": false + }, + "type": 105, + "edges": [ + "Path2Checklist1OptionA", + "Path2Checklist1OptionB", + "Path2Checklist1Blank" + ] + }, + "Path5QuestionBlank": { + "data": { + "text": "None" + }, + "type": 200 + }, + "Path2Checklist2Blank": { + "data": { + "text": "None" + }, + "type": 200 + }, + "Path4Question1": { + "data": { + "fn": "option", + "text": "Options 1" + }, + "type": 100, + "edges": [ + "Path4Question1OptionA", + "Path4Question1Blank" + ] + }, + "TestPath2": { + "data": { + "text": "2", + "description": "Checklists with different options" + }, + "type": 200, + "edges": [ + "Path2Checklist1", + "Path2Checklist2" + ] + }, + "Path4Question2OptionA": { + "data": { + "val": "a", + "text": "A" + }, + "type": 200 + }, + "Path4Question1Blank": { + "data": { + "text": "None" + }, + "type": 200 + }, + "TestPath5": { + "data": { + "text": "5", + "description": "Checklist then question with same options" + }, + "type": 200, + "edges": [ + "Path5Checklist", + "Path5Question" + ] + }, + "Path4Question1OptionA": { + "data": { + "val": "a", + "text": "A" + }, + "type": 200 + }, + "Path2Checklist2OptionA": { + "data": { + "val": "a", + "text": "A" + }, + "type": 200 + }, + "Path5ChecklistOptionB": { + "data": { + "val": "b", + "text": "B" + }, + "type": 200 + }, + "Path1Checklist2OptionB": { + "data": { + "val": "b", + "text": "B" + }, + "type": 200 + }, + "Path5QuestionOptionB": { + "data": { + "val": "b", + "text": "B" + }, + "type": 200 + }, + "TestPath3": { + "data": { + "text": "3", + "description": "Questions with same options" + }, + "type": 200, + "edges": [ + "Path3Question1", + "Path3Question2" + ] + }, + "Path3Question1Blank": { + "data": { + "text": "None" + }, + "type": 200 + }, + "Path2Checklist2OptionB": { + "data": { + "val": "b", + "text": "B" + }, + "type": 200 + }, + "Path4Question2OptionB": { + "data": { + "val": "b", + "text": "B" + }, + "type": 200 + }, + "Path3Question2Blank": { + "data": { + "text": "None" + }, + "type": 200 + }, + "Path2Checklist1Blank": { + "data": { + "text": "None" + }, + "type": 200 + }, + "Path5Checklist1Blank": { + "data": { + "text": "None" + }, + "type": 200 + }, + "Path4Question3": { + "data": { + "fn": "option", + "text": "Options 3" + }, + "type": 100, + "edges": [ + "Path4Question3OptionB", + "Path4Question3OptionA", + "Path4Question3Blank" + ] + }, + "Path4Question3Blank": { + "data": { + "text": "None" + }, + "type": 200 + }, + "Path1Checklist2": { + "data": { + "fn": "option", + "text": "Options 2", + "allRequired": false + }, + "type": 105, + "edges": [ + "Path1Checklist2OptionA", + "Path1Checklist2OptionB", + "Path1Checklist2Blank" + ] + }, + "TestPath1": { + "data": { + "text": "1", + "description": "Checklists with same options" + }, + "type": 200, + "edges": [ + "Path1Checklist1", + "Path1Checklist2" + ] + }, + "TestPath4": { + "data": { + "text": "4", + "description": "Questions with different options" + }, + "type": 200, + "edges": [ + "Path4Question1", + "Path4Question2", + "Path4Question3" + ] + }, + "Path5ChecklistBlank": { + "data": { + "text": "None" + }, + "type": 200 + }, + "Path4Question2": { + "data": { + "fn": "option", + "text": "Options 2" + }, + "type": 100, + "edges": [ + "Path4Question2OptionA", + "Path4Question2OptionB", + "Path4Question2Blank" + ] + }, + "Path1Checklist2Blank": { + "data": { + "text": "None" + }, + "type": 200 + }, + "Path4Question3OptionB": { + "data": { + "val": "b", + "text": "B" + }, + "type": 200 + }, + "Path2Checklist1OptionB": { + "data": { + "val": "b", + "text": "B" + }, + "type": 200 + }, + "Path2Checklist2": { + "data": { + "fn": "option", + "text": "Options 2", + "allRequired": false + }, + "type": 105, + "edges": [ + "Path2Checklist2OptionA", + "Path2Checklist2OptionB", + "Path2Checklist2OptionC", + "Path2Checklist2Blank" + ] + }, + "Path5Checklist1OptionB": { + "data": { + "val": "b", + "text": "B" + }, + "type": 200 + }, + "Path4Question3OptionA": { + "data": { + "val": "a", + "text": "A" + }, + "type": 200 + }, + "Path5QuestionOptionA": { + "data": { + "val": "a", + "text": "A" + }, + "type": 200 + }, + "Path2Checklist1OptionA": { + "data": { + "val": "a", + "text": "A" + }, + "type": 200 + }, + "Path2Checklist2OptionC": { + "data": { + "val": "c", + "text": "C" + }, + "type": 200 + } +}; diff --git a/editor.planx.uk/src/pages/FlowEditor/lib/__tests__/automations.parentChild.test.ts b/editor.planx.uk/src/pages/FlowEditor/lib/__tests__/automations.parentChild.test.ts new file mode 100644 index 0000000000..b03fb9c12a --- /dev/null +++ b/editor.planx.uk/src/pages/FlowEditor/lib/__tests__/automations.parentChild.test.ts @@ -0,0 +1,207 @@ +import { Store, useStore } from "../store"; +import { clickContinue } from "./utils"; + +const { getState, setState } = useStore; +const { upcomingCardIds, resetPreview, autoAnswerableOptions, computePassport } = getState(); + +describe("Parent-child automations and granularity", () => { + beforeEach(() => { + resetPreview(); + setState({ flow }); + }); + + test("Selecting `a` and `a.x` only auto-answers `a.x`", () => { + // Manually answer the first Checklist + expect(upcomingCardIds()).toEqual(["Checklist1", "Checklist2", "Question"]); + clickContinue("Checklist1", { answers: ["Checklist1ParentA", "Checklist1ChildAX"], auto: false }); + + // Only the most granular value is retained in the passport and queued up for auto-answering the subsequent Checklist & Question + expect(upcomingCardIds()?.[0]).toEqual("Checklist2"); + expect(computePassport()?.data).toEqual({ "values": ["a.x"] }); + expect(autoAnswerableOptions("Checklist2")).toEqual(["Checklist2ChildAX"]); + expect(autoAnswerableOptions("Checklist2")).not.toContain("Checklist2ParentA"); + clickContinue("Checklist2", { answers: autoAnswerableOptions("Checklist2"), auto: true }); + + expect(upcomingCardIds()?.[0]).toEqual("Question"); + expect(computePassport()?.data).toEqual({ "values": ["a.x"] }); + expect(autoAnswerableOptions("Question")).toEqual(["QuestionChildAX"]); + expect(autoAnswerableOptions("Question")).toHaveLength(1); + }); + + test("Selecting `a` and `b` auto-answers both", () => { + // Manually answer the first Checklist + expect(upcomingCardIds()).toEqual(["Checklist1", "Checklist2", "Question"]); + clickContinue("Checklist1", { answers: ["Checklist1ParentA", "Checklist1ParentB"], auto: false }); + + // Both values are queued up for auto-answering the subsequent Checklist because they are the same granularity + expect(upcomingCardIds()?.[0]).toEqual("Checklist2"); + expect(computePassport()?.data).toEqual({ "values": ["a", "b"] }); + expect(autoAnswerableOptions("Checklist2")).toEqual(["Checklist2ParentA", "Checklist2ParentB"]); + clickContinue("Checklist2", { answers: autoAnswerableOptions("Checklist2"), auto: true }); + + // Only the left-most value is queued up for auto-answering the Question + expect(upcomingCardIds()?.[0]).toEqual("Question"); + expect(computePassport()?.data).toEqual({ "values": ["a", "b"] }); + expect(autoAnswerableOptions("Question")).toEqual(["QuestionParentA"]); + expect(autoAnswerableOptions("Question")).toHaveLength(1); + }); + + test("Selecting `a`, `a.x` and `b` auto-answers `a.x` and `b` if a Checklist and only `a.x` if a Question", () => { + // Manually answer the first Checklist + expect(upcomingCardIds()).toEqual(["Checklist1", "Checklist2", "Question"]); + clickContinue("Checklist1", { answers: ["Checklist1ParentA", "Checklist1ChildAX", "Checklist1ParentB"], auto: false }); + + // Only the most granular value _per_ parent category is retained in the passport and queued up for auto-answering the subsequent Checklist + expect(upcomingCardIds()?.[0]).toEqual("Checklist2"); + expect(computePassport()?.data).toEqual({ "values": ["a.x", "b"] }); + expect(autoAnswerableOptions("Checklist2")).toEqual(["Checklist2ChildAX", "Checklist2ParentB"]); + expect(autoAnswerableOptions("Checklist2")).not.toContain("Checklist2ParentA"); + clickContinue("Checklist2", { answers: autoAnswerableOptions("Checklist2"), auto: true }); + + // Only the most granular, left-most value is queued up for auto-answering the Question + expect(upcomingCardIds()?.[0]).toEqual("Question"); + expect(computePassport()?.data).toEqual({ "values": ["a.x", "b"] }); + expect(autoAnswerableOptions("Question")).toEqual(["QuestionChildAX"]); + expect(autoAnswerableOptions("Question")).toHaveLength(1); + }); +}); + +// editor.planx.dev/testing/behaviour-check +const flow: Store.Flow = { + "_root": { + "edges": [ + "Checklist1", + "Checklist2", + "Question" + ] + }, + "QuestionParentB": { + "data": { + "val": "b", + "text": "b parent" + }, + "type": 200 + }, + "Checklist1ParentA": { + "data": { + "val": "a", + "text": "a parent" + }, + "type": 200 + }, + "Checklist2ChildAX": { + "data": { + "val": "a.x", + "text": "a child" + }, + "type": 200 + }, + "Checklist1": { + "data": { + "fn": "values", + "tags": [], + "text": "Pick many", + "allRequired": false, + "neverAutoAnswer": false + }, + "type": 105, + "edges": [ + "Checklist1ParentA", + "Checklist1ChildAX", + "Checklist1ParentB" + ] + }, + "Checklist2": { + "data": { + "fn": "values", + "tags": [], + "text": "Pick many", + "allRequired": false, + "neverAutoAnswer": false + }, + "type": 105, + "edges": [ + "Checklist2ParentA", + "Checklist2ChildAX", + "Checklist2ParentB", + "Checklist2ParentC", + "Checklist2Blank" + ] + }, + "QuestionChildAX": { + "data": { + "val": "a.x", + "text": "a child" + }, + "type": 200 + }, + "QuestionParentA": { + "data": { + "val": "a", + "text": "a parent" + }, + "type": 200 + }, + "Checklist2ParentC": { + "data": { + "val": "c", + "text": "c parent" + }, + "type": 200 + }, + "QuestionBlank": { + "data": { + "text": "blank" + }, + "type": 200 + }, + "Checklist2Blank": { + "data": { + "text": "blank" + }, + "type": 200 + }, + "Checklist2ParentA": { + "data": { + "val": "a", + "text": "a parent" + }, + "type": 200 + }, + "Question": { + "data": { + "fn": "values", + "tags": [], + "text": "Pick one", + "neverAutoAnswer": false + }, + "type": 100, + "edges": [ + "QuestionParentA", + "QuestionChildAX", + "QuestionParentB", + "QuestionBlank" + ] + }, + "Checklist1ParentB": { + "data": { + "val": "b", + "text": "b parent" + }, + "type": 200 + }, + "Checklist2ParentB": { + "data": { + "val": "b", + "text": "b parent" + }, + "type": 200 + }, + "Checklist1ChildAX": { + "data": { + "val": "a.x", + "text": "a child" + }, + "type": 200 + } +}; diff --git a/editor.planx.uk/src/pages/FlowEditor/lib/__tests__/automations.planningConstraintNots.test.ts b/editor.planx.uk/src/pages/FlowEditor/lib/__tests__/automations.planningConstraintNots.test.ts new file mode 100644 index 0000000000..1f677455d8 --- /dev/null +++ b/editor.planx.uk/src/pages/FlowEditor/lib/__tests__/automations.planningConstraintNots.test.ts @@ -0,0 +1,154 @@ +import { Store, useStore } from "../store"; +import { clickContinue } from "./utils"; + +const { getState, setState } = useStore; +const { upcomingCardIds, resetPreview, autoAnswerableOptions, computePassport } = getState(); + +describe("Auto-answering using planning constraints `_nots`", () => { + beforeEach(() => { + resetPreview(); + setState({ flow }); + }); + + test("When there are postive intersecting constraints and `_nots`", () => { + expect(upcomingCardIds()).toEqual(["PlanningConstraints", "ConservationAreaQuestion", "Article4Question", "FloodZone1Question"]); + + // Manually proceed forward through PlanningConstraints as if we've checked 4x datasets: Article 4, Conservation Area, Flood Zone 2, Flood Zone 3 + clickContinue("PlanningConstraints", { + data: { + "property.constraints.planning": ["article4"], + "_nots": { + "property.constraints.planning": ["designated.conservationArea", "flood.zone.2", "flood.zone.3"] + } + }, + auto: false + }); + + expect(computePassport()?.data).toHaveProperty("property.constraints.planning"); + expect(computePassport()?.data).toHaveProperty(["_nots", "property.constraints.planning"]); + + // Confirm auto-answer behavior + expect(autoAnswerableOptions("ConservationAreaQuestion")).toEqual(["ConservationAreaNo"]); + expect(autoAnswerableOptions("Article4Question")).toEqual(["Article4Yes"]); + expect(autoAnswerableOptions("FloodZone1Question")).toEqual(["FloodZone1No"]); // Because we have passport vals, follows blank independent of options + }); + + test("When there are only negative `_nots` constraints", () => { + expect(upcomingCardIds()).toEqual(["PlanningConstraints", "ConservationAreaQuestion", "Article4Question", "FloodZone1Question"]); + + // Manually proceed forward through PlanningConstraints as if we've checked 4x datasets: Article 4, Conservation Area, Flood Zone 2, Flood Zone 3 + clickContinue("PlanningConstraints", { + data: { + "_nots": { + "property.constraints.planning": ["article4", "designated.conservationArea", "flood.zone.2", "flood.zone.3"] + } + }, + auto: false + }); + + expect(computePassport()?.data).not.toHaveProperty("property.constraints.planning"); + expect(computePassport()?.data).toHaveProperty(["_nots", "property.constraints.planning"]); + + // Confirm auto-answer behavior + expect(autoAnswerableOptions("ConservationAreaQuestion")).toEqual(["ConservationAreaNo"]); + expect(autoAnswerableOptions("Article4Question")).toEqual(["Article4No"]); + expect(autoAnswerableOptions("FloodZone1Question")).toBeUndefined(); // Because we do not have positive passport vals, puts to user because unseen option + }); +}); + +const flow: Store.Flow = { + "_root": { + "edges": [ + "PlanningConstraints", + "ConservationAreaQuestion", + "Article4Question", + "FloodZone1Question" // flood.zone.1 is NOT fetched or set by Planning Data + ] + }, + "PlanningConstraints": { + "type": 11, + "data": { + "title": "Planning constraints", + "description": "Planning constraints might limit how you can develop or use the property", + "fn": "property.constraints.planning", + "disclaimer": "

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

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

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

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

Listed and permitted dev path

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

Not listed and permission needed path

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

Not listed and permitted dev path

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

Listed and permission needed path

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

No result path 1

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

No result path 2

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

Immunity path 1

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

Immunity path 2

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

this project is immune

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

this project is not immune

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

last thing

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

last thing

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

Pause

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

Selected orange

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

Welcome to this test flow

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

Selected banana

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

Selected apple

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

Welcome to this test flow

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

Selected apple

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

Selected orange

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

Selected banana

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

it's a lion

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

it's a tiger or something else

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

it's a gazelle

\n", - }, - type: 250, - }, - sv0hklWPX1: { - data: { - val: "lion", - text: "lion", - }, - type: 200, - }, -}; - -const { getState, setState } = useStore; - -describe("if I initially pick", () => { - beforeEach(() => { - getState().resetPreview(); - setState({ flow }); - }); - - test("lion, it should display 'lion'", () => { - getState().record("NS7QFc7Cjc", { answers: ["TDIbLrdTdd"] }); - expect(getState().upcomingCardIds()).toEqual(["Sd38UCC8Cg"]); - }); - - test("tiger, it should display 'tiger or something else'", () => { - getState().record("NS7QFc7Cjc", { answers: ["UqZo0rGwcY"] }); - expect(getState().upcomingCardIds()).toEqual(["UOefNWg6uf"]); - }); - - test("gazelle, it should ask which animal it is", () => { - getState().record("NS7QFc7Cjc", { answers: ["BecasKrIhI"] }); - expect(getState().upcomingCardIds()).toEqual(["eTBHJsbJKc"]); - getState().record("eTBHJsbJKc", { answers: ["nR15Tl0lhC"] }); - expect(getState().upcomingCardIds()).toEqual(["pqZK1mpn23"]); - }); -}); - -test("back button works as expected", () => { - getState().resetPreview(); - setState({ - flow, - breadcrumbs: { - NS7QFc7Cjc: { - answers: ["BecasKrIhI"], - auto: false, - }, - "3cNtq1pLmt": { - answers: ["TnvmCtle0s"], - auto: true, - }, - eTBHJsbJKc: { - answers: ["nR15Tl0lhC"], - auto: false, - }, - }, - }); - - getState().record("eTBHJsbJKc"); - expect(getState().upcomingCardIds()).toEqual(["eTBHJsbJKc"]); -}); diff --git a/editor.planx.uk/src/pages/FlowEditor/lib/__tests__/utils.ts b/editor.planx.uk/src/pages/FlowEditor/lib/__tests__/utils.ts new file mode 100644 index 0000000000..13b421c3da --- /dev/null +++ b/editor.planx.uk/src/pages/FlowEditor/lib/__tests__/utils.ts @@ -0,0 +1,18 @@ +import { NodeId } from "@opensystemslab/planx-core/types"; +import { Store, useStore } from "../store"; + +const { getState } = useStore; +const { upcomingCardIds, record } = getState(); + +/** + * @returns List of nodes ids that have been visited (seen or automated) + */ +export const visitedNodes = () => Object.keys(getState().breadcrumbs); + +/** + * Mimic clicking "Continue" button on a card and submitting user data + */ +export const clickContinue = (nodeId: NodeId, userData: Store.UserData) => { + record(nodeId, userData); + upcomingCardIds(); +}; diff --git a/editor.planx.uk/src/pages/FlowEditor/lib/store/preview.ts b/editor.planx.uk/src/pages/FlowEditor/lib/store/preview.ts index 66bf427076..8a3c67343b 100644 --- a/editor.planx.uk/src/pages/FlowEditor/lib/store/preview.ts +++ b/editor.planx.uk/src/pages/FlowEditor/lib/store/preview.ts @@ -8,27 +8,24 @@ import type { import { DEFAULT_FLAG_CATEGORY, flatFlags, + ComponentType as TYPES, } from "@opensystemslab/planx-core/types"; -import { ComponentType as TYPES } from "@opensystemslab/planx-core/types"; import { FileList } from "@planx/components/FileUploadAndLabel/model"; import { SetValue } from "@planx/components/SetValue/model"; import { handleSetValue } from "@planx/components/SetValue/utils"; import { sortIdsDepthFirst } from "@planx/graph"; import { logger } from "airbrake"; import { objectWithoutNullishValues } from "lib/objectHelpers"; -import difference from "lodash/difference"; -import flatten from "lodash/flatten"; import isEqual from "lodash/isEqual"; -import isNil from "lodash/isNil"; import omit from "lodash/omit"; import pick from "lodash/pick"; import uniq from "lodash/uniq"; import { v4 as uuidV4 } from "uuid"; import type { StateCreator } from "zustand"; +import type { Store } from "."; import type { Session } from "./../../../../types"; import { ApplicationPath } from "./../../../../types"; -import type { Store } from "."; import { NavigationStore } from "./navigation"; import type { SharedStore } from "./shared"; @@ -88,6 +85,8 @@ export interface PreviewStore extends Store.Store { saveToEmail?: string; overrideAnswer: (fn: string) => void; requestedFiles: () => FileList; + autoAnswerableOptions: (id: NodeId) => Array | undefined; + autoAnswerableFlag: (filterId: NodeId) => NodeId | undefined; } export const previewStore: StateCreator< @@ -395,189 +394,31 @@ export const previewStore: StateCreator< sessionId: uuidV4(), upcomingCardIds() { - const { flow, breadcrumbs, computePassport, collectedFlags } = get(); - - const knownNotVals = knownNots( - flow, - breadcrumbs, - // _nots is created by PlanningConstraints/Public - computePassport().data?._nots, - ); + const { flow, breadcrumbs } = get(); const ids: Set = new Set(); - const visited: Set = new Set(); + // Based on a given node, get the nodes we should navigate through next: the children of any selected options, as well as nodes on the _root graph that should be seen no matter which option is selected const nodeIdsConnectedFrom = (source: NodeId): void => { return (flow[source]?.edges ?? []) .filter((id) => { - if (visited.has(id)) return false; - - visited.add(id); - + // Filter out nodes we've already visited (aka have a breadcrumb for) (eg clones) const node = flow[id]; - - return ( - node && - !breadcrumbs[id] && - ((node.edges || []).length > 0 || - (node.type && !SUPPORTED_DECISION_TYPES.includes(node.type))) - ); + return node && !breadcrumbs[id]; }) .forEach((id) => { const node = flow[id]; - const passport = computePassport(); - + // Recursively get children in internal portals if (node.type === TYPES.InternalPortal) { return nodeIdsConnectedFrom(id); } - const fn = node.type === TYPES.Filter ? "flag" : node.data?.fn; - - const [globalFlag] = collectedFlags(id, Array.from(visited)); - - let passportValues = (() => { - try { - return fn === "flag" ? globalFlag : passport.data?.[fn]?.sort(); - } catch (err) { - return []; - } - })(); - - if (fn && (fn === "flag" || passportValues !== undefined)) { - const responses = node.edges?.map((id) => ({ - id, - ...flow[id], - })); - - let responsesThatCanBeAutoAnswered = [] as any[]; - - const sortedResponses = responses - ? responses - // sort by the most to least number of comma-separated items in data.val - .sort( - (a: any, b: any) => - String(b.data?.val).split(",").length - - String(a.data?.val).split(",").length, - ) - .filter((response) => response.data?.val) - : []; - - if (passportValues !== undefined) { - if (!Array.isArray(passportValues)) - passportValues = [passportValues]; - - passportValues = (passportValues || []).filter((pv: any) => - sortedResponses.some((r) => pv.startsWith(r.data?.val)), - ); - - if (passportValues.length > 0) { - responsesThatCanBeAutoAnswered = (sortedResponses || []).filter( - (r) => { - const responseValues = String(r.data?.val) - .split(",") - .sort(); - return String(responseValues) === String(passportValues); - }, - ); - - if (responsesThatCanBeAutoAnswered.length === 0) { - responsesThatCanBeAutoAnswered = ( - sortedResponses || [] - ).filter((r) => { - const responseValues = String(r.data?.val) - .split(",") - .sort(); - - for (const responseValue of responseValues) { - return passportValues.some((passportValue: any) => - String(passportValue).startsWith(responseValue), - ); - } - }); - } - } - } - - if (responsesThatCanBeAutoAnswered.length === 0) { - const _responses = (responses || []).filter( - (r) => !knownNotVals[fn]?.includes(r.data?.val), - ); - - if (_responses.length === 1 && isNil(_responses[0].data?.val)) { - responsesThatCanBeAutoAnswered = _responses; - } else if ( - !passport.data?.[fn] || - passport.data?.[fn].length > 0 - ) { - responsesThatCanBeAutoAnswered = (responses || []).filter( - (r) => !r.data?.val, - ); - } - } - - if (responsesThatCanBeAutoAnswered.length > 0) { - if (node.type !== TYPES.Checklist) { - responsesThatCanBeAutoAnswered = - responsesThatCanBeAutoAnswered.slice(0, 1); - } - - if (fn !== "flag") { - set({ - breadcrumbs: { - ...breadcrumbs, - [id]: { - answers: responsesThatCanBeAutoAnswered.map((r) => r.id), - auto: true, - }, - }, - }); - } - - return responsesThatCanBeAutoAnswered.forEach((r) => - nodeIdsConnectedFrom(r.id), - ); - } - } else if ( - fn && - knownNotVals[fn] && - passportValues === undefined && - Array.isArray(node.edges) - ) { - const data = node.edges.reduce( - (acc, edgeId) => { - if (flow[edgeId].data?.val === undefined) { - acc.responseWithNoValueId = edgeId; - } else if (!knownNotVals[fn].includes(flow[edgeId].data?.val)) { - acc.edges.push(edgeId); - } - return acc; - }, - { edges: [] } as { - responseWithNoValueId?: NodeId; - edges: Array; - }, - ); - - if (data.responseWithNoValueId && data.edges.length === 0) { - set({ - breadcrumbs: { - ...breadcrumbs, - [id]: { - answers: [data.responseWithNoValueId], - auto: true, - }, - }, - }); - return nodeIdsConnectedFrom(data.responseWithNoValueId); - } - } - ids.add(id); }); }; - // with a guaranteed unique set + // With a guaranteed unique set new Set( // of all the answers collected so far Object.values(breadcrumbs) @@ -590,10 +431,164 @@ export const previewStore: StateCreator< // run nodeIdsConnectedFrom(answerId) ).forEach(nodeIdsConnectedFrom); - // then return an array of the upcoming node ids, in depth-first order + // Then return an array of the upcoming node ids, in depth-first order return sortIdsDepthFirst(flow)(ids); }, + /** + * Questions and Checklists auto-answer based on passport values + * @param id - id of the Question or Checklist node + * @returns - list of ids of the Answer nodes which can auto-answered (max length 1 for Questions) + */ + autoAnswerableOptions: (id: NodeId) => { + const { breadcrumbs, flow, computePassport } = get(); + const { type, data, edges } = flow[id]; + const { data: passportData } = computePassport(); + + // Only Question & Checklist nodes that have an fn & edges are eligible for auto-answering + if (!type || !SUPPORTED_DECISION_TYPES.includes(type) || !data?.fn || !edges || data?.neverAutoAnswer) return; + + // Only proceed if the user has seen at least one node with this fn before + const visitedFns = Object.entries(breadcrumbs).filter(([nodeId, _breadcrumb]) => flow[nodeId].data?.fn === data.fn); + if (!visitedFns) return; + + // Get all options (aka edges or Answer nodes) for this node + const options: Array = edges.map((edgeId) => ({ + id: edgeId, + ...flow[edgeId], + })); + const sortedOptions = options + .sort( + (a, b) => + // Sort by the most to least number of dot-separated items in data.val (most granular to least) + String(b.data?.val).split(".").length - + String(a.data?.val).split(".").length, + ) + // Only keep options with a data value set (remove blanks) + .filter((option) => option.data?.val); + const blankOption = options.find((option) => !option.data?.val); + let optionsThatCanBeAutoAnswered: Array = []; + + // Get existing passport value(s) for this node's fn + 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), + // then proceed through the matching option(s) or the blank option independent if other vals have been seen before + if (Array.isArray(passportValues) && passportValues.length > 0) { + // Check if the existing passport value(s) startsWith at least one option's val (eg passport retains most granular values only) + const matchingPassportValues = passportValues.filter((passportValue: any) => + sortedOptions.some((option) => + passportValue?.startsWith(option.data?.val), + ), + ); + + if (matchingPassportValues.length > 0) { + let foundExactMatch = false; + sortedOptions.forEach((option) => { + passportValues.forEach((passportValue: any) => { + // An option can be auto-answered if it has direct match in the passport + // or if the passport has a more granular version of the option (eg option is `fruit`, passport has `fruit.apple`) + // but only in cases where we don't also have the exact match + if (passportValue === option.data?.val) { + if (option.id) optionsThatCanBeAutoAnswered.push(option.id); + foundExactMatch = true; + } else if (passportValue.startsWith(option.data?.val) && !foundExactMatch) { + if (option.id) optionsThatCanBeAutoAnswered.push(option.id); + } + }); + }); + } else { + if (blankOption?.id) optionsThatCanBeAutoAnswered.push(blankOption.id); + } + } else { + // If we don't have any existing passport values for this fn but we do have a blank option, + // proceed through the blank if every option's val has been visited before + const sortedOptionVals: string[] = sortedOptions.map((option) => option.data?.val); + let visitedOptionVals: string[] = []; + visitedFns.forEach(([nodeId, _breadcrumb]) => { + flow[nodeId].edges?.map((edgeId) => { + if (flow[edgeId].type === TYPES.Answer && flow[edgeId].data?.val) { + visitedOptionVals.push(flow[edgeId].data.val); + } + }) + }); + + // Planning Constraints use a bespoke "_nots" data structure to describe all option vals returned via GIS API + // Concat these onto other visitedOptionVals so that questions about constraints we haven't fetched are put to user exactly once + if (visitedFns.some(([nodeId, _breadcrumb]) => flow[nodeId].type === TYPES.PlanningConstraints)) { + const nots: string[] | undefined = passportData?.["_nots"]?.[data.fn]; + if (nots) visitedOptionVals = visitedOptionVals.concat(nots); + } + + const hasVisitedEveryOption = sortedOptionVals.every(value => visitedOptionVals.includes(value)); + if (blankOption?.id && hasVisitedEveryOption) optionsThatCanBeAutoAnswered.push(blankOption.id); + } + + // Questions 'select one' and therefore can only auto-answer the single left-most matching option + if (type === TYPES.Question) { + optionsThatCanBeAutoAnswered = optionsThatCanBeAutoAnswered.slice(0, 1); + } + + return optionsThatCanBeAutoAnswered.length > 0 ? optionsThatCanBeAutoAnswered : undefined; + }, + + /** + * Filters auto-answer based on a hierarchy of collected flags + * @param filterId - id of the Filter node + * @returns - id of the Answer node of the highest order matching flag + */ + autoAnswerableFlag: (filterId: NodeId) => { + const { breadcrumbs, flow } = get(); + const { type, data, edges } = flow[filterId]; + + // Only Filter nodes that have an fn & edges are eligible for auto-answering + if (!type || type !== TYPES.Filter || !data?.fn || !edges) return; + + // Get all options (aka flags or edges or Answer nodes) for this node + const options: Array = edges.map((edgeId) => ({ + id: edgeId, + ...flow[edgeId], + })); + const optionsThatCanBeAutoAnswered: Array = []; + + // "New" Filters will have a category prop, but existing ones may still be relying on DEFAULT category + const filterCategory = data?.category || DEFAULT_FLAG_CATEGORY; + const possibleFlags = flatFlags.filter( + (flag) => flag.category === filterCategory, + ); + const possibleFlagValues = possibleFlags.map((flag) => flag.value); + + // Get all flags collected so far based on selected answers, excluding flags not in this category + const collectedFlags: Flag[] = []; + Object.entries(breadcrumbs).forEach(([_nodeId, breadcrumb]) => { + if (breadcrumb.answers) { + breadcrumb.answers.forEach((answerId) => { + const node = flow[answerId]; + if (node.data?.flag && possibleFlagValues.includes(node.data.flag)) + collectedFlags.push(node.data?.flag); + }); + } + }); + + // Starting from the left of the Filter options, check for matches + options.forEach((option) => { + collectedFlags.forEach((flag) => { + if (option.data?.val === flag && option.id) { + optionsThatCanBeAutoAnswered.push(option.id); + } + }); + }); + + // If we didn't match a flag, travel through "No result" (aka blank) option + if (optionsThatCanBeAutoAnswered.length === 0) { + const noResultFlag = options.find((option) => !option.data?.val); + if (noResultFlag?.id) optionsThatCanBeAutoAnswered.push(noResultFlag.id); + } + + // Filters 'select one' and therefore can only auto-answer the single left-most matching flag option + return optionsThatCanBeAutoAnswered.slice(0, 1).toString(); + }, + isFinalCard: () => { // Temporarily always returns false until upcomingCardIds is optimised // OSL Slack explanation: https://bit.ly/3x38IRY @@ -669,36 +664,6 @@ export const previewStore: StateCreator< getCurrentCard: () => get().currentCard, }); -const knownNots = ( - flow: Store.Flow, - breadcrumbs: Store.Breadcrumbs, - nots = {}, -) => - Object.entries(breadcrumbs).reduce( - (acc, [id, { answers = [] }]) => { - if (!flow[id]) return acc; - - const _knownNotVals = difference( - flow[id].edges, - answers as Array, - ); - - if (flow[id].data?.fn) { - acc[flow[id].data.fn] = uniq( - flatten([ - ...(acc[flow[id].data?.fn] || []), - _knownNotVals.flatMap((n) => flow[n].data?.val), - ]), - ).filter(Boolean) as Array; - } - - return acc; - }, - { - ...nots, - } as Record>, - ); - interface RemoveOrphansFromBreadcrumbsProps { id: string; flow: Store.Flow; @@ -836,9 +801,9 @@ export const sortBreadcrumbs = ( return editingNodes?.length ? nextBreadcrumbs : sortIdsDepthFirst(flow)(new Set(Object.keys(nextBreadcrumbs))).reduce( - (acc, id) => ({ ...acc, [id]: nextBreadcrumbs[id] }), - {} as Store.Breadcrumbs, - ); + (acc, id) => ({ ...acc, [id]: nextBreadcrumbs[id] }), + {} as Store.Breadcrumbs, + ); }; function handleNodesWithPassport({ diff --git a/editor.planx.uk/src/pages/GlobalSettings.tsx b/editor.planx.uk/src/pages/GlobalSettings.tsx index 38da050091..0b331509df 100644 --- a/editor.planx.uk/src/pages/GlobalSettings.tsx +++ b/editor.planx.uk/src/pages/GlobalSettings.tsx @@ -9,11 +9,11 @@ import React from "react"; import type { TextContent } from "types"; import InputGroup from "ui/editor/InputGroup"; import InputLegend from "ui/editor/InputLegend"; -import ListManager from "ui/editor/ListManager"; -import RichTextInput from "ui/editor/RichTextInput"; +import ListManager from "ui/editor/ListManager/ListManager"; +import RichTextInput from "ui/editor/RichTextInput/RichTextInput"; import SettingsDescription from "ui/editor/SettingsDescription"; import SettingsSection from "ui/editor/SettingsSection"; -import Input from "ui/shared/Input"; +import Input from "ui/shared/Input/Input"; import InputRow from "ui/shared/InputRow"; import InputRowItem from "ui/shared/InputRowItem"; import { slugify } from "utils"; diff --git a/editor.planx.uk/src/pages/Login.tsx b/editor.planx.uk/src/pages/Login.tsx index fe7cf774d4..a8c6118554 100644 --- a/editor.planx.uk/src/pages/Login.tsx +++ b/editor.planx.uk/src/pages/Login.tsx @@ -5,7 +5,7 @@ import Button from "@mui/material/Button"; import Container from "@mui/material/Container"; import { styled } from "@mui/material/styles"; import Typography from "@mui/material/Typography"; -import DelayedLoadingIndicator from "components/DelayedLoadingIndicator"; +import DelayedLoadingIndicator from "components/DelayedLoadingIndicator/DelayedLoadingIndicator"; import React from "react"; import { useLoadingRoute } from "react-navi"; diff --git a/editor.planx.uk/src/pages/Pay/MakePayment.tsx b/editor.planx.uk/src/pages/Pay/MakePayment.tsx index 409734dbcf..f6b686dab3 100644 --- a/editor.planx.uk/src/pages/Pay/MakePayment.tsx +++ b/editor.planx.uk/src/pages/Pay/MakePayment.tsx @@ -23,7 +23,7 @@ import { } from "../../@planx/components/Pay/model"; import Confirm from "../../@planx/components/Pay/Public/Confirm"; import { logger } from "../../airbrake"; -import DelayedLoadingIndicator from "../../components/DelayedLoadingIndicator"; +import DelayedLoadingIndicator from "../../components/DelayedLoadingIndicator/DelayedLoadingIndicator"; const States = { Init: { diff --git a/editor.planx.uk/src/pages/Preview/ContentPage.tsx b/editor.planx.uk/src/pages/Preview/ContentPage.tsx index 4b40e6dd11..6275f392b2 100644 --- a/editor.planx.uk/src/pages/Preview/ContentPage.tsx +++ b/editor.planx.uk/src/pages/Preview/ContentPage.tsx @@ -9,7 +9,7 @@ import { useStore } from "pages/FlowEditor/lib/store"; import React from "react"; import { useNavigation } from "react-navi"; import { FOOTER_ITEMS } from "types"; -import ReactMarkdownOrHtml from "ui/shared/ReactMarkdownOrHtml"; +import ReactMarkdownOrHtml from "ui/shared/ReactMarkdownOrHtml/ReactMarkdownOrHtml"; const Root = styled(Box)(({ theme }) => ({ width: "100%", diff --git a/editor.planx.uk/src/pages/Preview/Node.tsx b/editor.planx.uk/src/pages/Preview/Node.tsx index 8926eca044..f3170568f1 100644 --- a/editor.planx.uk/src/pages/Preview/Node.tsx +++ b/editor.planx.uk/src/pages/Preview/Node.tsx @@ -19,6 +19,8 @@ import type { FileUpload } from "@planx/components/FileUpload/model"; import FileUploadComponent from "@planx/components/FileUpload/Public"; import type { FileUploadAndLabel } from "@planx/components/FileUploadAndLabel/model"; import FileUploadAndLabelComponent from "@planx/components/FileUploadAndLabel/Public"; +import type { Props as Filter } from "@planx/components/Filter/Editor"; +import FilterComponent from "@planx/components/Filter/Public"; import type { FindProperty } from "@planx/components/FindProperty/model"; import FindPropertyComponent from "@planx/components/FindProperty/Public"; import type { List } from "@planx/components/List/model"; @@ -239,8 +241,11 @@ const Node: React.FC = (props) => { /> ); - case TYPES.ExternalPortal: case TYPES.Filter: + return ()} />; + + // These types are never seen by users, nor do they leave their own breadcrumbs entry + case TYPES.ExternalPortal: case TYPES.Flow: case TYPES.InternalPortal: case TYPES.Answer: diff --git a/editor.planx.uk/src/pages/Preview/ResumePage.tsx b/editor.planx.uk/src/pages/Preview/ResumePage.tsx index 1ff744e660..999cff7315 100644 --- a/editor.planx.uk/src/pages/Preview/ResumePage.tsx +++ b/editor.planx.uk/src/pages/Preview/ResumePage.tsx @@ -5,7 +5,7 @@ import { PaymentRequest } from "@opensystemslab/planx-core/types"; import Card from "@planx/components/shared/Preview/Card"; import { CardHeader } from "@planx/components/shared/Preview/CardHeader/CardHeader"; import axios from "axios"; -import DelayedLoadingIndicator from "components/DelayedLoadingIndicator"; +import DelayedLoadingIndicator from "components/DelayedLoadingIndicator/DelayedLoadingIndicator"; import { useFormik } from "formik"; import { useStore } from "pages/FlowEditor/lib/store"; import React, { useEffect, useState } from "react"; @@ -13,7 +13,7 @@ import { Link as ReactNaviLink, useCurrentRoute } from "react-navi"; import type { ReconciliationResponse, Session } from "types"; import { ApplicationPath, SendEmailPayload } from "types"; import InputLabel from "ui/public/InputLabel"; -import Input from "ui/shared/Input"; +import Input from "ui/shared/Input/Input"; import InputRow from "ui/shared/InputRow"; import { object, string } from "yup"; diff --git a/editor.planx.uk/src/pages/Preview/SaveAndReturn.tsx b/editor.planx.uk/src/pages/Preview/SaveAndReturn.tsx index 0716b76a7f..06b2a3c9c1 100644 --- a/editor.planx.uk/src/pages/Preview/SaveAndReturn.tsx +++ b/editor.planx.uk/src/pages/Preview/SaveAndReturn.tsx @@ -6,7 +6,7 @@ import { useStore } from "pages/FlowEditor/lib/store"; import React from "react"; import { useCurrentRoute } from "react-navi"; import InputLabel from "ui/public/InputLabel"; -import Input from "ui/shared/Input"; +import Input from "ui/shared/Input/Input"; import InputRow from "ui/shared/InputRow"; import { object, ref, string } from "yup"; diff --git a/editor.planx.uk/src/pages/Preview/SavePage.tsx b/editor.planx.uk/src/pages/Preview/SavePage.tsx index e508e61d07..39eecd2a73 100644 --- a/editor.planx.uk/src/pages/Preview/SavePage.tsx +++ b/editor.planx.uk/src/pages/Preview/SavePage.tsx @@ -1,6 +1,6 @@ import Typography from "@mui/material/Typography"; import axios from "axios"; -import DelayedLoadingIndicator from "components/DelayedLoadingIndicator"; +import DelayedLoadingIndicator from "components/DelayedLoadingIndicator/DelayedLoadingIndicator"; import { add } from "date-fns"; import { useStore } from "pages/FlowEditor/lib/store"; import React, { useEffect, useState } from "react"; diff --git a/editor.planx.uk/src/pages/layout/AuthenticatedLayout.tsx b/editor.planx.uk/src/pages/layout/AuthenticatedLayout.tsx index 2fdd596a7d..36494e2fd0 100644 --- a/editor.planx.uk/src/pages/layout/AuthenticatedLayout.tsx +++ b/editor.planx.uk/src/pages/layout/AuthenticatedLayout.tsx @@ -7,7 +7,7 @@ import React, { PropsWithChildren } from "react"; import { DndProvider } from "react-dnd"; import { HTML5Backend } from "react-dnd-html5-backend"; -import Header from "../../components/Header"; +import Header from "../../components/Header/Header"; const DashboardWrap = styled(Box)(({ theme }) => ({ backgroundColor: theme.palette.background.default, diff --git a/editor.planx.uk/src/pages/layout/LoadingLayout.tsx b/editor.planx.uk/src/pages/layout/LoadingLayout.tsx index 3198b87999..2c994fa9a0 100644 --- a/editor.planx.uk/src/pages/layout/LoadingLayout.tsx +++ b/editor.planx.uk/src/pages/layout/LoadingLayout.tsx @@ -1,4 +1,4 @@ -import DelayedLoadingIndicator from "components/DelayedLoadingIndicator"; +import DelayedLoadingIndicator from "components/DelayedLoadingIndicator/DelayedLoadingIndicator"; import React from "react"; import { useLoadingRoute, View } from "react-navi"; diff --git a/editor.planx.uk/src/pages/layout/PublicLayout.tsx b/editor.planx.uk/src/pages/layout/PublicLayout.tsx index 72860758dc..b15b2597cd 100644 --- a/editor.planx.uk/src/pages/layout/PublicLayout.tsx +++ b/editor.planx.uk/src/pages/layout/PublicLayout.tsx @@ -15,8 +15,8 @@ import { useCurrentRoute } from "react-navi"; import { generateTeamTheme } from "theme"; import Logo from "ui/images/OGLLogo.svg"; -import Footer from "../../components/Footer"; -import Header from "../../components/Header"; +import Footer from "../../components/Footer/Footer"; +import Header from "../../components/Header/Header"; import { FOOTER_ITEMS } from "../../types"; const MainContainer = styled(Box)(({ theme }) => ({ diff --git a/editor.planx.uk/src/routes/feedback.tsx b/editor.planx.uk/src/routes/feedback.tsx index 1cd9dd382f..36d4453738 100644 --- a/editor.planx.uk/src/routes/feedback.tsx +++ b/editor.planx.uk/src/routes/feedback.tsx @@ -1,6 +1,6 @@ import { ComponentType } from "@opensystemslab/planx-core/types"; -import { FeedbackCategory } from "components/Feedback"; -import { Sentiment } from "components/Feedback/MoreInfoFeedback"; +import { Sentiment } from "components/Feedback/MoreInfoFeedback/MoreInfoFeedback"; +import { FeedbackCategory } from "components/Feedback/types"; import gql from "graphql-tag"; import { compose, mount, NotFoundError, route, withData } from "navi"; import { FeedbackPage } from "pages/FlowEditor/components/Flow/FeedbackPage"; diff --git a/editor.planx.uk/src/ui/editor/ColorPicker.stories.tsx b/editor.planx.uk/src/ui/editor/ColorPicker/ColorPicker.stories.tsx similarity index 100% rename from editor.planx.uk/src/ui/editor/ColorPicker.stories.tsx rename to editor.planx.uk/src/ui/editor/ColorPicker/ColorPicker.stories.tsx diff --git a/editor.planx.uk/src/ui/editor/ColorPicker.tsx b/editor.planx.uk/src/ui/editor/ColorPicker/ColorPicker.tsx similarity index 100% rename from editor.planx.uk/src/ui/editor/ColorPicker.tsx rename to editor.planx.uk/src/ui/editor/ColorPicker/ColorPicker.tsx diff --git a/editor.planx.uk/src/ui/editor/ImgInput.stories.tsx b/editor.planx.uk/src/ui/editor/ImgInput/ImgInput.stories.tsx similarity index 100% rename from editor.planx.uk/src/ui/editor/ImgInput.stories.tsx rename to editor.planx.uk/src/ui/editor/ImgInput/ImgInput.stories.tsx diff --git a/editor.planx.uk/src/ui/editor/ImgInput.tsx b/editor.planx.uk/src/ui/editor/ImgInput/ImgInput.tsx similarity index 98% rename from editor.planx.uk/src/ui/editor/ImgInput.tsx rename to editor.planx.uk/src/ui/editor/ImgInput/ImgInput.tsx index 90d3216d9c..98cdeb7efd 100644 --- a/editor.planx.uk/src/ui/editor/ImgInput.tsx +++ b/editor.planx.uk/src/ui/editor/ImgInput/ImgInput.tsx @@ -10,7 +10,7 @@ import React, { useMemo, useState } from "react"; import PublicFileUploadButton, { AcceptedFileTypes, -} from "../shared/PublicFileUploadButton"; +} from "../../shared/PublicFileUploadButton"; const ImageUploadContainer = styled(Box)(() => ({ height: 50, diff --git a/editor.planx.uk/src/ui/editor/InputField.stories.tsx b/editor.planx.uk/src/ui/editor/InputField/InputField.stories.tsx similarity index 88% rename from editor.planx.uk/src/ui/editor/InputField.stories.tsx rename to editor.planx.uk/src/ui/editor/InputField/InputField.stories.tsx index 065a489cd7..d412698a36 100644 --- a/editor.planx.uk/src/ui/editor/InputField.stories.tsx +++ b/editor.planx.uk/src/ui/editor/InputField/InputField.stories.tsx @@ -1,5 +1,5 @@ import { Meta, StoryObj } from "@storybook/react"; -import InputField from "ui/editor/InputField"; +import InputField from "ui/editor/InputField/InputField"; const meta = { title: "Design System/Atoms/Form Elements/InputField", diff --git a/editor.planx.uk/src/ui/editor/InputField.tsx b/editor.planx.uk/src/ui/editor/InputField/InputField.tsx similarity index 100% rename from editor.planx.uk/src/ui/editor/InputField.tsx rename to editor.planx.uk/src/ui/editor/InputField/InputField.tsx diff --git a/editor.planx.uk/src/ui/editor/ListManager.stories.tsx b/editor.planx.uk/src/ui/editor/ListManager/ListManager.stories.tsx similarity index 92% rename from editor.planx.uk/src/ui/editor/ListManager.stories.tsx rename to editor.planx.uk/src/ui/editor/ListManager/ListManager.stories.tsx index 207473ba68..3c25d5dd8d 100644 --- a/editor.planx.uk/src/ui/editor/ListManager.stories.tsx +++ b/editor.planx.uk/src/ui/editor/ListManager/ListManager.stories.tsx @@ -2,8 +2,8 @@ import Box from "@mui/material/Box"; import { Meta } from "@storybook/react"; import React, { ChangeEvent, useState } from "react"; -import Input from "../shared/Input"; -import ColorPicker from "./ColorPicker"; +import Input from "../../shared/Input/Input"; +import ColorPicker from "../ColorPicker/ColorPicker"; import ListManager from "./ListManager"; const metadata: Meta = { diff --git a/editor.planx.uk/src/ui/editor/ListManager.tsx b/editor.planx.uk/src/ui/editor/ListManager/ListManager.tsx similarity index 99% rename from editor.planx.uk/src/ui/editor/ListManager.tsx rename to editor.planx.uk/src/ui/editor/ListManager/ListManager.tsx index f688b2a907..9fb64dc58a 100644 --- a/editor.planx.uk/src/ui/editor/ListManager.tsx +++ b/editor.planx.uk/src/ui/editor/ListManager/ListManager.tsx @@ -16,7 +16,7 @@ import { DropResult, } from "react-beautiful-dnd"; -import { removeAt, setAt } from "../../utils"; +import { removeAt, setAt } from "../../../utils"; export interface EditorProps { index: number; diff --git a/editor.planx.uk/src/ui/editor/RichTextInput.stories.tsx b/editor.planx.uk/src/ui/editor/RichTextInput/RichTextInput.stories.tsx similarity index 100% rename from editor.planx.uk/src/ui/editor/RichTextInput.stories.tsx rename to editor.planx.uk/src/ui/editor/RichTextInput/RichTextInput.stories.tsx diff --git a/editor.planx.uk/src/ui/editor/RichTextInput.test.tsx b/editor.planx.uk/src/ui/editor/RichTextInput/RichTextInput.test.tsx similarity index 96% rename from editor.planx.uk/src/ui/editor/RichTextInput.test.tsx rename to editor.planx.uk/src/ui/editor/RichTextInput/RichTextInput.test.tsx index 7b2b40568a..00bf1bcd67 100644 --- a/editor.planx.uk/src/ui/editor/RichTextInput.test.tsx +++ b/editor.planx.uk/src/ui/editor/RichTextInput/RichTextInput.test.tsx @@ -1,7 +1,7 @@ import { screen } from "@testing-library/react"; import React from "react"; import { setup } from "testUtils"; -import RichTextInput from "ui/editor/RichTextInput"; +import RichTextInput from "ui/editor/RichTextInput/RichTextInput"; import { modifyDeep } from "./RichTextInput"; diff --git a/editor.planx.uk/src/ui/editor/RichTextInput.tsx b/editor.planx.uk/src/ui/editor/RichTextInput/RichTextInput.tsx similarity index 99% rename from editor.planx.uk/src/ui/editor/RichTextInput.tsx rename to editor.planx.uk/src/ui/editor/RichTextInput/RichTextInput.tsx index 5b8d4312f6..5b679ea204 100644 --- a/editor.planx.uk/src/ui/editor/RichTextInput.tsx +++ b/editor.planx.uk/src/ui/editor/RichTextInput/RichTextInput.tsx @@ -51,9 +51,9 @@ import { inputFocusStyle } from "theme"; import tippy, { type Instance } from "tippy.js"; import { create } from "zustand"; -import Input from "../shared/Input"; -import PublicFileUploadButton from "../shared/PublicFileUploadButton"; -import CustomImage from "./RichTextImage"; +import Input from "../../shared/Input/Input"; +import PublicFileUploadButton from "../../shared/PublicFileUploadButton"; +import CustomImage from "../RichTextImage"; interface Props extends InputBaseProps { className?: string; diff --git a/editor.planx.uk/src/ui/editor/SelectInput.stories.tsx b/editor.planx.uk/src/ui/editor/SelectInput/SelectInput.stories.tsx similarity index 90% rename from editor.planx.uk/src/ui/editor/SelectInput.stories.tsx rename to editor.planx.uk/src/ui/editor/SelectInput/SelectInput.stories.tsx index 4812adc317..aee7bc04a2 100644 --- a/editor.planx.uk/src/ui/editor/SelectInput.stories.tsx +++ b/editor.planx.uk/src/ui/editor/SelectInput/SelectInput.stories.tsx @@ -1,7 +1,7 @@ import MenuItem from "@mui/material/MenuItem"; import { Meta, StoryObj } from "@storybook/react"; import React from "react"; -import SelectInput from "ui/editor/SelectInput"; +import SelectInput from "ui/editor/SelectInput/SelectInput"; const meta = { title: "Design System/Atoms/Form Elements/SelectInput", diff --git a/editor.planx.uk/src/ui/editor/SelectInput.tsx b/editor.planx.uk/src/ui/editor/SelectInput/SelectInput.tsx similarity index 98% rename from editor.planx.uk/src/ui/editor/SelectInput.tsx rename to editor.planx.uk/src/ui/editor/SelectInput/SelectInput.tsx index 926e01b58b..634f476154 100644 --- a/editor.planx.uk/src/ui/editor/SelectInput.tsx +++ b/editor.planx.uk/src/ui/editor/SelectInput/SelectInput.tsx @@ -3,7 +3,7 @@ import Select, { selectClasses, SelectProps } from "@mui/material/Select"; import { styled } from "@mui/material/styles"; import React, { ReactNode } from "react"; -import Input from "../shared/Input"; +import Input from "../../shared/Input/Input"; export interface Props extends SelectProps { name?: string; diff --git a/editor.planx.uk/src/ui/images/feedback_filled-01.svg b/editor.planx.uk/src/ui/images/feedback_filled-01.svg new file mode 100644 index 0000000000..bd3307e0ac --- /dev/null +++ b/editor.planx.uk/src/ui/images/feedback_filled-01.svg @@ -0,0 +1,9 @@ + + + + + + + + + \ No newline at end of file diff --git a/editor.planx.uk/src/ui/images/feedback_filled-02.svg b/editor.planx.uk/src/ui/images/feedback_filled-02.svg new file mode 100644 index 0000000000..95c5609458 --- /dev/null +++ b/editor.planx.uk/src/ui/images/feedback_filled-02.svg @@ -0,0 +1,9 @@ + + + + + + + + + \ No newline at end of file diff --git a/editor.planx.uk/src/ui/images/feedback_filled-03.svg b/editor.planx.uk/src/ui/images/feedback_filled-03.svg new file mode 100644 index 0000000000..7912058369 --- /dev/null +++ b/editor.planx.uk/src/ui/images/feedback_filled-03.svg @@ -0,0 +1,9 @@ + + + + + + + + + \ No newline at end of file diff --git a/editor.planx.uk/src/ui/images/feedback_filled-04.svg b/editor.planx.uk/src/ui/images/feedback_filled-04.svg new file mode 100644 index 0000000000..7062cc9287 --- /dev/null +++ b/editor.planx.uk/src/ui/images/feedback_filled-04.svg @@ -0,0 +1,9 @@ + + + + + + + + + \ No newline at end of file diff --git a/editor.planx.uk/src/ui/images/feedback_filled-05.svg b/editor.planx.uk/src/ui/images/feedback_filled-05.svg new file mode 100644 index 0000000000..db58d2375d --- /dev/null +++ b/editor.planx.uk/src/ui/images/feedback_filled-05.svg @@ -0,0 +1,9 @@ + + + + + + + + + \ No newline at end of file diff --git a/editor.planx.uk/src/ui/images/feedback_stroke-01.svg b/editor.planx.uk/src/ui/images/feedback_stroke-01.svg new file mode 100644 index 0000000000..0a9c8261e6 --- /dev/null +++ b/editor.planx.uk/src/ui/images/feedback_stroke-01.svg @@ -0,0 +1,9 @@ + + + + + + + + + \ No newline at end of file diff --git a/editor.planx.uk/src/ui/images/feedback_stroke-02.svg b/editor.planx.uk/src/ui/images/feedback_stroke-02.svg new file mode 100644 index 0000000000..2d0f2e3441 --- /dev/null +++ b/editor.planx.uk/src/ui/images/feedback_stroke-02.svg @@ -0,0 +1,9 @@ + + + + + + + + + \ No newline at end of file diff --git a/editor.planx.uk/src/ui/images/feedback_stroke-03.svg b/editor.planx.uk/src/ui/images/feedback_stroke-03.svg new file mode 100644 index 0000000000..a6714c098f --- /dev/null +++ b/editor.planx.uk/src/ui/images/feedback_stroke-03.svg @@ -0,0 +1,9 @@ + + + + + + + + + \ No newline at end of file diff --git a/editor.planx.uk/src/ui/images/feedback_stroke-04.svg b/editor.planx.uk/src/ui/images/feedback_stroke-04.svg new file mode 100644 index 0000000000..0c23e56845 --- /dev/null +++ b/editor.planx.uk/src/ui/images/feedback_stroke-04.svg @@ -0,0 +1,9 @@ + + + + + + + + + \ No newline at end of file diff --git a/editor.planx.uk/src/ui/images/feedback_stroke-05.svg b/editor.planx.uk/src/ui/images/feedback_stroke-05.svg new file mode 100644 index 0000000000..6a0063a8e8 --- /dev/null +++ b/editor.planx.uk/src/ui/images/feedback_stroke-05.svg @@ -0,0 +1,9 @@ + + + + + + + + + \ No newline at end of file diff --git a/editor.planx.uk/src/ui/public/InputLabel.tsx b/editor.planx.uk/src/ui/public/InputLabel.tsx index 816006a974..5932b5325a 100644 --- a/editor.planx.uk/src/ui/public/InputLabel.tsx +++ b/editor.planx.uk/src/ui/public/InputLabel.tsx @@ -10,7 +10,7 @@ const Root = styled("label")(() => ({ export default function InputLabel(props: { label: string; - children: ReactNode; + children?: ReactNode; hidden?: boolean; htmlFor?: string; id?: string; diff --git a/editor.planx.uk/src/ui/public/NumberedList.tsx b/editor.planx.uk/src/ui/public/NumberedList.tsx index 5e10d40ef5..a318d7d58c 100644 --- a/editor.planx.uk/src/ui/public/NumberedList.tsx +++ b/editor.planx.uk/src/ui/public/NumberedList.tsx @@ -6,7 +6,7 @@ import Typography from "@mui/material/Typography"; import React from "react"; import { FONT_WEIGHT_SEMI_BOLD } from "theme"; import Caret from "ui/icons/Caret"; -import ReactMarkdownOrHtml from "ui/shared/ReactMarkdownOrHtml"; +import ReactMarkdownOrHtml from "ui/shared/ReactMarkdownOrHtml/ReactMarkdownOrHtml"; const STEP_DIAMETER = "45px"; const STEP_SPACER = "60px"; diff --git a/editor.planx.uk/src/ui/shared/Checkbox.stories.tsx b/editor.planx.uk/src/ui/shared/Checkbox/Checkbox.stories.tsx similarity index 100% rename from editor.planx.uk/src/ui/shared/Checkbox.stories.tsx rename to editor.planx.uk/src/ui/shared/Checkbox/Checkbox.stories.tsx diff --git a/editor.planx.uk/src/ui/shared/Checkbox.tsx b/editor.planx.uk/src/ui/shared/Checkbox/Checkbox.tsx similarity index 100% rename from editor.planx.uk/src/ui/shared/Checkbox.tsx rename to editor.planx.uk/src/ui/shared/Checkbox/Checkbox.tsx diff --git a/editor.planx.uk/src/ui/shared/ChecklistItem.stories.tsx b/editor.planx.uk/src/ui/shared/ChecklistItem/ChecklistItem.stories.tsx similarity index 100% rename from editor.planx.uk/src/ui/shared/ChecklistItem.stories.tsx rename to editor.planx.uk/src/ui/shared/ChecklistItem/ChecklistItem.stories.tsx diff --git a/editor.planx.uk/src/ui/shared/ChecklistItem.tsx b/editor.planx.uk/src/ui/shared/ChecklistItem/ChecklistItem.tsx similarity index 96% rename from editor.planx.uk/src/ui/shared/ChecklistItem.tsx rename to editor.planx.uk/src/ui/shared/ChecklistItem/ChecklistItem.tsx index 606c19bee5..e40bcd78bb 100644 --- a/editor.planx.uk/src/ui/shared/ChecklistItem.tsx +++ b/editor.planx.uk/src/ui/shared/ChecklistItem/ChecklistItem.tsx @@ -3,7 +3,7 @@ import { styled } from "@mui/material/styles"; import Typography from "@mui/material/Typography"; import * as React from "react"; -import Checkbox from "./Checkbox"; +import Checkbox from "../Checkbox/Checkbox"; const Root = styled(Box)(({ theme }) => ({ width: "100%", diff --git a/editor.planx.uk/src/ui/shared/DateInput.stories.tsx b/editor.planx.uk/src/ui/shared/DateInput/DateInput.stories.tsx similarity index 100% rename from editor.planx.uk/src/ui/shared/DateInput.stories.tsx rename to editor.planx.uk/src/ui/shared/DateInput/DateInput.stories.tsx diff --git a/editor.planx.uk/src/ui/shared/DateInput.tsx b/editor.planx.uk/src/ui/shared/DateInput/DateInput.tsx similarity index 97% rename from editor.planx.uk/src/ui/shared/DateInput.tsx rename to editor.planx.uk/src/ui/shared/DateInput/DateInput.tsx index caa063ea63..f16fc9105a 100644 --- a/editor.planx.uk/src/ui/shared/DateInput.tsx +++ b/editor.planx.uk/src/ui/shared/DateInput/DateInput.tsx @@ -3,8 +3,8 @@ import { styled } from "@mui/material/styles"; import Typography from "@mui/material/Typography"; import React, { ChangeEvent, FocusEvent } from "react"; -import ErrorWrapper from "./ErrorWrapper"; -import Input from "./Input"; +import ErrorWrapper from "../ErrorWrapper"; +import Input from "../Input/Input"; export interface Props { label?: string; diff --git a/editor.planx.uk/src/ui/shared/ErrorSummary.stories.tsx b/editor.planx.uk/src/ui/shared/ErrorSummary/ErrorSummary.stories.tsx similarity index 93% rename from editor.planx.uk/src/ui/shared/ErrorSummary.stories.tsx rename to editor.planx.uk/src/ui/shared/ErrorSummary/ErrorSummary.stories.tsx index d6c05924a3..6c33fef189 100644 --- a/editor.planx.uk/src/ui/shared/ErrorSummary.stories.tsx +++ b/editor.planx.uk/src/ui/shared/ErrorSummary/ErrorSummary.stories.tsx @@ -1,6 +1,6 @@ import { Meta, StoryObj } from "@storybook/react"; -import ErrorSummary from "../shared/ErrorSummary"; +import ErrorSummary from "./ErrorSummary"; const meta = { title: "Design System/Molecules/ErrorSummary", diff --git a/editor.planx.uk/src/ui/shared/ErrorSummary.tsx b/editor.planx.uk/src/ui/shared/ErrorSummary/ErrorSummary.tsx similarity index 100% rename from editor.planx.uk/src/ui/shared/ErrorSummary.tsx rename to editor.planx.uk/src/ui/shared/ErrorSummary/ErrorSummary.tsx diff --git a/editor.planx.uk/src/ui/shared/Input.stories.tsx b/editor.planx.uk/src/ui/shared/Input/Input.stories.tsx similarity index 100% rename from editor.planx.uk/src/ui/shared/Input.stories.tsx rename to editor.planx.uk/src/ui/shared/Input/Input.stories.tsx diff --git a/editor.planx.uk/src/ui/shared/Input.tsx b/editor.planx.uk/src/ui/shared/Input/Input.tsx similarity index 98% rename from editor.planx.uk/src/ui/shared/Input.tsx rename to editor.planx.uk/src/ui/shared/Input/Input.tsx index 45b57e3a3e..73bdcec41a 100644 --- a/editor.planx.uk/src/ui/shared/Input.tsx +++ b/editor.planx.uk/src/ui/shared/Input/Input.tsx @@ -15,7 +15,7 @@ import { inputFocusStyle, } from "theme"; -import ErrorWrapper from "./ErrorWrapper"; +import ErrorWrapper from "../ErrorWrapper"; const PREFIX = "Input"; @@ -115,7 +115,7 @@ export default forwardRef((props: Props, ref): FCReturn => { container.current?.querySelector("input")?.select(); }, }), - [] + [], ); return ( diff --git a/editor.planx.uk/src/ui/shared/ReactMarkdownOrHtml.test.tsx b/editor.planx.uk/src/ui/shared/ReactMarkdownOrHtml/ReactMarkdownOrHtml.test.tsx similarity index 100% rename from editor.planx.uk/src/ui/shared/ReactMarkdownOrHtml.test.tsx rename to editor.planx.uk/src/ui/shared/ReactMarkdownOrHtml/ReactMarkdownOrHtml.test.tsx diff --git a/editor.planx.uk/src/ui/shared/ReactMarkdownOrHtml.tsx b/editor.planx.uk/src/ui/shared/ReactMarkdownOrHtml/ReactMarkdownOrHtml.tsx similarity index 100% rename from editor.planx.uk/src/ui/shared/ReactMarkdownOrHtml.tsx rename to editor.planx.uk/src/ui/shared/ReactMarkdownOrHtml/ReactMarkdownOrHtml.tsx diff --git a/hasura.planx.uk/metadata/tables.yaml b/hasura.planx.uk/metadata/tables.yaml index 78a98cbf2f..b24e87efbb 100644 --- a/hasura.planx.uk/metadata/tables.yaml +++ b/hasura.planx.uk/metadata/tables.yaml @@ -157,7 +157,7 @@ definition: enable_manual: false insert: - columns: '*' + columns: "*" retry_conf: interval_sec: 30 num_retries: 1 @@ -171,7 +171,7 @@ query_params: type: bops-submission template_engine: Kriti - url: '{{$base_url}}/webhooks/hasura/send-slack-notification' + url: "{{$base_url}}/webhooks/hasura/send-slack-notification" version: 2 - table: name: document_template @@ -225,7 +225,7 @@ definition: enable_manual: false insert: - columns: '*' + columns: "*" retry_conf: interval_sec: 30 num_retries: 1 @@ -239,7 +239,7 @@ query_params: type: email-submission template_engine: Kriti - url: '{{$base_url}}/webhooks/hasura/send-slack-notification' + url: "{{$base_url}}/webhooks/hasura/send-slack-notification" version: 2 - table: name: feedback @@ -301,6 +301,36 @@ name: teams schema: public select_permissions: + - role: demoUser + permission: + columns: + - feedback_id + - device + - node_data + - address + - feedback_type + - help_definition + - help_sources + - help_text + - intersecting_constraints + - node_id + - node_text + - node_title + - node_type + - project_type + - service_slug + - status + - team_slug + - uprn + - user_comment + - user_context + - created_at + filter: + team: + flows: + creator_id: + _eq: x-hasura-user-id + comment: "" - role: platformAdmin permission: columns: @@ -434,6 +464,8 @@ - role: api permission: check: {} + set: + creator_id: x-hasura-user-id columns: - copied_from - created_at @@ -451,13 +483,35 @@ forward_client_headers: false headers: - name: authorization - value: '{{HASURA_PLANX_API_KEY}}' + value: "{{HASURA_PLANX_API_KEY}}" timeout: 10 - url: '{{HASURA_PLANX_API_URL}}/webhooks/hasura/validate-input/jsonb/clean-html' + url: "{{HASURA_PLANX_API_URL}}/webhooks/hasura/validate-input/jsonb/clean-html" type: http + - role: demoUser + permission: + check: + team_id: + _eq: 32 + set: + creator_id: x-hasura-user-id + columns: + - copied_from + - created_at + - creator_id + - data + - id + - name + - settings + - slug + - team_id + - updated_at + - version + comment: They can only insert into Demo team [id = 32] - role: platformAdmin permission: check: {} + set: + creator_id: x-hasura-user-id columns: - copied_from - created_at @@ -475,9 +529,9 @@ forward_client_headers: false headers: - name: authorization - value: '{{HASURA_PLANX_API_KEY}}' + value: "{{HASURA_PLANX_API_KEY}}" timeout: 10 - url: '{{HASURA_PLANX_API_URL}}/webhooks/hasura/validate-input/jsonb/clean-html' + url: "{{HASURA_PLANX_API_URL}}/webhooks/hasura/validate-input/jsonb/clean-html" type: http - role: teamEditor permission: @@ -489,6 +543,8 @@ _eq: x-hasura-user-id - role: _eq: teamEditor + set: + creator_id: x-hasura-user-id columns: - copied_from - created_at @@ -506,9 +562,9 @@ forward_client_headers: false headers: - name: authorization - value: '{{HASURA_PLANX_API_KEY}}' + value: "{{HASURA_PLANX_API_KEY}}" timeout: 10 - url: '{{HASURA_PLANX_API_URL}}/webhooks/hasura/validate-input/jsonb/clean-html' + url: "{{HASURA_PLANX_API_URL}}/webhooks/hasura/validate-input/jsonb/clean-html" type: http select_permissions: - role: api @@ -530,6 +586,37 @@ - data_merged filter: {} allow_aggregations: true + - role: demoUser + permission: + columns: + - created_at + - creator_id + - data + - id + - name + - settings + - slug + - status + - team_id + - updated_at + - version + computed_fields: + - data_merged + filter: + _or: + - _and: + - creator_id: + _eq: x-hasura-user-id + - team: + id: + _eq: 32 + - team: + id: + _in: + - 1 + - 29 + - 30 + comment: "For the demo user, we want to ensure they can only see their own flows, and flows from the Open Digital Planning [id = 30], Open Systems Lab [id = 1], and Templates [id = 29] team " - role: platformAdmin permission: columns: @@ -567,6 +654,7 @@ - data_merged filter: {} allow_aggregations: true + comment: "" - role: teamEditor permission: columns: @@ -609,10 +697,32 @@ forward_client_headers: false headers: - name: authorization - value: '{{HASURA_PLANX_API_KEY}}' + value: "{{HASURA_PLANX_API_KEY}}" timeout: 10 - url: '{{HASURA_PLANX_API_URL}}/webhooks/hasura/validate-input/jsonb/clean-html' + url: "{{HASURA_PLANX_API_URL}}/webhooks/hasura/validate-input/jsonb/clean-html" type: http + - role: demoUser + permission: + columns: + - copied_from + - created_at + - creator_id + - data + - id + - name + - settings + - slug + - team_id + - updated_at + - version + filter: + _and: + - team_id: + _eq: 32 + - creator_id: + _eq: x-hasura-user-id + check: null + comment: "" - role: platformAdmin permission: columns: @@ -629,9 +739,9 @@ forward_client_headers: false headers: - name: authorization - value: '{{HASURA_PLANX_API_KEY}}' + value: "{{HASURA_PLANX_API_KEY}}" timeout: 10 - url: '{{HASURA_PLANX_API_URL}}/webhooks/hasura/validate-input/jsonb/clean-html' + url: "{{HASURA_PLANX_API_URL}}/webhooks/hasura/validate-input/jsonb/clean-html" type: http - role: teamEditor permission: @@ -656,11 +766,20 @@ forward_client_headers: false headers: - name: authorization - value: '{{HASURA_PLANX_API_KEY}}' + value: "{{HASURA_PLANX_API_KEY}}" timeout: 10 - url: '{{HASURA_PLANX_API_URL}}/webhooks/hasura/validate-input/jsonb/clean-html' + url: "{{HASURA_PLANX_API_URL}}/webhooks/hasura/validate-input/jsonb/clean-html" type: http delete_permissions: + - role: demoUser + permission: + filter: + _and: + - team_id: + _eq: 32 + - creator_id: + _eq: x-hasura-user-id + comment: "" - role: platformAdmin permission: filter: {} @@ -689,11 +808,16 @@ forward_client_headers: false headers: - name: authorization - value: '{{HASURA_PLANX_API_KEY}}' + value: "{{HASURA_PLANX_API_KEY}}" timeout: 10 - url: '{{HASURA_PLANX_API_URL}}/webhooks/hasura/validate-input/jsonb/clean-html' + url: "{{HASURA_PLANX_API_URL}}/webhooks/hasura/validate-input/jsonb/clean-html" type: http select_permissions: + - role: demoUser + permission: + columns: + - footer_content + filter: {} - role: platformAdmin permission: columns: @@ -724,9 +848,9 @@ forward_client_headers: false headers: - name: authorization - value: '{{HASURA_PLANX_API_KEY}}' + value: "{{HASURA_PLANX_API_KEY}}" timeout: 10 - url: '{{HASURA_PLANX_API_URL}}/webhooks/hasura/validate-input/jsonb/clean-html' + url: "{{HASURA_PLANX_API_URL}}/webhooks/hasura/validate-input/jsonb/clean-html" type: http - table: name: lowcal_sessions @@ -873,7 +997,7 @@ method: POST query_params: {} template_engine: Kriti - url: '{{$base_url}}/send-email/confirmation' + url: "{{$base_url}}/send-email/confirmation" version: 2 - name: setup_lowcal_expiry_events definition: @@ -903,7 +1027,7 @@ method: POST query_params: {} template_engine: Kriti - url: '{{$base_url}}/webhooks/hasura/create-expiry-event' + url: "{{$base_url}}/webhooks/hasura/create-expiry-event" version: 2 - name: setup_lowcal_reminder_events definition: @@ -933,7 +1057,7 @@ method: POST query_params: {} template_engine: Kriti - url: '{{$base_url}}/webhooks/hasura/create-reminder-event' + url: "{{$base_url}}/webhooks/hasura/create-reminder-event" version: 2 - table: name: operations @@ -946,6 +1070,14 @@ using: foreign_key_constraint_on: flow_id insert_permissions: + - role: demoUser + permission: + check: + flow: + creator_id: + _eq: x-hausra-user-id + columns: [] + comment: "" - role: platformAdmin permission: check: {} @@ -977,6 +1109,18 @@ - updated_at - flow_id select_permissions: + - role: demoUser + permission: + columns: + - id + - actor_id + - version + - data + - created_at + - updated_at + - flow_id + filter: {} + comment: "" - role: platformAdmin permission: columns: @@ -1000,6 +1144,15 @@ - updated_at filter: {} update_permissions: + - role: demoUser + permission: + columns: [] + filter: + flow: + creator_id: + _eq: x-hausra-user-id + check: null + comment: "" - role: platformAdmin permission: columns: @@ -1110,7 +1263,7 @@ definition: enable_manual: false insert: - columns: '*' + columns: "*" retry_conf: interval_sec: 10 num_retries: 3 @@ -1132,13 +1285,13 @@ method: POST query_params: {} template_engine: Kriti - url: '{{$base_url}}/webhooks/hasura/create-payment-expiry-events' + url: "{{$base_url}}/webhooks/hasura/create-payment-expiry-events" version: 2 - name: setup_payment_invitation_events definition: enable_manual: false insert: - columns: '*' + columns: "*" retry_conf: interval_sec: 10 num_retries: 3 @@ -1160,13 +1313,13 @@ method: POST query_params: {} template_engine: Kriti - url: '{{$base_url}}/webhooks/hasura/create-payment-invitation-events' + url: "{{$base_url}}/webhooks/hasura/create-payment-invitation-events" version: 2 - name: setup_payment_reminder_events definition: enable_manual: false insert: - columns: '*' + columns: "*" retry_conf: interval_sec: 10 num_retries: 3 @@ -1188,7 +1341,7 @@ method: POST query_params: {} template_engine: Kriti - url: '{{$base_url}}/webhooks/hasura/create-payment-reminder-events' + url: "{{$base_url}}/webhooks/hasura/create-payment-reminder-events" version: 2 - name: setup_payment_send_events definition: @@ -1217,7 +1370,7 @@ method: POST query_params: {} template_engine: Kriti - url: '{{$base_url}}/webhooks/hasura/create-payment-send-events' + url: "{{$base_url}}/webhooks/hasura/create-payment-send-events" version: 2 - table: name: payment_status @@ -1304,6 +1457,25 @@ - created_at - flow_id - data + - role: demoUser + permission: + check: + _and: + - flow: + creator_id: + _eq: x-hasura-user-id + - flow: + team: + id: + _eq: 32 + columns: + - id + - publisher_id + - summary + - created_at + - flow_id + - data + comment: A demoUser can only insert a published flow for their own flows and for flows inside the Demo team [id = 32] - role: platformAdmin permission: check: {} @@ -1344,6 +1516,17 @@ - data filter: {} allow_aggregations: true + - role: demoUser + permission: + columns: + - created_at + - data + - flow_id + - id + - publisher_id + - summary + filter: {} + allow_aggregations: true - role: platformAdmin permission: columns: @@ -1449,12 +1632,12 @@ definition: enable_manual: false insert: - columns: '*' + columns: "*" retry_conf: interval_sec: 30 num_retries: 1 timeout_sec: 60 - webhook: '{{HASURA_PLANX_API_URL}}' + webhook: "{{HASURA_PLANX_API_URL}}" headers: - name: authorization value_from_env: HASURA_PLANX_API_KEY @@ -1463,7 +1646,7 @@ query_params: type: s3-submission template_engine: Kriti - url: '{{$base_url}}/webhooks/hasura/send-slack-notification' + url: "{{$base_url}}/webhooks/hasura/send-slack-notification" version: 2 - table: name: sessions @@ -1532,6 +1715,19 @@ name: submission_services_log schema: public select_permissions: + - role: demoUser + permission: + columns: + - retry + - response + - event_id + - event_type + - status + - created_at + - flow_id + - session_id + filter: {} + comment: "" - role: platformAdmin permission: columns: @@ -1562,6 +1758,38 @@ name: submission_services_summary schema: public select_permissions: + - role: demoUser + permission: + columns: + - number_times_resumed + - sent_to_bops + - sent_to_email + - sent_to_s3_power_automate + - sent_to_uniform + - user_clicked_save + - user_invited_to_pay + - session_length_days + - bops_applications + - email_applications + - payment_requests + - payment_status + - s3_applications + - uniform_applications + - allow_list_answers + - application_declaration_connection + - draw_boundary_action + - find_property_action + - property_constraints_planning + - property_type + - proposal_project_type + - user_role + - service_slug + - session_id + - team_slug + - created_at + - submitted_at + filter: {} + comment: "For future, if this moves outside of the Flow to somewhere like Team, we should update 'demoUser' to only see submission data related to only their flows. " - role: platformAdmin permission: columns: @@ -1672,6 +1900,14 @@ - staging_govpay_secret - power_automate_webhook_url filter: {} + - role: demoUser + permission: + columns: + - has_planning_data + - id + - team_id + filter: {} + comment: "" - role: platformAdmin permission: columns: @@ -1929,6 +2165,18 @@ - team_id filter: {} comment: "" + - role: demoUser + permission: + columns: + - action_colour + - favicon + - id + - link_colour + - logo + - primary_colour + - team_id + filter: {} + comment: "" - role: platformAdmin permission: columns: @@ -2057,6 +2305,23 @@ - slug - updated_at filter: {} + - role: demoUser + permission: + columns: + - created_at + - domain + - id + - name + - slug + - updated_at + filter: + id: + _in: + - 1 + - 29 + - 30 + - 32 + comment: "For the demo user, we want to ensure they can only see their own team [id = 32], and the teams: Open Digital Planning [id = 30], Open Systems Lab [id = 1], and Templates [id = 29] team " - role: platformAdmin permission: columns: @@ -2173,7 +2438,7 @@ definition: enable_manual: false insert: - columns: '*' + columns: "*" retry_conf: interval_sec: 30 num_retries: 1 @@ -2187,7 +2452,7 @@ query_params: type: uniform-submission template_engine: Kriti - url: '{{$base_url}}/webhooks/hasura/send-slack-notification' + url: "{{$base_url}}/webhooks/hasura/send-slack-notification" version: 2 - table: name: user_roles @@ -2243,6 +2508,17 @@ - last_name - updated_at filter: {} + - role: demoUser + permission: + columns: + - created_at + - email + - first_name + - id + - is_platform_admin + - last_name + - updated_at + filter: {} - role: platformAdmin permission: columns: diff --git a/hasura.planx.uk/migrations/1729675271585_insert_into_public_user_roles/down.sql b/hasura.planx.uk/migrations/1729675271585_insert_into_public_user_roles/down.sql new file mode 100644 index 0000000000..6ab981936e --- /dev/null +++ b/hasura.planx.uk/migrations/1729675271585_insert_into_public_user_roles/down.sql @@ -0,0 +1 @@ +DELETE FROM "public"."user_roles" WHERE "value" = 'demoUser'; diff --git a/hasura.planx.uk/migrations/1729675271585_insert_into_public_user_roles/up.sql b/hasura.planx.uk/migrations/1729675271585_insert_into_public_user_roles/up.sql new file mode 100644 index 0000000000..bacb81581c --- /dev/null +++ b/hasura.planx.uk/migrations/1729675271585_insert_into_public_user_roles/up.sql @@ -0,0 +1 @@ +INSERT INTO "public"."user_roles"("value") VALUES (E'demoUser'); diff --git a/hasura.planx.uk/tests/analytics.test.js b/hasura.planx.uk/tests/analytics.test.js index 63b69a6d26..12120fa4e8 100644 --- a/hasura.planx.uk/tests/analytics.test.js +++ b/hasura.planx.uk/tests/analytics.test.js @@ -76,6 +76,21 @@ describe("analytics and analytics_logs", () => { }); }); + describe("demoUser", () => { + let i; + beforeAll(async () => { + i = await introspectAs("demoUser"); + }); + + test("cannot query analytics_logs", () => { + expect(i.queries).not.toContain("analytics_logs"); + }); + + test("cannot create, update, or delete analytics_logs", () => { + expect(i).toHaveNoMutationsFor("analytics_logs"); + }); + }); + describe("api", () => { beforeAll(async () => { i = await introspectAs("api"); diff --git a/hasura.planx.uk/tests/blpu_codes.test.js b/hasura.planx.uk/tests/blpu_codes.test.js index 7614433e41..32cb4e0c2c 100644 --- a/hasura.planx.uk/tests/blpu_codes.test.js +++ b/hasura.planx.uk/tests/blpu_codes.test.js @@ -59,6 +59,21 @@ describe("blpu_codes", () => { }); }); + describe("demoUser", () => { + let i; + beforeAll(async () => { + i = await introspectAs("demoUser"); + }); + + test("can query blpu_codes", () => { + expect(i.queries).not.toContain("blpu_codes"); + }); + + test("cannot create, update, or delete blpu_codes", () => { + expect(i).toHaveNoMutationsFor("blpu_codes"); + }); + }); + describe("api", () => { let i; beforeAll(async () => { diff --git a/hasura.planx.uk/tests/bops_applications.test.js b/hasura.planx.uk/tests/bops_applications.test.js index 91001b1262..7ef50991a2 100644 --- a/hasura.planx.uk/tests/bops_applications.test.js +++ b/hasura.planx.uk/tests/bops_applications.test.js @@ -60,6 +60,21 @@ describe("bops_applications", () => { }); }); + describe("demoUser", () => { + let i; + beforeAll(async () => { + i = await introspectAs("demoUser"); + }); + + test("cannot query bops applications", () => { + expect(i.queries).not.toContain("bops_applications"); + }); + + test("cannot create, update, or delete bops applications", () => { + expect(i).toHaveNoMutationsFor("bops_applications"); + }); + }); + describe("api", () => { let i; beforeAll(async () => { diff --git a/hasura.planx.uk/tests/email_applications.test.js b/hasura.planx.uk/tests/email_applications.test.js index 11db01eed5..df0a92416d 100644 --- a/hasura.planx.uk/tests/email_applications.test.js +++ b/hasura.planx.uk/tests/email_applications.test.js @@ -60,6 +60,21 @@ describe("email_applications", () => { expect(i).toHaveNoMutationsFor("email_applications"); }); }); + + describe("demoUser", () => { + let i; + beforeAll(async () => { + i = await introspectAs("demoUser"); + }); + + test("cannot query email_applications", () => { + expect(i.queries).not.toContain("email_applications"); + }); + + test("cannot create, update, or delete email_applications", () => { + expect(i).toHaveNoMutationsFor("email_applications"); + }); + }); describe("api", () => { let i; diff --git a/hasura.planx.uk/tests/feedback.test.js b/hasura.planx.uk/tests/feedback.test.js index 07d5e4b4da..4d650b1e17 100644 --- a/hasura.planx.uk/tests/feedback.test.js +++ b/hasura.planx.uk/tests/feedback.test.js @@ -8,21 +8,21 @@ describe("feedback", () => { }); test("cannot query feedback", () => { - expect(i.queries).not.toContain("feedback"); - }); - - test("cannot update feedback", () => { - expect(i.mutations).not.toContain("update_feedback"); - expect(i.mutations).not.toContain("update_feedback_by_pk"); - }); - - test("cannot delete feedback", async () => { - expect(i.mutations).not.toContain("delete_feedback"); - }); - - test("can insert feedback", async () => { - expect(i.mutations).toContain("insert_feedback"); - }); + expect(i.queries).not.toContain("feedback"); + }); + + test("cannot update feedback", () => { + expect(i.mutations).not.toContain("update_feedback"); + expect(i.mutations).not.toContain("update_feedback_by_pk"); + }); + + test("cannot delete feedback", async () => { + expect(i.mutations).not.toContain("delete_feedback"); + }); + + test("can insert feedback", async () => { + expect(i.mutations).toContain("insert_feedback"); + }); }); describe("admin", () => { @@ -32,13 +32,13 @@ describe("feedback", () => { }); test("has full access to query and mutate feedback", () => { - expect(i.mutations).toContain("insert_feedback"); - expect(i.mutations).toContain("insert_feedback_one"); - expect(i.mutations).toContain("update_feedback"); - expect(i.mutations).toContain("update_feedback_by_pk"); - expect(i.mutations).toContain("update_feedback_many"); - expect(i.mutations).toContain("delete_feedback"); - expect(i.mutations).toContain("delete_feedback_by_pk"); + expect(i.mutations).toContain("insert_feedback"); + expect(i.mutations).toContain("insert_feedback_one"); + expect(i.mutations).toContain("update_feedback"); + expect(i.mutations).toContain("update_feedback_by_pk"); + expect(i.mutations).toContain("update_feedback_many"); + expect(i.mutations).toContain("delete_feedback"); + expect(i.mutations).toContain("delete_feedback_by_pk"); }); }); @@ -50,11 +50,10 @@ describe("feedback", () => { test("cannot query feedback", () => { expect(i.queries).not.toContain("feedback"); - }); test("cannot mutate feedback", async () => { - expect(i).toHaveNoMutationsFor("feedback") + expect(i).toHaveNoMutationsFor("feedback"); }); }); @@ -66,11 +65,25 @@ describe("feedback", () => { test("cannot query feedback", () => { expect(i.queries).not.toContain("feedback"); + }); + + test("cannot mutate feedback", async () => { + expect(i).toHaveNoMutationsFor("feedback"); + }); + }); + + describe("demoUser", () => { + let i; + beforeAll(async () => { + i = await introspectAs("demoUser"); + }); + test("cannot query feedback", () => { + expect(i.queries).not.toContain("feedback"); }); test("cannot mutate feedback", async () => { - expect(i).toHaveNoMutationsFor("feedback") + expect(i).toHaveNoMutationsFor("feedback"); }); }); @@ -92,7 +105,7 @@ describe("feedback", () => { test("can delete feedback", async () => { expect(i.mutations).toContain("delete_feedback"); }); - + test("cannot insert feedback", async () => { expect(i.mutations).not.toContain("insert_feedback"); }); diff --git a/hasura.planx.uk/tests/feedback_status.test.js b/hasura.planx.uk/tests/feedback_status.test.js index d3483584d2..85b66dce8c 100644 --- a/hasura.planx.uk/tests/feedback_status.test.js +++ b/hasura.planx.uk/tests/feedback_status.test.js @@ -1,92 +1,107 @@ const { introspectAs } = require("./utils"); describe("feedback_status_enum", () => { - describe("public", () => { - let i; - beforeAll(async () => { - i = await introspectAs("public"); - }); - - test("cannot INSERT records", () => { - expect(i.mutations).not.toContain("insert_feedback_status_enum"); - }); - - test("cannot QUERY records", () => { - expect(i.queries).not.toContain("feedback_status_enum"); - }); - - test("cannot DELETE records", () => { - expect(i.mutations).not.toContain("delete_feedback_status_enum"); - }); - - test("cannot UPDATE records", () => { - expect(i.mutations).not.toContain("update_feedback_status_enum"); - }); - }); - - describe("admin", () => { - let i; - beforeAll(async () => { - i = await introspectAs("admin"); - }); - - test("has full access to query and mutate feedback_status_enum", async () => { - expect(i.queries).toContain("feedback_status_enum"); - expect(i.mutations).toContain("insert_feedback_status_enum"); - expect(i.mutations).toContain("delete_feedback_status_enum"); - }); - }); - - describe("platformAdmin", () => { - let i; - beforeAll(async () => { - i = await introspectAs("platformAdmin"); - }); - - test("cannot query feedback_status_enum", () => { - expect(i.queries).not.toContain("feedback_status_enum"); - }); - - test("cannot create, update, or delete feedback_status_enum", () => { - expect(i).toHaveNoMutationsFor("feedback_status_enum"); - }); - }); - - describe("teamEditor", () => { - let i; - beforeAll(async () => { - i = await introspectAs("teamEditor"); - }); - - test("cannot query feedback_status_enum", () => { - expect(i.queries).not.toContain("feedback_status_enum"); - }); - - test("cannot create, update, or delete feedback_status_enum", () => { - expect(i).toHaveNoMutationsFor("feedback_status_enum"); - }); - }); - - describe("api", () => { - let i; - beforeAll(async () => { - i = await introspectAs("api"); - }); - - test("cannot INSERT records", () => { - expect(i.mutations).not.toContain("insert_feedback_status_enum"); - }); - - test("cannot QUERY records", () => { - expect(i.queries).not.toContain("feedback_status_enum"); - }); - - test("cannot DELETE records", () => { - expect(i.mutations).not.toContain("delete_feedback_status_enum"); - }); - - test("cannot UPDATE records", () => { - expect(i.mutations).not.toContain("update_feedback_status_enum"); - }); - }); - }); + describe("public", () => { + let i; + beforeAll(async () => { + i = await introspectAs("public"); + }); + + test("cannot INSERT records", () => { + expect(i.mutations).not.toContain("insert_feedback_status_enum"); + }); + + test("cannot QUERY records", () => { + expect(i.queries).not.toContain("feedback_status_enum"); + }); + + test("cannot DELETE records", () => { + expect(i.mutations).not.toContain("delete_feedback_status_enum"); + }); + + test("cannot UPDATE records", () => { + expect(i.mutations).not.toContain("update_feedback_status_enum"); + }); + }); + + describe("admin", () => { + let i; + beforeAll(async () => { + i = await introspectAs("admin"); + }); + + test("has full access to query and mutate feedback_status_enum", async () => { + expect(i.queries).toContain("feedback_status_enum"); + expect(i.mutations).toContain("insert_feedback_status_enum"); + expect(i.mutations).toContain("delete_feedback_status_enum"); + }); + }); + + describe("platformAdmin", () => { + let i; + beforeAll(async () => { + i = await introspectAs("platformAdmin"); + }); + + test("cannot query feedback_status_enum", () => { + expect(i.queries).not.toContain("feedback_status_enum"); + }); + + test("cannot create, update, or delete feedback_status_enum", () => { + expect(i).toHaveNoMutationsFor("feedback_status_enum"); + }); + }); + + describe("teamEditor", () => { + let i; + beforeAll(async () => { + i = await introspectAs("teamEditor"); + }); + + test("cannot query feedback_status_enum", () => { + expect(i.queries).not.toContain("feedback_status_enum"); + }); + + test("cannot create, update, or delete feedback_status_enum", () => { + expect(i).toHaveNoMutationsFor("feedback_status_enum"); + }); + }); + + describe("demoUser", () => { + let i; + beforeAll(async () => { + i = await introspectAs("demoUser"); + }); + + test("cannot query feedback_status_enum", () => { + expect(i.queries).not.toContain("feedback_status_enum"); + }); + + test("cannot create, update, or delete feedback_status_enum", () => { + expect(i).toHaveNoMutationsFor("feedback_status_enum"); + }); + }); + + describe("api", () => { + let i; + beforeAll(async () => { + i = await introspectAs("api"); + }); + + test("cannot INSERT records", () => { + expect(i.mutations).not.toContain("insert_feedback_status_enum"); + }); + + test("cannot QUERY records", () => { + expect(i.queries).not.toContain("feedback_status_enum"); + }); + + test("cannot DELETE records", () => { + expect(i.mutations).not.toContain("delete_feedback_status_enum"); + }); + + test("cannot UPDATE records", () => { + expect(i.mutations).not.toContain("update_feedback_status_enum"); + }); + }); +}); diff --git a/hasura.planx.uk/tests/feedback_type_enum.test.js b/hasura.planx.uk/tests/feedback_type_enum.test.js index b79d487bfe..4048dbec27 100644 --- a/hasura.planx.uk/tests/feedback_type_enum.test.js +++ b/hasura.planx.uk/tests/feedback_type_enum.test.js @@ -1,92 +1,107 @@ const { introspectAs } = require("./utils"); describe("feedback_type_enum", () => { - describe("public", () => { - let i; - beforeAll(async () => { - i = await introspectAs("public"); - }); - - test("cannot INSERT records", () => { - expect(i.mutations).not.toContain("insert_feedback_type_enum"); - }); - - test("cannot QUERY records", () => { - expect(i.queries).not.toContain("feedback_type_enum"); - }); - - test("cannot DELETE records", () => { - expect(i.mutations).not.toContain("delete_feedback_type_enum"); - }); - - test("cannot UPDATE records", () => { - expect(i.mutations).not.toContain("update_feedback_type_enum"); - }); - }); - - describe("admin", () => { - let i; - beforeAll(async () => { - i = await introspectAs("admin"); - }); - - test("has full access to query and mutate feedback_type_enum", async () => { - expect(i.queries).toContain("feedback_type_enum"); - expect(i.mutations).toContain("insert_feedback_type_enum"); - expect(i.mutations).toContain("delete_feedback_type_enum"); - }); - }); - - describe("platformAdmin", () => { - let i; - beforeAll(async () => { - i = await introspectAs("platformAdmin"); - }); - - test("cannot query feedback_type_enum", () => { - expect(i.queries).not.toContain("feedback_type_enum"); - }); - - test("cannot create, update, or delete feedback_type_enum", () => { - expect(i).toHaveNoMutationsFor("feedback_type_enum"); - }); - }); - - describe("teamEditor", () => { - let i; - beforeAll(async () => { - i = await introspectAs("teamEditor"); - }); - - test("cannot query feedback_type_enum", () => { - expect(i.queries).not.toContain("feedback_type_enum"); - }); - - test("cannot create, update, or delete feedback_type_enum", () => { - expect(i).toHaveNoMutationsFor("feedback_type_enum"); - }); - }); - - describe("api", () => { - let i; - beforeAll(async () => { - i = await introspectAs("api"); - }); - - test("cannot INSERT records", () => { - expect(i.mutations).not.toContain("insert_feedback_type_enum"); - }); - - test("cannot QUERY records", () => { - expect(i.queries).not.toContain("feedback_type_enum"); - }); - - test("cannot DELETE records", () => { - expect(i.mutations).not.toContain("delete_feedback_type_enum"); - }); - - test("cannot UPDATE records", () => { - expect(i.mutations).not.toContain("update_feedback_type_enum"); - }); - }); - }); + describe("public", () => { + let i; + beforeAll(async () => { + i = await introspectAs("public"); + }); + + test("cannot INSERT records", () => { + expect(i.mutations).not.toContain("insert_feedback_type_enum"); + }); + + test("cannot QUERY records", () => { + expect(i.queries).not.toContain("feedback_type_enum"); + }); + + test("cannot DELETE records", () => { + expect(i.mutations).not.toContain("delete_feedback_type_enum"); + }); + + test("cannot UPDATE records", () => { + expect(i.mutations).not.toContain("update_feedback_type_enum"); + }); + }); + + describe("admin", () => { + let i; + beforeAll(async () => { + i = await introspectAs("admin"); + }); + + test("has full access to query and mutate feedback_type_enum", async () => { + expect(i.queries).toContain("feedback_type_enum"); + expect(i.mutations).toContain("insert_feedback_type_enum"); + expect(i.mutations).toContain("delete_feedback_type_enum"); + }); + }); + + describe("platformAdmin", () => { + let i; + beforeAll(async () => { + i = await introspectAs("platformAdmin"); + }); + + test("cannot query feedback_type_enum", () => { + expect(i.queries).not.toContain("feedback_type_enum"); + }); + + test("cannot create, update, or delete feedback_type_enum", () => { + expect(i).toHaveNoMutationsFor("feedback_type_enum"); + }); + }); + + describe("teamEditor", () => { + let i; + beforeAll(async () => { + i = await introspectAs("teamEditor"); + }); + + test("cannot query feedback_type_enum", () => { + expect(i.queries).not.toContain("feedback_type_enum"); + }); + + test("cannot create, update, or delete feedback_type_enum", () => { + expect(i).toHaveNoMutationsFor("feedback_type_enum"); + }); + }); + + describe("demoUser", () => { + let i; + beforeAll(async () => { + i = await introspectAs("demoUser"); + }); + + test("cannot query feedback_type_enum", () => { + expect(i.queries).not.toContain("feedback_type_enum"); + }); + + test("cannot create, update, or delete feedback_type_enum", () => { + expect(i).toHaveNoMutationsFor("feedback_type_enum"); + }); + }); + + describe("api", () => { + let i; + beforeAll(async () => { + i = await introspectAs("api"); + }); + + test("cannot INSERT records", () => { + expect(i.mutations).not.toContain("insert_feedback_type_enum"); + }); + + test("cannot QUERY records", () => { + expect(i.queries).not.toContain("feedback_type_enum"); + }); + + test("cannot DELETE records", () => { + expect(i.mutations).not.toContain("delete_feedback_type_enum"); + }); + + test("cannot UPDATE records", () => { + expect(i.mutations).not.toContain("update_feedback_type_enum"); + }); + }); +}); diff --git a/hasura.planx.uk/tests/flow_document_templates.test.js b/hasura.planx.uk/tests/flow_document_templates.test.js index 75057b2f86..c77c0db4cf 100644 --- a/hasura.planx.uk/tests/flow_document_templates.test.js +++ b/hasura.planx.uk/tests/flow_document_templates.test.js @@ -64,6 +64,21 @@ describe("flow_document_templates", () => { expect(i).toHaveNoMutationsFor("flow_document_templates"); }); }); + + describe("demoUser", () => { + let i; + beforeAll(async () => { + i = await introspectAs("demoUser"); + }); + + test("cannot query flow_document_templates", () => { + expect(i.queries).not.toContain("flow_document_templates"); + }); + + test("cannot create, update, or delete flow_document_templates", () => { + expect(i).toHaveNoMutationsFor("flow_document_templates"); + }); + }); describe("api", () => { beforeAll(async () => { diff --git a/hasura.planx.uk/tests/flow_status_history.test.js b/hasura.planx.uk/tests/flow_status_history.test.js index cee2e236d4..b5893844b0 100644 --- a/hasura.planx.uk/tests/flow_status_history.test.js +++ b/hasura.planx.uk/tests/flow_status_history.test.js @@ -61,6 +61,21 @@ describe("flows status history", () => { }); }); + describe("demoUser", () => { + let i; + beforeAll(async () => { + i = await introspectAs("demoUser"); + }); + + test("cannot query flow_status_history", () => { + expect(i.queries).not.toContain("flow_status_history"); + }); + + test("cannot create, update, or delete flows or their associated operations", () => { + expect(i).toHaveNoMutationsFor("flow_status_history"); + }); + }); + describe("api", () => { let i; beforeAll(async () => { diff --git a/hasura.planx.uk/tests/flows.test.js b/hasura.planx.uk/tests/flows.test.js index 2338e7ea27..d857e0c04d 100644 --- a/hasura.planx.uk/tests/flows.test.js +++ b/hasura.planx.uk/tests/flows.test.js @@ -140,6 +140,58 @@ describe("flows and operations", () => { expect(i.mutations).not.toContain("update_published_flows"); }); }); + + describe("demoUser", () => { + let i; + beforeAll(async () => { + i = await introspectAs("demoUser"); + }); + + test("can query flows and their associated operations", () => { + expect(i.queries).toContain("flows"); + expect(i.queries).toContain("operations"); + }); + + test("can update flows and their associated operations", () => { + expect(i.mutations).toContain("update_flows_by_pk"); + expect(i.mutations).toContain("update_flows"); + expect(i.mutations).toContain("update_operations_by_pk"); + expect(i.mutations).toContain("update_operations"); + }); + + test("can create flows and their associated operations", () => { + expect(i.mutations).toContain("insert_flows_one"); + expect(i.mutations).toContain("insert_flows"); + expect(i.mutations).toContain("insert_operations_one"); + expect(i.mutations).toContain("insert_operations"); + }); + + test("can delete flows", () => { + expect(i.mutations).toContain("delete_flows_by_pk"); + expect(i.mutations).toContain("delete_flows"); + }); + + test("cannot delete operations", () => { + expect(i.mutations).not.toContain("delete_operations_by_pk"); + expect(i.mutations).not.toContain("delete_operations"); + }); + + test("can query published flows", () => { + expect(i.queries).toContain("published_flows"); + }); + + test("can create published_flows", () => { + expect(i.mutations).toContain("insert_published_flows_one"); + expect(i.mutations).toContain("insert_published_flows"); + }); + + test("cannot update or delete published_flows", () => { + expect(i.mutations).not.toContain("delete_published_flows_by_pk"); + expect(i.mutations).not.toContain("delete_published_flows"); + expect(i.mutations).not.toContain("update_published_flows_by_pk"); + expect(i.mutations).not.toContain("update_published_flows"); + }); + }); describe("api", () => { let i; diff --git a/hasura.planx.uk/tests/global_settings.test.js b/hasura.planx.uk/tests/global_settings.test.js index c7b3d9be95..a75a7a6629 100644 --- a/hasura.planx.uk/tests/global_settings.test.js +++ b/hasura.planx.uk/tests/global_settings.test.js @@ -64,6 +64,21 @@ describe("global_settings", () => { }); }); + describe("demoUser", () => { + let i; + beforeAll(async () => { + i = await introspectAs("demoUser"); + }); + + test("can query global_settings view", () => { + expect(i.queries).toContain("global_settings"); + }); + + test("cannot create, update, or delete global_settings", () => { + expect(i).toHaveNoMutationsFor("global_settings"); + }); + }); + describe("api", () => { let i; beforeAll(async () => { diff --git a/hasura.planx.uk/tests/lowcal_sessions.test.js b/hasura.planx.uk/tests/lowcal_sessions.test.js index aa0149ff88..8e6e2d6da7 100644 --- a/hasura.planx.uk/tests/lowcal_sessions.test.js +++ b/hasura.planx.uk/tests/lowcal_sessions.test.js @@ -1,8 +1,7 @@ const { introspectAs, gqlAdmin, gqlPublic } = require("./utils"); -const { v4: uuidV4 } = require('uuid'); +const { v4: uuidV4 } = require("uuid"); const assert = require("assert"); - describe("lowcal_sessions", () => { describe("public role introspection", () => { let i; @@ -23,15 +22,9 @@ describe("lowcal_sessions", () => { describe("public role queries and mutations", () => { let ids; const flowId = uuidV4(); - const [ - alice1, - alice2, - bob1, - bob2, - mallory1, - robert1, - anon1 - ] = [...Array(7)].map(() => uuidV4()); + const [alice1, alice2, bob1, bob2, mallory1, robert1, anon1] = [ + ...Array(7), + ].map(() => uuidV4()); const insertSession = ` mutation InsertLowcalSession( @@ -123,12 +116,12 @@ describe("lowcal_sessions", () => { } } } - ` + `; let res = await gqlAdmin(query); ids = res.data.insert_lowcal_sessions.returning.map((row) => row.id); assert.strictEqual(ids.length, 5); }); - + afterAll(async () => { const res = await gqlAdmin(` mutation { @@ -137,20 +130,23 @@ describe("lowcal_sessions", () => { } } `); - assert.strictEqual(res.data.delete_lowcal_sessions.affected_rows, ids.length); + assert.strictEqual( + res.data.delete_lowcal_sessions.affected_rows, + ids.length + ); }); describe("INSERT without permission", () => { test("Anonymous users can insert a session with an empty email", async () => { const headers = { "x-hasura-lowcal-session-id": anon1, - "x-hasura-lowcal-email": "" + "x-hasura-lowcal-email": "", }; const payload = { email: "", sessionId: anon1, - data: { x: 1 } - } + data: { x: 1 }, + }; const res = await gqlPublic(insertSession, payload, headers); ids.push(res.data.insert_lowcal_sessions_one.id); // add the email to ids for teardown expect(res).not.toHaveProperty("errors"); @@ -162,89 +158,133 @@ describe("lowcal_sessions", () => { test("Alice cannot insert a session with an email that doesn't match headers", async () => { const headers = { "x-hasura-lowcal-session-id": alice2, - "x-hasura-lowcal-email": "helloalice@opensystemslab.io" + "x-hasura-lowcal-email": "helloalice@opensystemslab.io", }; const payload = { email: "alice@opensystemslab.io", // not the same as in header sessionId: alice2, - data: { x: 1 } - } + data: { x: 1 }, + }; const res = await gqlPublic(insertSession, payload, headers); expect(res).toHaveProperty("errors"); - expect(res.errors[0].message).toContain('check constraint of an insert/update permission has failed'); + expect(res.errors[0].message).toContain( + "check constraint of an insert/update permission has failed" + ); }); - }) + }); describe("UPDATE without permission", () => { test("cannot update without 'x-hasura-lowcal-session-id' header", async () => { - const res = await gqlPublic(updateByPK, { sessionId: alice1, data: { x: 1 } }, { "x-hasura-lowcal-email": "alice@opensystemslab.io" }); + const res = await gqlPublic( + updateByPK, + { sessionId: alice1, data: { x: 1 } }, + { "x-hasura-lowcal-email": "alice@opensystemslab.io" } + ); expect(res).toHaveProperty("errors"); - expect(res.errors[0].message).toContain('missing session variable: "x-hasura-lowcal-session-id"'); + expect(res.errors[0].message).toContain( + 'missing session variable: "x-hasura-lowcal-session-id"' + ); }); - + test("cannot update without 'x-hasura-lowcal-email' header", async () => { - const res = await gqlPublic(updateByPK, { sessionId: alice1, data: { x: 1 } }, { "x-hasura-lowcal-session-id": uuidV4() }); + const res = await gqlPublic( + updateByPK, + { sessionId: alice1, data: { x: 1 } }, + { "x-hasura-lowcal-session-id": uuidV4() } + ); expect(res).toHaveProperty("errors"); - expect(res.errors[0].message).toContain('missing session variable: "x-hasura-lowcal-email"'); + expect(res.errors[0].message).toContain( + 'missing session variable: "x-hasura-lowcal-email"' + ); }); test("'x-hasura-lowcal-session-id' header must have value", async () => { - const res = await gqlPublic(updateByPK, { sessionId: alice1, data: { x: 1 } }, { "x-hasura-lowcal-session-id": null, "x-hasura-lowcal-email": "alice@opensystemslab.io"}); + const res = await gqlPublic( + updateByPK, + { sessionId: alice1, data: { x: 1 } }, + { + "x-hasura-lowcal-session-id": null, + "x-hasura-lowcal-email": "alice@opensystemslab.io", + } + ); expect(res).toHaveProperty("errors"); - expect(res.errors[0].message).toContain("invalid input syntax for type uuid: \"null\""); + expect(res.errors[0].message).toContain( + 'invalid input syntax for type uuid: "null"' + ); }); test("Alice cannot update her own session with invalid sessionId", async () => { const headers = { "x-hasura-lowcal-session-id": uuidV4(), - "x-hasura-lowcal-email": "alice@opensystemslab.io" + "x-hasura-lowcal-email": "alice@opensystemslab.io", }; - const res = await gqlPublic(updateByPK, { sessionId: alice1, data: { x: 1 } }, headers); + const res = await gqlPublic( + updateByPK, + { sessionId: alice1, data: { x: 1 } }, + headers + ); expect(res.data.update_lowcal_sessions_by_pk).toBeNull(); }); test("Alice cannot update her own session with invalid email", async () => { const headers = { "x-hasura-lowcal-session-id": alice1, - "x-hasura-lowcal-email": "not-alice@opensystemslab.io" + "x-hasura-lowcal-email": "not-alice@opensystemslab.io", }; - const res = await gqlPublic(updateByPK, { sessionId: alice1, data: { x: 1 } }, headers); + const res = await gqlPublic( + updateByPK, + { sessionId: alice1, data: { x: 1 } }, + headers + ); expect(res.data.update_lowcal_sessions_by_pk).toBeNull(); }); test("Alice cannot update her own session with a missing email", async () => { const headers = { "x-hasura-lowcal-session-id": alice1, - "x-hasura-lowcal-email": null + "x-hasura-lowcal-email": null, }; - const res = await gqlPublic(updateByPK, { sessionId: alice1, data: { x: 1 } }, headers); + const res = await gqlPublic( + updateByPK, + { sessionId: alice1, data: { x: 1 } }, + headers + ); expect(res.data.update_lowcal_sessions_by_pk).toBeNull(); }); test("Alice cannot update her own existing session with an empty email ", async () => { const headers = { "x-hasura-lowcal-session-id": alice1, - "x-hasura-lowcal-email": "" + "x-hasura-lowcal-email": "", }; - const res = await gqlPublic(updateByPK, { sessionId: alice1, data: { x: 1 } }, headers); + const res = await gqlPublic( + updateByPK, + { sessionId: alice1, data: { x: 1 } }, + headers + ); expect(res.data.update_lowcal_sessions_by_pk).toBeNull(); }); test("Mallory cannot update Alice's session", async () => { const headers = { "x-hasura-lowcal-session-id": uuidV4(), - "x-hasura-lowcal-email": "random@opensystemslab.io" + "x-hasura-lowcal-email": "random@opensystemslab.io", }; - const res = await gqlPublic(updateByPK, { sessionId: alice1, data: { x: 1 } }, headers); + const res = await gqlPublic( + updateByPK, + { sessionId: alice1, data: { x: 1 } }, + headers + ); expect(res.data.update_lowcal_sessions_by_pk).toBeNull(); }); test("Mallory cannot update multiple sessions which do not belong to them", async () => { const headers = { "x-hasura-lowcal-session-id": uuidV4(), - "x-hasura-lowcal-email": "random@opensystemslab.io" + "x-hasura-lowcal-email": "random@opensystemslab.io", }; - const res = await gqlPublic(` + const res = await gqlPublic( + ` mutation UpdateMultipleSessionsWithoutWhereClause { update_lowcal_sessions(where: {}, _set: { data: "{ x: 1 }" }) { returning { @@ -252,16 +292,20 @@ describe("lowcal_sessions", () => { } } } - `, null, headers); + `, + null, + headers + ); expect(res.data.update_lowcal_sessions.returning).toHaveLength(0); }); test("Bob cannot update multiple sessions which do belong to them", async () => { const headers = { "x-hasura-lowcal-session-id": bob1, - "x-hasura-lowcal-email": "bob@opensystemslab.io" + "x-hasura-lowcal-email": "bob@opensystemslab.io", }; - const res = await gqlPublic(` + const res = await gqlPublic( + ` mutation UpdateMultipleSessionsWithoutWhereClause { update_lowcal_sessions(where: {}, _set: { data: "{ x: 1 }" }) { returning { @@ -269,7 +313,10 @@ describe("lowcal_sessions", () => { } } } - `, null, headers); + `, + null, + headers + ); expect(res.data.update_lowcal_sessions.returning).toHaveLength(1); expect(res.data.update_lowcal_sessions.returning[0].id).toEqual(bob1); }); @@ -277,46 +324,75 @@ describe("lowcal_sessions", () => { test("Anonymous users can upsert their own session with an empty email", async () => { const headers = { "x-hasura-lowcal-session-id": anon1, - "x-hasura-lowcal-email": "" + "x-hasura-lowcal-email": "", }; // initial insert (upsert) - const res1 = await gqlPublic(updateByPK, { sessionId: anon1, data: { x: 1 } }, headers); + const res1 = await gqlPublic( + updateByPK, + { sessionId: anon1, data: { x: 1 } }, + headers + ); expect(res1.data.update_lowcal_sessions_by_pk).not.toBeNull(); expect(res1.data.update_lowcal_sessions_by_pk.id).toEqual(anon1); - expect(res1.data.update_lowcal_sessions_by_pk.data).toHaveProperty("x", 1); + expect(res1.data.update_lowcal_sessions_by_pk.data).toHaveProperty( + "x", + 1 + ); // update 1 - const res2 = await gqlPublic(updateByPK, { sessionId: anon1, data: { y: 2 } }, headers); + const res2 = await gqlPublic( + updateByPK, + { sessionId: anon1, data: { y: 2 } }, + headers + ); expect(res2.data.update_lowcal_sessions_by_pk).not.toBeNull(); expect(res2.data.update_lowcal_sessions_by_pk.id).toEqual(anon1); - expect(res2.data.update_lowcal_sessions_by_pk.data).toHaveProperty("y", 2); + expect(res2.data.update_lowcal_sessions_by_pk.data).toHaveProperty( + "y", + 2 + ); // update 2 - const res3 = await gqlPublic(updateByPK, { sessionId: anon1, data: {} }, headers); + const res3 = await gqlPublic( + updateByPK, + { sessionId: anon1, data: {} }, + headers + ); expect(res3.data.update_lowcal_sessions_by_pk).not.toBeNull(); expect(res3.data.update_lowcal_sessions_by_pk.data).toEqual({}); }); }); - + describe("UPDATE with permission", () => { test("Alice can update her session", async () => { const headers = { "x-hasura-lowcal-session-id": alice1, - "x-hasura-lowcal-email": "alice@opensystemslab.io" + "x-hasura-lowcal-email": "alice@opensystemslab.io", }; - const res = await gqlPublic(updateByPK, { sessionId: alice1, data: { x: 1 } }, headers); + const res = await gqlPublic( + updateByPK, + { sessionId: alice1, data: { x: 1 } }, + headers + ); expect(res.data.update_lowcal_sessions_by_pk).not.toBeNull(); expect(res.data.update_lowcal_sessions_by_pk.id).toEqual(alice1); - expect(res.data.update_lowcal_sessions_by_pk.data).toHaveProperty("x", 1); + expect(res.data.update_lowcal_sessions_by_pk.data).toHaveProperty( + "x", + 1 + ); }); test("Alice cannot update her session with an empty email", async () => { const headers = { "x-hasura-lowcal-session-id": alice1, - "x-hasura-lowcal-email": "" + "x-hasura-lowcal-email": "", }; - const res = await gqlPublic(updateByPK, { sessionId: alice1, data: { x: 1 } }, headers); + const res = await gqlPublic( + updateByPK, + { sessionId: alice1, data: { x: 1 } }, + headers + ); expect(res).not.toHaveProperty("errors"); expect(res.data.update_lowcal_sessions_by_pk).toBeNull(); }); @@ -324,9 +400,13 @@ describe("lowcal_sessions", () => { test("Robert cannot update his read-only session", async () => { const headers = { "x-hasura-lowcal-session-id": robert1, - "x-hasura-lowcal-email": "robert@opensystemslab.io" + "x-hasura-lowcal-email": "robert@opensystemslab.io", }; - const res = await gqlPublic(updateByPK, { sessionId: robert1, data: { x: 1 } }, headers); + const res = await gqlPublic( + updateByPK, + { sessionId: robert1, data: { x: 1 } }, + headers + ); expect(res).not.toHaveProperty("errors"); expect(res.data.update_lowcal_sessions_by_pk).toBeNull(); }); @@ -336,19 +416,27 @@ describe("lowcal_sessions", () => { test("cannot select without 'x-hasura-lowcal-session-id' header", async () => { const res = await gqlPublic(selectByPK, { sessionId: alice1 }); expect(res).toHaveProperty("errors"); - expect(res.errors[0].message).toContain('missing session variable: "x-hasura-lowcal-session-id"'); + expect(res.errors[0].message).toContain( + 'missing session variable: "x-hasura-lowcal-session-id"' + ); }); test("cannot select without 'x-hasura-lowcal-email' header", async () => { - const res = await gqlPublic(selectByPK, { sessionId: alice1 }, { "x-hasura-lowcal-session-id": uuidV4() }); + const res = await gqlPublic( + selectByPK, + { sessionId: alice1 }, + { "x-hasura-lowcal-session-id": uuidV4() } + ); expect(res).toHaveProperty("errors"); - expect(res.errors[0].message).toContain('missing session variable: "x-hasura-lowcal-email"'); + expect(res.errors[0].message).toContain( + 'missing session variable: "x-hasura-lowcal-email"' + ); }); test("Mallory cannot select Alice's session", async () => { const headers = { "x-hasura-lowcal-session-id": uuidV4(), - "x-hasura-lowcal-email": "random@opensystemslab.io" + "x-hasura-lowcal-email": "random@opensystemslab.io", }; const res = await gqlPublic(selectByPK, { sessionId: alice1 }, headers); expect(res.data.lowcal_sessions_by_pk).toBeNull(); @@ -357,43 +445,51 @@ describe("lowcal_sessions", () => { test("Mallory cannot select all sessions", async () => { const headers = { "x-hasura-lowcal-session-id": uuidV4(), - "x-hasura-lowcal-email": "random@opensystemslab.io" + "x-hasura-lowcal-email": "random@opensystemslab.io", }; - const res = await gqlPublic(` + const res = await gqlPublic( + ` query SelectAllLowcalSessions { lowcal_sessions { id } } - `, null, headers); + `, + null, + headers + ); expect(res.data.lowcal_sessions).toHaveLength(0); }); test("Bob cannot select multiple sessions which belong to him", async () => { const headers = { "x-hasura-lowcal-session-id": bob1, - "x-hasura-lowcal-email": "bob@opensystemslab.io" + "x-hasura-lowcal-email": "bob@opensystemslab.io", }; - const res = await gqlPublic(` + const res = await gqlPublic( + ` query SelectAllLowcalSessions { lowcal_sessions { id } } - `, null, headers); + `, + null, + headers + ); expect(res.data.lowcal_sessions).toHaveLength(1); - expect(res.data.lowcal_sessions[0].id).toEqual(bob1) + expect(res.data.lowcal_sessions[0].id).toEqual(bob1); }); - }); describe("SELECT with permission", () => { test("Alice can select her session", async () => { const headers = { "x-hasura-lowcal-session-id": alice1, - "x-hasura-lowcal-email": "alice@opensystemslab.io" + "x-hasura-lowcal-email": "alice@opensystemslab.io", }; - const res = await gqlPublic(` + const res = await gqlPublic( + ` query SelectAllLowcalSessions { lowcal_sessions { created_at @@ -403,17 +499,21 @@ describe("lowcal_sessions", () => { updated_at } } - `, null, headers); + `, + null, + headers + ); expect(res.data.lowcal_sessions).toHaveLength(1); - expect(res.data.lowcal_sessions[0].id).toEqual(alice1) + expect(res.data.lowcal_sessions[0].id).toEqual(alice1); }); test("Anonymous users cannot select their own session", async () => { const headers = { "x-hasura-lowcal-session-id": anon1, - "x-hasura-lowcal-email": "" + "x-hasura-lowcal-email": "", }; - const res = await gqlPublic(` + const res = await gqlPublic( + ` query SelectAllLowcalSessions { lowcal_sessions { created_at @@ -423,7 +523,10 @@ describe("lowcal_sessions", () => { updated_at } } - `, null, headers); + `, + null, + headers + ); expect(res.data.lowcal_sessions).toHaveLength(0); }); }); @@ -459,6 +562,21 @@ describe("lowcal_sessions", () => { }); }); + describe("demoUser", () => { + let i; + beforeAll(async () => { + i = await introspectAs("demoUser"); + }); + + test("cannot query lowcal_sessions", () => { + expect(i.queries).not.toContain("lowcal_sessions"); + }); + + test("cannot create, update, or delete lowcal_sessions", () => { + expect(i).toHaveNoMutationsFor("lowcal_sessions"); + }); + }); + describe("api", () => { let i; beforeAll(async () => { diff --git a/hasura.planx.uk/tests/payment_requests.test.js b/hasura.planx.uk/tests/payment_requests.test.js index 5580187ae3..14f26aac38 100644 --- a/hasura.planx.uk/tests/payment_requests.test.js +++ b/hasura.planx.uk/tests/payment_requests.test.js @@ -1,5 +1,5 @@ const { introspectAs, gqlAdmin, gqlPublic } = require("./utils"); -const { v4: uuidV4 } = require('uuid'); +const { v4: uuidV4 } = require("uuid"); const assert = require("assert"); describe("payment_requests", () => { @@ -22,8 +22,8 @@ describe("payment_requests", () => { }); describe("public query", () => { - const sessionIds = [uuidV4(), uuidV4()] - const paymentRequestIds = [uuidV4(), uuidV4()] + const sessionIds = [uuidV4(), uuidV4()]; + const paymentRequestIds = [uuidV4(), uuidV4()]; beforeAll(async () => { await insertSessions(sessionIds); @@ -32,23 +32,25 @@ describe("payment_requests", () => { afterAll(async () => { await deleteSessions(sessionIds); - }) + }); test("can QUERY records", () => { expect(i.queries).toContain("payment_requests"); expect(i.queries).toContain("payment_requests_by_pk"); }); - test("requires x-hasura-payment-request-id to query", async() => { + test("requires x-hasura-payment-request-id to query", async () => { const query = ` query GetAllPaymentRequests { payment_requests { id } } - ` + `; const publicRes = await gqlPublic(query); - expect(publicRes.errors[0].message).toMatch(/missing session variable: "x-hasura-payment-request-id"/) + expect(publicRes.errors[0].message).toMatch( + /missing session variable: "x-hasura-payment-request-id"/ + ); }); test("can only access records with a known id", async () => { @@ -58,7 +60,7 @@ describe("payment_requests", () => { id } } - ` + `; const headers = { "x-hasura-payment-request-id": paymentRequestIds[0], }; @@ -75,7 +77,7 @@ describe("payment_requests", () => { paid_at } } - ` + `; const publicRes = await gqlPublic(query); expect(publicRes).toHaveProperty("errors"); @@ -128,6 +130,21 @@ describe("payment_requests", () => { }); }); + describe("demoUser", () => { + let i; + beforeAll(async () => { + i = await introspectAs("demoUser"); + }); + + test("cannot query payment_requests", () => { + expect(i.queries).not.toContain("payment_requests"); + }); + + test("cannot create, update, or delete payment_requests", () => { + expect(i).toHaveNoMutationsFor("payment_requests"); + }); + }); + describe("api", () => { let i; beforeAll(async () => { @@ -205,7 +222,7 @@ const insertPaymentRequests = async (sessionIds, paymentRequestIds) => { } } } - ` + `; const res = await gqlAdmin(query); ids = res.data.insert_payment_requests.returning.map((row) => row.id); assert.strictEqual(ids.length, 2); @@ -214,10 +231,15 @@ const insertPaymentRequests = async (sessionIds, paymentRequestIds) => { const deleteSessions = async (sessionIds) => { const res = await gqlAdmin(` mutation { - delete_lowcal_sessions(where: {id: {_in: ${JSON.stringify(sessionIds)}}}) { + delete_lowcal_sessions(where: {id: {_in: ${JSON.stringify( + sessionIds + )}}}) { affected_rows } } `); - assert.strictEqual(res.data.delete_lowcal_sessions.affected_rows, sessionIds.length); + assert.strictEqual( + res.data.delete_lowcal_sessions.affected_rows, + sessionIds.length + ); }; diff --git a/hasura.planx.uk/tests/payment_status.test.js b/hasura.planx.uk/tests/payment_status.test.js index 6bcc81d3a0..57072df591 100644 --- a/hasura.planx.uk/tests/payment_status.test.js +++ b/hasura.planx.uk/tests/payment_status.test.js @@ -67,6 +67,21 @@ describe("payment_status", () => { }); }); + describe("demoUser", () => { + let i; + beforeAll(async () => { + i = await introspectAs("demoUser"); + }); + + test("cannot query payment_status", () => { + expect(i.queries).not.toContain("payment_status"); + }); + + test("cannot create, update, or delete payment_status", () => { + expect(i).toHaveNoMutationsFor("payment_status"); + }); + }); + describe("api", () => { let i; beforeAll(async () => { @@ -84,6 +99,6 @@ describe("payment_status", () => { test("cannot delete or update payment_status", () => { expect(i.mutations).not.toContain("update_payment_status"); expect(i.mutations).not.toContain("delete_payment_status"); - }) + }); }); }); diff --git a/hasura.planx.uk/tests/planning_constraints_requests.test.js b/hasura.planx.uk/tests/planning_constraints_requests.test.js index 8f0c2627ff..e9a68d0c1c 100644 --- a/hasura.planx.uk/tests/planning_constraints_requests.test.js +++ b/hasura.planx.uk/tests/planning_constraints_requests.test.js @@ -25,7 +25,9 @@ describe("planning_constraints_requests", () => { test("has full access to query and mutate planning constraints requests", () => { expect(i.queries).toContain("planning_constraints_requests"); expect(i.mutations).toContain("insert_planning_constraints_requests"); - expect(i.mutations).toContain("update_planning_constraints_requests_by_pk"); + expect(i.mutations).toContain( + "update_planning_constraints_requests_by_pk" + ); expect(i.mutations).toContain("delete_planning_constraints_requests"); }); }); @@ -60,6 +62,21 @@ describe("planning_constraints_requests", () => { }); }); + describe("demoUser", () => { + let i; + beforeAll(async () => { + i = await introspectAs("demoUser"); + }); + + test("cannot query planning_constraints_requests", () => { + expect(i.queries).not.toContain("planning_constraints_requests"); + }); + + test("cannot create, update, or delete planning_constraints_requests", () => { + expect(i).toHaveNoMutationsFor("planning_constraints_requests"); + }); + }); + describe("api", () => { let i; beforeAll(async () => { @@ -68,15 +85,17 @@ describe("planning_constraints_requests", () => { test("can query planning_constraints_requests", () => { expect(i.queries).toContain("planning_constraints_requests"); - }) + }); test("can insert planning_constraints_requests", () => { expect(i.mutations).toContain("insert_planning_constraints_requests"); }); test("cannot update or delete planning_constriants_requests", () => { - expect(i.mutations).not.toContain("update_planning_constraints_requests_by_pk"); + expect(i.mutations).not.toContain( + "update_planning_constraints_requests_by_pk" + ); expect(i.mutations).not.toContain("delete_planning_constraints_requests"); - }) + }); }); }); diff --git a/hasura.planx.uk/tests/reconciliation_requests.test.js b/hasura.planx.uk/tests/reconciliation_requests.test.js index 9e8f5812d6..91a43de011 100644 --- a/hasura.planx.uk/tests/reconciliation_requests.test.js +++ b/hasura.planx.uk/tests/reconciliation_requests.test.js @@ -64,6 +64,21 @@ describe("reconciliation_requests", () => { }); }); + describe("demoUser", () => { + let i; + beforeAll(async () => { + i = await introspectAs("demoUser"); + }); + + test("cannot query reconciliation_requests", () => { + expect(i.queries).not.toContain("reconciliation_requests"); + }); + + test("cannot create, update, or delete reconciliation_requests", () => { + expect(i).toHaveNoMutationsFor("reconciliation_requests"); + }); + }); + describe("api", () => { beforeAll(async () => { i = await introspectAs("api"); diff --git a/hasura.planx.uk/tests/sessions.test.js b/hasura.planx.uk/tests/sessions.test.js index 54c7a615a2..c77482f746 100644 --- a/hasura.planx.uk/tests/sessions.test.js +++ b/hasura.planx.uk/tests/sessions.test.js @@ -564,7 +564,7 @@ describe("sessions", () => { headers ); expect(res.data.sessions).toHaveLength(1); - const session = res.data.sessions[0] + const session = res.data.sessions[0]; expect(session.id).toEqual(alice1); expect(session).toHaveProperty(["created_at"]); expect(session).toHaveProperty(["breadcrumbs"]); @@ -629,6 +629,21 @@ describe("sessions", () => { }); }); + describe("demoUser", () => { + let i; + beforeAll(async () => { + i = await introspectAs("demoUser"); + }); + + test("cannot query sessions", () => { + expect(i.queries).not.toContain("sessions"); + }); + + test("cannot create, update, or delete sessions", () => { + expect(i).toHaveNoMutationsFor("sessions"); + }); + }); + describe("api", () => { let i; beforeAll(async () => { @@ -645,5 +660,4 @@ describe("sessions", () => { expect(i.mutations).not.toContain("delete_sessions"); }); }); - }); diff --git a/hasura.planx.uk/tests/team_integrations.test.js b/hasura.planx.uk/tests/team_integrations.test.js index aab8de9680..d141493096 100644 --- a/hasura.planx.uk/tests/team_integrations.test.js +++ b/hasura.planx.uk/tests/team_integrations.test.js @@ -61,6 +61,21 @@ describe("team_integrations", () => { }); }); + describe("demoUser", () => { + let i; + beforeAll(async () => { + i = await introspectAs("demoUser"); + }); + + test("can query team_integrations", () => { + expect(i.queries).toContain("team_integrations"); + }); + + test("cannot create, update, or delete team_integrations", () => { + expect(i).toHaveNoMutationsFor("team_integrations"); + }); + }); + describe("api", () => { let i; beforeAll(async () => { diff --git a/hasura.planx.uk/tests/team_members.test.js b/hasura.planx.uk/tests/team_members.test.js index 6ed7853b1e..f46b3609dd 100644 --- a/hasura.planx.uk/tests/team_members.test.js +++ b/hasura.planx.uk/tests/team_members.test.js @@ -48,7 +48,7 @@ describe("team_members", () => { beforeAll(async () => { i = await introspectAs("teamEditor"); }); - + // Row-level permissions tested in e2e/tests/api-driven // teamEditors can only query their own record test("can query teams", () => { @@ -60,6 +60,21 @@ describe("team_members", () => { }); }); + describe("demoUser", () => { + let i; + beforeAll(async () => { + i = await introspectAs("demoUser"); + }); + + test("cannot query teams", () => { + expect(i.queries).not.toContain("team_members"); + }); + + test("cannot create, update, or delete team_members", () => { + expect(i).toHaveNoMutationsFor("team_members"); + }); + }); + describe("api", () => { let i; beforeAll(async () => { diff --git a/hasura.planx.uk/tests/team_themes.test.js b/hasura.planx.uk/tests/team_themes.test.js index 6e22bff88a..386c6cba0c 100644 --- a/hasura.planx.uk/tests/team_themes.test.js +++ b/hasura.planx.uk/tests/team_themes.test.js @@ -82,6 +82,21 @@ describe("team_themes", () => { }); }); + describe("demoUser", () => { + let i; + beforeAll(async () => { + i = await introspectAs("demoUser"); + }); + + test("can query team_themes", () => { + expect(i.queries).toContain("team_themes"); + }); + + test("cannot create, update, or delete team_themes", () => { + expect(i).toHaveNoMutationsFor("team_themes"); + }); + }); + describe("api", () => { let i; beforeAll(async () => { diff --git a/hasura.planx.uk/tests/teams.test.js b/hasura.planx.uk/tests/teams.test.js index 85865cb2dc..d20267eb0d 100644 --- a/hasura.planx.uk/tests/teams.test.js +++ b/hasura.planx.uk/tests/teams.test.js @@ -72,6 +72,21 @@ describe("teams", () => { }); }); + describe("demoUser", () => { + let i; + beforeAll(async () => { + i = await introspectAs("demoUser"); + }); + + test("can query teams", () => { + expect(i.queries).toContain("teams"); + }); + + test("cannot create, update, or delete teams", () => { + expect(i).toHaveNoMutationsFor("teams"); + }); + }); + describe("api", () => { let i; beforeAll(async () => { diff --git a/hasura.planx.uk/tests/uniform_applications.test.js b/hasura.planx.uk/tests/uniform_applications.test.js index 92f851a377..ca8947bd2f 100644 --- a/hasura.planx.uk/tests/uniform_applications.test.js +++ b/hasura.planx.uk/tests/uniform_applications.test.js @@ -60,6 +60,21 @@ describe("uniform_applications", () => { }); }); + describe("demoUser", () => { + let i; + beforeAll(async () => { + i = await introspectAs("demoUser"); + }); + + test("cannot query uniform applications", () => { + expect(i.queries).not.toContain("uniform_applications"); + }); + + test("cannot create, update, or delete uniform applications", () => { + expect(i).toHaveNoMutationsFor("uniform_applications"); + }); + }); + describe("api", () => { let i; beforeAll(async () => { @@ -74,6 +89,6 @@ describe("uniform_applications", () => { test("cannot delete uniform applications", () => { expect(i.mutations).not.toContain("delete_uniform_applications"); - }) + }); }); }); diff --git a/hasura.planx.uk/tests/users.test.js b/hasura.planx.uk/tests/users.test.js index 80466806d2..15ac9f0b68 100644 --- a/hasura.planx.uk/tests/users.test.js +++ b/hasura.planx.uk/tests/users.test.js @@ -67,6 +67,21 @@ describe("users", () => { }); }); + describe("demoUser", () => { + let i; + beforeAll(async () => { + i = await introspectAs("demoUser"); + }); + + test("can query users", async () => { + expect(i.queries).toContain("users"); + }); + + test("cannot create, update, or delete users", async () => { + expect(i).toHaveNoMutationsFor("users"); + }); + }); + describe("api", () => { let i; beforeAll(async () => { diff --git a/hasura.planx.uk/tests/utils.js b/hasura.planx.uk/tests/utils.js index f0e5a4cdf0..c9b2adb302 100644 --- a/hasura.planx.uk/tests/utils.js +++ b/hasura.planx.uk/tests/utils.js @@ -2,13 +2,16 @@ const fetch = require("isomorphic-fetch"); const jsonwebtoken = require("jsonwebtoken"); async function gqlAdmin(query, variables = {}) { - const res = await fetch(`http://${process.env.HASURA_HOST}:${process.env.HASURA_PORT}/v1/graphql`, { - method: "POST", - headers: { - "X-Hasura-Admin-Secret": process.env.HASURA_ADMIN_SECRET, - }, - body: JSON.stringify({ query, variables }), - }); + const res = await fetch( + `http://${process.env.HASURA_HOST}:${process.env.HASURA_PORT}/v1/graphql`, + { + method: "POST", + headers: { + "X-Hasura-Admin-Secret": process.env.HASURA_ADMIN_SECRET, + }, + body: JSON.stringify({ query, variables }), + } + ); const json = await res.json(); if (json.errors && json.errors[0].message.includes("x-hasura-admin-secret")) { throw Error("Invalid HASURA_SECRET"); @@ -17,41 +20,47 @@ async function gqlAdmin(query, variables = {}) { } async function gqlPublic(query, variables = {}, headers = {}) { - const res = await fetch(`http://${process.env.HASURA_HOST}:${process.env.HASURA_PORT}/v1/graphql`, { - method: "POST", - headers: headers, - body: JSON.stringify({ query: query, variables }), - }); + const res = await fetch( + `http://${process.env.HASURA_HOST}:${process.env.HASURA_PORT}/v1/graphql`, + { + method: "POST", + headers: headers, + body: JSON.stringify({ query: query, variables }), + } + ); return await res.json(); } /** * Get a role-based connection to Hasura - * @param {string} role - * @param {number} userId + * @param {string} role + * @param {number} userId * @returns A GQL client which authenticates to Hasura with the given role and userId */ function gqlWithRole(role, userId) { - const jwt = buildJWTForRole(role, userId) + const jwt = buildJWTForRole(role, userId); const gql = async (query, variables = {}, headers = {}) => { - const res = await fetch(`http://${process.env.HASURA_HOST}:${process.env.HASURA_PORT}/v1/graphql`, { - method: "POST", - headers: { - ...headers, - Authorization: `Bearer ${jwt}`, - }, - body: JSON.stringify({ query: query, variables }), - }); + const res = await fetch( + `http://${process.env.HASURA_HOST}:${process.env.HASURA_PORT}/v1/graphql`, + { + method: "POST", + headers: { + ...headers, + Authorization: `Bearer ${jwt}`, + }, + body: JSON.stringify({ query: query, variables }), + } + ); return await res.json(); - } + }; return gql; } /** - * @param {string} role - * @param {number} userId + * @param {string} role + * @param {number} userId * @returns {string} */ function buildJWTForRole(role, userId = 1) { @@ -81,10 +90,11 @@ const introspectAs = async (role, userId = undefined) => { const gql = { admin: gqlAdmin, public: gqlPublic, + demoUser: gqlWithRole("demoUser", userId), platformAdmin: gqlWithRole("platformAdmin", userId), teamEditor: gqlWithRole("teamEditor", userId), api: gqlWithRole("api"), - }[role] + }[role]; const INTROSPECTION_QUERY = ` query IntrospectionQuery { __schema {