diff --git a/editor.planx.uk/src/pages/FlowEditor/ReadMePage/ReadMePage.stories.tsx b/editor.planx.uk/src/pages/FlowEditor/ReadMePage/ReadMePage.stories.tsx new file mode 100644 index 0000000000..969c2136d2 --- /dev/null +++ b/editor.planx.uk/src/pages/FlowEditor/ReadMePage/ReadMePage.stories.tsx @@ -0,0 +1,26 @@ +import { Meta, StoryObj } from "@storybook/react"; + +import { ReadMePage } from "./ReadMePage"; + +const meta = { + title: "Design System/Pages/ReadMe", + component: ReadMePage, +} satisfies Meta; + +export default meta; + +type Story = StoryObj; + +export const Basic = { + args: { + teamSlug: "barnet", + flowSlug: "Apply for prior permission", + flowInformation: { + status: "online", + description: "A long description of a service", + summary: "A short blurb", + limitations: "", + settings: {}, + }, + }, +} satisfies Story; diff --git a/editor.planx.uk/src/pages/FlowEditor/ReadMePage/ReadMePage.tsx b/editor.planx.uk/src/pages/FlowEditor/ReadMePage/ReadMePage.tsx index c39cf62958..ed505f6808 100644 --- a/editor.planx.uk/src/pages/FlowEditor/ReadMePage/ReadMePage.tsx +++ b/editor.planx.uk/src/pages/FlowEditor/ReadMePage/ReadMePage.tsx @@ -5,7 +5,8 @@ import Typography from "@mui/material/Typography"; import { TextInputType } from "@planx/components/TextInput/model"; import { useFormik } from "formik"; import { useToast } from "hooks/useToast"; -import React from "react"; +import capitalize from "lodash/capitalize"; +import React, { useState } from "react"; import FlowTag from "ui/editor/FlowTag/FlowTag"; import { FlowTagType, StatusVariant } from "ui/editor/FlowTag/types"; import InputGroup from "ui/editor/InputGroup"; @@ -16,25 +17,17 @@ import SettingsSection from "ui/editor/SettingsSection"; import { CharacterCounter } from "ui/shared/CharacterCounter"; import Input from "ui/shared/Input/Input"; import InputRow from "ui/shared/InputRow"; +import { Switch } from "ui/shared/Switch"; import { object, string } from "yup"; +import { ExternalPortals } from "../components/Sidebar/Search/ExternalPortalList/ExternalPortals"; import { useStore } from "../lib/store"; -import { FlowInformation } from "../utils"; - -interface ReadMePageProps { - flowInformation: FlowInformation; - teamSlug: string; -} - -interface ReadMePageForm { - serviceSummary: string; - serviceDescription: string; - serviceLimitations: string; -} +import { ReadMePageForm, ReadMePageProps } from "./types"; export const ReadMePage: React.FC = ({ flowInformation, teamSlug, + flowSlug, }) => { const { status: flowStatus } = flowInformation; const [ @@ -44,6 +37,7 @@ export const ReadMePage: React.FC = ({ updateFlowSummary, flowLimitations, updateFlowLimitations, + externalPortals, flowName, ] = useStore((state) => [ state.flowDescription, @@ -52,11 +46,16 @@ export const ReadMePage: React.FC = ({ state.updateFlowSummary, state.flowLimitations, state.updateFlowLimitations, + state.externalPortals, state.flowName, ]); const toast = useToast(); + const hasExternalPortals = Boolean(Object.keys(externalPortals).length); + + const [showExternalPortals, setShowExternalPortals] = useState(false); + const formik = useFormik({ initialValues: { serviceSummary: flowSummary || "", @@ -129,7 +128,8 @@ export const ReadMePage: React.FC = ({ - {flowName} + {/* fallback from request params if store not populated with flowName */} + {flowName || capitalize(flowSlug.replaceAll("-", " "))} @@ -161,6 +161,7 @@ export const ReadMePage: React.FC = ({ disabled={!useStore.getState().canUserEditTeam(teamSlug)} inputProps={{ "aria-describedby": "A short blurb on what this service is.", + "aria-label": "Service Description", }} /> = ({ + + setShowExternalPortals(!showExternalPortals)} + /> + {showExternalPortals && + (hasExternalPortals ? ( + + External Portals + + Your service contains the following external portals: + + + + ) : ( + This service has no external portals. + ))} + ); }; diff --git a/editor.planx.uk/src/pages/FlowEditor/ReadMePage/tests/ReadMePage.test.tsx b/editor.planx.uk/src/pages/FlowEditor/ReadMePage/tests/ReadMePage.test.tsx new file mode 100644 index 0000000000..e84a358c74 --- /dev/null +++ b/editor.planx.uk/src/pages/FlowEditor/ReadMePage/tests/ReadMePage.test.tsx @@ -0,0 +1,126 @@ +import { act, screen } from "@testing-library/react"; +import { FullStore, useStore } from "pages/FlowEditor/lib/store"; +import React from "react"; +import { DndProvider } from "react-dnd"; +import { HTML5Backend } from "react-dnd-html5-backend"; +import { setup } from "testUtils"; +import { axe } from "vitest-axe"; + +import { ReadMePage } from "../ReadMePage"; +import { defaultProps, longInput, platformAdminUser } from "./helpers"; + +const { getState, setState } = useStore; + +let initialState: FullStore; + +describe("Read Me Page component", () => { + beforeAll(() => (initialState = getState())); + + beforeEach(() => { + getState().setUser(platformAdminUser); + }); + + afterEach(() => { + act(() => setState(initialState)); + }); + + it("renders and submits data without an error", async () => { + const { user } = setup( + + + + ); + + expect(getState().flowSummary).toBe(""); + + const serviceSummaryInput = screen.getByPlaceholderText("Description"); + + await user.type(serviceSummaryInput, "a summary"); + + await user.click(screen.getByRole("button", { name: "Save" })); + + expect(screen.getByText("a summary")).toBeInTheDocument(); + await user.click(screen.getByRole("button", { name: "Reset changes" })); // refreshes page and refetches data + + expect(getState().flowSummary).toEqual("a summary"); + expect(screen.getByText("a summary")).toBeInTheDocument(); + }); + + it("displays an error if the service description is longer than 120 characters", async () => { + const { user } = setup( + + + + ); + + expect(getState().flowSummary).toBe(""); + + const serviceSummaryInput = screen.getByPlaceholderText("Description"); + + await user.type(serviceSummaryInput, longInput); + + expect( + await screen.findByText("You have 2 characters too many") + ).toBeInTheDocument(); + + await user.click(screen.getByRole("button", { name: "Save" })); + + expect( + screen.getByText("Service description must be 120 characters or less") + ).toBeInTheDocument(); + + await user.click(screen.getByRole("button", { name: "Reset changes" })); // refreshes page and refetches data + expect(getState().flowSummary).toBe(""); // db has not been updated + }); + + it("displays data in the fields if there is already flow information in the database", async () => { + await act(async () => + setState({ + flowSummary: "This flow summary is in the db already", + }) + ); + + setup( + + + + ); + + expect( + screen.getByText("This flow summary is in the db already") + ).toBeInTheDocument(); + }); + + it.todo("displays an error toast if there is a server-side issue"); // waiting for PR 4019 to merge first so can use msw package + + it("should not have any accessibility violations", async () => { + const { container } = setup( + + + + ); + + const results = await axe(container); + expect(results).toHaveNoViolations(); + }); + + it("is not editable if the user has the teamViewer role", async () => { + const teamViewerUser = { ...platformAdminUser, isPlatformAdmin: false }; + getState().setUser(teamViewerUser); + + getState().setTeamMembers([{ ...teamViewerUser, role: "teamViewer" }]); + + setup( + + + + ); + + expect(getState().flowSummary).toBe(""); + + const serviceSummaryInput = screen.getByPlaceholderText("Description"); + + expect(serviceSummaryInput).toBeDisabled(); + expect(screen.getByRole("button", { name: "Save" })).toBeDisabled(); + }); +}); diff --git a/editor.planx.uk/src/pages/FlowEditor/ReadMePage/tests/helpers.ts b/editor.planx.uk/src/pages/FlowEditor/ReadMePage/tests/helpers.ts new file mode 100644 index 0000000000..ceb8cfa5c3 --- /dev/null +++ b/editor.planx.uk/src/pages/FlowEditor/ReadMePage/tests/helpers.ts @@ -0,0 +1,26 @@ +import { ReadMePageProps } from "../types"; + +export const platformAdminUser = { + id: 1, + firstName: "Editor", + lastName: "Test", + isPlatformAdmin: true, + email: "test@test.com", + teams: [], + jwt: "x.y.z", +}; + +export const defaultProps = { + flowSlug: "apply-for-planning-permission", + teamSlug: "barnet", + flowInformation: { + status: "online", + description: "A long description of a service", + summary: "A short blurb", + limitations: "", + settings: {}, + }, +} as ReadMePageProps; + +export const longInput = + "A wonderful serenity has taken possession of my entire soul, like these sweet mornings of spring which I enjoy with my who"; // 122 characters diff --git a/editor.planx.uk/src/pages/FlowEditor/ReadMePage/types.ts b/editor.planx.uk/src/pages/FlowEditor/ReadMePage/types.ts new file mode 100644 index 0000000000..9eef95cd29 --- /dev/null +++ b/editor.planx.uk/src/pages/FlowEditor/ReadMePage/types.ts @@ -0,0 +1,13 @@ +import { FlowInformation } from "../utils"; + +export interface ReadMePageProps { + flowInformation: FlowInformation; + teamSlug: string; + flowSlug: string; +} + +export interface ReadMePageForm { + serviceSummary: string; + serviceDescription: string; + serviceLimitations: string; +} diff --git a/editor.planx.uk/src/pages/FlowEditor/components/Sidebar/Search/ExternalPortalList.test.tsx b/editor.planx.uk/src/pages/FlowEditor/components/Sidebar/Search/ExternalPortalList/ExternalPortalList.test.tsx similarity index 89% rename from editor.planx.uk/src/pages/FlowEditor/components/Sidebar/Search/ExternalPortalList.test.tsx rename to editor.planx.uk/src/pages/FlowEditor/components/Sidebar/Search/ExternalPortalList/ExternalPortalList.test.tsx index 6130fde2a0..9ea84825a8 100644 --- a/editor.planx.uk/src/pages/FlowEditor/components/Sidebar/Search/ExternalPortalList.test.tsx +++ b/editor.planx.uk/src/pages/FlowEditor/components/Sidebar/Search/ExternalPortalList/ExternalPortalList.test.tsx @@ -6,9 +6,9 @@ import { setup } from "testUtils"; import { vi } from "vitest"; import { axe } from "vitest-axe"; -import Search from "."; -import { flow } from "./mocks/simple"; -import { VirtuosoWrapper } from "./testUtils"; +import Search from ".."; +import { flow } from "../mocks/simple"; +import { VirtuosoWrapper } from "../testUtils"; vi.mock("react-navi", () => ({ useNavigation: () => ({ @@ -34,7 +34,7 @@ it("does not display if there are no external portals in the flow", () => { const { queryByTestId } = setup( - , + ); expect(queryByTestId("searchExternalPortalList")).not.toBeInTheDocument(); @@ -46,7 +46,7 @@ it("does not display if there is no search term provided", () => { const { queryByTestId } = setup( - , + ); expect(queryByTestId("searchExternalPortalList")).not.toBeInTheDocument(); @@ -58,14 +58,14 @@ it("displays a list of external portals if present in the flow, and a search ter const { findByTestId, getByText, getByLabelText, user } = setup( - , + ); const searchInput = getByLabelText("Search this flow and internal portals"); user.type(searchInput, "ind"); const externalPortalList = await waitFor(() => - findByTestId("searchExternalPortalList"), + findByTestId("searchExternalPortalList") ); expect(externalPortalList).toBeDefined(); @@ -79,14 +79,14 @@ it("allows users to navigate to the external portals", async () => { const { getAllByRole, getByLabelText, user } = setup( - , + ); const searchInput = getByLabelText("Search this flow and internal portals"); user.type(searchInput, "ind"); const [first, second] = await waitFor( - () => getAllByRole("link") as HTMLAnchorElement[], + () => getAllByRole("link") as HTMLAnchorElement[] ); expect(first).toHaveAttribute("href", "../myTeam/portalOne"); expect(second).toHaveAttribute("href", "../myTeam/portalTwo"); @@ -98,14 +98,14 @@ it("should not have any accessibility violations on initial load", async () => { const { container, getByLabelText, user, findByTestId } = setup( - , + ); const searchInput = getByLabelText("Search this flow and internal portals"); user.type(searchInput, "ind"); await waitFor(() => - expect(findByTestId("searchExternalPortalList")).toBeDefined(), + expect(findByTestId("searchExternalPortalList")).toBeDefined() ); const results = await axe(container); diff --git a/editor.planx.uk/src/pages/FlowEditor/components/Sidebar/Search/ExternalPortalList.tsx b/editor.planx.uk/src/pages/FlowEditor/components/Sidebar/Search/ExternalPortalList/ExternalPortalList.tsx similarity index 53% rename from editor.planx.uk/src/pages/FlowEditor/components/Sidebar/Search/ExternalPortalList.tsx rename to editor.planx.uk/src/pages/FlowEditor/components/Sidebar/Search/ExternalPortalList/ExternalPortalList.tsx index eb0cf298e3..ffe57f81da 100644 --- a/editor.planx.uk/src/pages/FlowEditor/components/Sidebar/Search/ExternalPortalList.tsx +++ b/editor.planx.uk/src/pages/FlowEditor/components/Sidebar/Search/ExternalPortalList/ExternalPortalList.tsx @@ -1,21 +1,11 @@ import Box from "@mui/material/Box"; -import List from "@mui/material/List"; -import ListItem from "@mui/material/ListItem"; -import ListItemButton from "@mui/material/ListItemButton"; -import { styled } from "@mui/material/styles"; import Typography from "@mui/material/Typography"; import { useStore } from "pages/FlowEditor/lib/store"; import React from "react"; import { Components } from "react-virtuoso"; -import { Context, Data } from "."; - -export const Root = styled(List)(({ theme }) => ({ - color: theme.palette.text.primary, - padding: theme.spacing(0.5, 0), - backgroundColor: theme.palette.background.paper, - border: `1px solid ${theme.palette.border.light}`, -})); +import { Context, Data } from ".."; +import { ExternalPortals } from "./ExternalPortals"; export const ExternalPortalList: Components["Footer"] = ({ context, @@ -36,17 +26,7 @@ export const ExternalPortalList: Components["Footer"] = ({ Your service also contains the following external portals, which have not been searched: - - {Object.values(externalPortals).map(({ name, href }) => ( - - - - {href.replaceAll("/", " / ")} - - - - ))} - + ); }; diff --git a/editor.planx.uk/src/pages/FlowEditor/components/Sidebar/Search/ExternalPortalList/ExternalPortals.tsx b/editor.planx.uk/src/pages/FlowEditor/components/Sidebar/Search/ExternalPortalList/ExternalPortals.tsx new file mode 100644 index 0000000000..15034e6d49 --- /dev/null +++ b/editor.planx.uk/src/pages/FlowEditor/components/Sidebar/Search/ExternalPortalList/ExternalPortals.tsx @@ -0,0 +1,25 @@ +import ListItem from "@mui/material/ListItem"; +import ListItemButton from "@mui/material/ListItemButton"; +import Typography from "@mui/material/Typography"; +import React from "react"; + +import { Root } from "./styles"; +import { ExternalPortalsProps } from "./types"; + +export const ExternalPortals: React.FC = ({ + externalPortals, +}) => { + return ( + + {Object.values(externalPortals).map(({ name, href }) => ( + + + + {href.replaceAll("/", " / ")} + + + + ))} + + ); +}; diff --git a/editor.planx.uk/src/pages/FlowEditor/components/Sidebar/Search/ExternalPortalList/styles.ts b/editor.planx.uk/src/pages/FlowEditor/components/Sidebar/Search/ExternalPortalList/styles.ts new file mode 100644 index 0000000000..cc7bf4356f --- /dev/null +++ b/editor.planx.uk/src/pages/FlowEditor/components/Sidebar/Search/ExternalPortalList/styles.ts @@ -0,0 +1,9 @@ +import List from "@mui/material/List"; +import { styled } from "@mui/material/styles"; + +export const Root = styled(List)(({ theme }) => ({ + color: theme.palette.text.primary, + padding: theme.spacing(0.5, 0), + backgroundColor: theme.palette.background.paper, + border: `1px solid ${theme.palette.border.light}`, +})); diff --git a/editor.planx.uk/src/pages/FlowEditor/components/Sidebar/Search/ExternalPortalList/types.ts b/editor.planx.uk/src/pages/FlowEditor/components/Sidebar/Search/ExternalPortalList/types.ts new file mode 100644 index 0000000000..b2f4988f93 --- /dev/null +++ b/editor.planx.uk/src/pages/FlowEditor/components/Sidebar/Search/ExternalPortalList/types.ts @@ -0,0 +1,9 @@ +export interface ExternalPortalsProps { + externalPortals: Record< + string, + { + name: string; + href: string; + } + >; +} diff --git a/editor.planx.uk/src/pages/FlowEditor/components/Sidebar/Search/index.tsx b/editor.planx.uk/src/pages/FlowEditor/components/Sidebar/Search/index.tsx index 9b64008d3d..485ad8b83c 100644 --- a/editor.planx.uk/src/pages/FlowEditor/components/Sidebar/Search/index.tsx +++ b/editor.planx.uk/src/pages/FlowEditor/components/Sidebar/Search/index.tsx @@ -12,7 +12,7 @@ import { useStore } from "pages/FlowEditor/lib/store"; import React, { useEffect, useMemo, useState } from "react"; import { Components, Virtuoso } from "react-virtuoso"; -import { ExternalPortalList } from "./ExternalPortalList"; +import { ExternalPortalList } from "./ExternalPortalList/ExternalPortalList"; import { ALL_FACETS, SearchFacets } from "./facets"; import { SearchHeader } from "./SearchHeader"; import { SearchResultCard } from "./SearchResultCard"; @@ -88,7 +88,7 @@ const Search: React.FC = () => { setLastPattern(pattern); setIsSearching(false); }, DEBOUNCE_MS), - [search], + [search] ); return ( diff --git a/editor.planx.uk/src/routes/readMePage.tsx b/editor.planx.uk/src/routes/readMePage.tsx index c5df9a66d0..a50e6f21d8 100644 --- a/editor.planx.uk/src/routes/readMePage.tsx +++ b/editor.planx.uk/src/routes/readMePage.tsx @@ -15,13 +15,19 @@ const readMePageRoutes = compose( mount({ "/": route(async (req) => { - const { team: teamSlug } = req.params; + const { team: teamSlug, flow: flowSlug } = req.params; const data = await getFlowInformation(req.params.flow, req.params.team); return { title: makeTitle("About this page"), - view: , + view: ( + + ), }; }), }), diff --git a/editor.planx.uk/src/ui/editor/FlowTag/FlowTag.tsx b/editor.planx.uk/src/ui/editor/FlowTag/FlowTag.tsx index ca299dd87f..95792cfde5 100644 --- a/editor.planx.uk/src/ui/editor/FlowTag/FlowTag.tsx +++ b/editor.planx.uk/src/ui/editor/FlowTag/FlowTag.tsx @@ -1,45 +1,7 @@ -import Box from "@mui/material/Box"; -import { styled } from "@mui/material/styles"; import React from "react"; -import { FONT_WEIGHT_SEMI_BOLD } from "theme"; -import { FlowTagProps, FlowTagType, StatusVariant } from "./types"; - -const Root = styled(Box, { - shouldForwardProp: (prop) => prop !== "tagType" && prop !== "statusVariant", -})(({ theme, tagType, statusVariant }) => ({ - fontSize: theme.typography.body2.fontSize, - fontWeight: FONT_WEIGHT_SEMI_BOLD, - padding: "2px 6px", - display: "flex", - alignItems: "center", - gap: theme.spacing(0.5), - borderRadius: "4px", - textTransform: "capitalize", - border: "1px solid rgba(0, 0, 0, 0.2)", - ...(tagType === FlowTagType.Status && { - backgroundColor: - statusVariant === StatusVariant.Online - ? theme.palette.flowTag.online - : theme.palette.flowTag.offline, - "&::before": { - content: '""', - width: "8px", - height: "8px", - borderRadius: "50%", - background: - statusVariant === StatusVariant.Online - ? theme.palette.success.main - : theme.palette.flowTag.lightOff, - }, - }), - ...(tagType === FlowTagType.ApplicationType && { - backgroundColor: theme.palette.flowTag.applicationType, - }), - ...(tagType === FlowTagType.ServiceType && { - backgroundColor: theme.palette.flowTag.serviceType, - }), -})); +import { Root } from "./styles"; +import { FlowTagProps } from "./types"; const FlowTag: React.FC = ({ tagType, diff --git a/editor.planx.uk/src/ui/editor/FlowTag/styles.ts b/editor.planx.uk/src/ui/editor/FlowTag/styles.ts new file mode 100644 index 0000000000..5bc710d060 --- /dev/null +++ b/editor.planx.uk/src/ui/editor/FlowTag/styles.ts @@ -0,0 +1,41 @@ +import Box from "@mui/material/Box"; +import { styled } from "@mui/material/styles"; +import { FONT_WEIGHT_SEMI_BOLD } from "theme"; + +import { FlowTagProps, FlowTagType, StatusVariant } from "./types"; + +export const Root = styled(Box, { + shouldForwardProp: (prop) => prop !== "tagType" && prop !== "statusVariant", +})(({ theme, tagType, statusVariant }) => ({ + fontSize: theme.typography.body2.fontSize, + fontWeight: FONT_WEIGHT_SEMI_BOLD, + padding: "2px 6px", + display: "flex", + alignItems: "center", + gap: theme.spacing(0.5), + borderRadius: "4px", + textTransform: "capitalize", + border: "1px solid rgba(0, 0, 0, 0.2)", + ...(tagType === FlowTagType.Status && { + backgroundColor: + statusVariant === StatusVariant.Online + ? theme.palette.flowTag.online + : theme.palette.flowTag.offline, + "&::before": { + content: '""', + width: "8px", + height: "8px", + borderRadius: "50%", + background: + statusVariant === StatusVariant.Online + ? theme.palette.success.main + : theme.palette.flowTag.lightOff, + }, + }), + ...(tagType === FlowTagType.ApplicationType && { + backgroundColor: theme.palette.flowTag.applicationType, + }), + ...(tagType === FlowTagType.ServiceType && { + backgroundColor: theme.palette.flowTag.serviceType, + }), +}));