From ebc5fe5019eccedc28c4513bafd3a45741cf01b9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dafydd=20Ll=C5=B7r=20Pearson?= Date: Mon, 26 Aug 2024 16:33:27 +0100 Subject: [PATCH 1/7] fix: Add `VITE_APP_*` env vars to staging and prod CI (#3561) --- .github/workflows/push-main.yml | 14 +++++++------- .github/workflows/push-production.yml | 14 +++++++------- 2 files changed, 14 insertions(+), 14 deletions(-) diff --git a/.github/workflows/push-main.yml b/.github/workflows/push-main.yml index 9fc99a9b1a..9c8b73c8e8 100644 --- a/.github/workflows/push-main.yml +++ b/.github/workflows/push-main.yml @@ -53,13 +53,13 @@ jobs: - run: pnpm build working-directory: editor.planx.uk env: - REACT_APP_API_URL: https://api.editor.planx.dev - REACT_APP_HASURA_URL: https://hasura.editor.planx.dev/v1/graphql - REACT_APP_HASURA_WEBSOCKET: wss://hasura.editor.planx.dev/v1/graphql - REACT_APP_SHAREDB_URL: wss://sharedb.editor.planx.dev - REACT_APP_AIRBRAKE_PROJECT_ID: ${{ secrets.AIRBRAKE_PROJECT_ID }} - REACT_APP_AIRBRAKE_PROJECT_KEY: ${{ secrets.AIRBRAKE_PROJECT_KEY }} - REACT_APP_ENV: staging + VITE_APP_API_URL: https://api.editor.planx.dev + VITE_APP_HASURA_URL: https://hasura.editor.planx.dev/v1/graphql + VITE_APP_HASURA_WEBSOCKET: wss://hasura.editor.planx.dev/v1/graphql + VITE_APP_SHAREDB_URL: wss://sharedb.editor.planx.dev + VITE_APP_AIRBRAKE_PROJECT_ID: ${{ secrets.AIRBRAKE_PROJECT_ID }} + VITE_APP_AIRBRAKE_PROJECT_KEY: ${{ secrets.AIRBRAKE_PROJECT_KEY }} + VITE_APP_ENV: staging - name: Upload Build Artifact uses: actions/upload-artifact@v4 with: diff --git a/.github/workflows/push-production.yml b/.github/workflows/push-production.yml index 917a32fa90..319f28051b 100644 --- a/.github/workflows/push-production.yml +++ b/.github/workflows/push-production.yml @@ -53,13 +53,13 @@ jobs: - run: pnpm build working-directory: editor.planx.uk env: - REACT_APP_API_URL: https://api.editor.planx.uk - REACT_APP_HASURA_URL: https://hasura.editor.planx.uk/v1/graphql - REACT_APP_HASURA_WEBSOCKET: wss://hasura.editor.planx.uk/v1/graphql - REACT_APP_SHAREDB_URL: wss://sharedb.editor.planx.uk - REACT_APP_AIRBRAKE_PROJECT_ID: ${{ secrets.AIRBRAKE_PROJECT_ID }} - REACT_APP_AIRBRAKE_PROJECT_KEY: ${{ secrets.AIRBRAKE_PROJECT_KEY }} - REACT_APP_ENV: production + VITE_APP_API_URL: https://api.editor.planx.uk + VITE_APP_HASURA_URL: https://hasura.editor.planx.uk/v1/graphql + VITE_APP_HASURA_WEBSOCKET: wss://hasura.editor.planx.uk/v1/graphql + VITE_APP_SHAREDB_URL: wss://sharedb.editor.planx.uk + VITE_APP_AIRBRAKE_PROJECT_ID: ${{ secrets.AIRBRAKE_PROJECT_ID }} + VITE_APP_AIRBRAKE_PROJECT_KEY: ${{ secrets.AIRBRAKE_PROJECT_KEY }} + VITE_APP_ENV: production - name: Upload Build Artifact uses: actions/upload-artifact@v4 with: From 0bc96f5b2c19230733c19fc2fd464d04ba29d500 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dafydd=20Ll=C5=B7r=20Pearson?= Date: Mon, 26 Aug 2024 17:03:15 +0100 Subject: [PATCH 2/7] chore: Updating remaining `REACT_APP_*` envs to `VITE_APP_*` (#3562) --- e2e/tests/ui-driven/install-dependencies.sh | 2 +- editor.planx.uk/src/@planx/components/MapAndLabel/Public.tsx | 4 +++- .../components/shared/Schema/InputFields/MapFieldInput.tsx | 4 +++- .../pages/FlowEditor/components/Settings/ServiceSettings.tsx | 2 +- 4 files changed, 8 insertions(+), 4 deletions(-) diff --git a/e2e/tests/ui-driven/install-dependencies.sh b/e2e/tests/ui-driven/install-dependencies.sh index be6e84f598..b40ecb4a71 100755 --- a/e2e/tests/ui-driven/install-dependencies.sh +++ b/e2e/tests/ui-driven/install-dependencies.sh @@ -5,7 +5,7 @@ cd $(dirname "$0") source ../../../.env -(cd ../../../editor.planx.uk && pnpm install --frozen-lockfile && REACT_APP_ENV=test pnpm build) +(cd ../../../editor.planx.uk && pnpm install --frozen-lockfile && VITE_APP_ENV=test pnpm build) if [ -z "${CI}" ]; then echo "Please make sure you have Chrome installed on this machine." diff --git a/editor.planx.uk/src/@planx/components/MapAndLabel/Public.tsx b/editor.planx.uk/src/@planx/components/MapAndLabel/Public.tsx index 743757538c..e930a0e8a1 100644 --- a/editor.planx.uk/src/@planx/components/MapAndLabel/Public.tsx +++ b/editor.planx.uk/src/@planx/components/MapAndLabel/Public.tsx @@ -79,7 +79,9 @@ function MapAndLabelComponent(props: Props) { teamSettings?.boundaryBBox && JSON.stringify(teamSettings?.boundaryBBox) } - osProxyEndpoint={`${process.env.REACT_APP_API_URL}/proxy/ordnance-survey`} + osProxyEndpoint={`${ + import.meta.env.VITE_APP_API_URL + }/proxy/ordnance-survey`} osCopyright={`© Crown copyright and database rights ${new Date().getFullYear()} OS (0)100024857`} collapseAttributions={window.innerWidth < 500 ? true : undefined} /> 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 2291556cbc..f362cba5b2 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 @@ -61,7 +61,9 @@ export const MapFieldInput: React.FC> = (props) => { maxZoom={23} latitude={Number(passport?.data?._address?.latitude)} longitude={Number(passport?.data?._address?.longitude)} - osProxyEndpoint={`${process.env.REACT_APP_API_URL}/proxy/ordnance-survey`} + osProxyEndpoint={`${ + import.meta.env.VITE_APP_API_URL + }/proxy/ordnance-survey`} osCopyright={`Basemap subject to Crown copyright and database rights ${new Date().getFullYear()} OS (0)100024857`} clipGeojsonData={ teamSettings?.boundaryBBox && diff --git a/editor.planx.uk/src/pages/FlowEditor/components/Settings/ServiceSettings.tsx b/editor.planx.uk/src/pages/FlowEditor/components/Settings/ServiceSettings.tsx index 2f9ae2b08a..59dc50e2ae 100644 --- a/editor.planx.uk/src/pages/FlowEditor/components/Settings/ServiceSettings.tsx +++ b/editor.planx.uk/src/pages/FlowEditor/components/Settings/ServiceSettings.tsx @@ -260,7 +260,7 @@ const ServiceSettings: React.FC = () => { const message = `${emoji[status]} *${teamSlug}/${flowSlug}* is now ${status} (@Silvia)`; return axios.post( - `${process.env.REACT_APP_API_URL}/send-slack-notification`, + `${import.meta.env.VITE_APP_API_URL}/send-slack-notification`, { message: message, }, From 6e7e278cadc31b7425a12f1c6445d3588703d229 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dafydd=20Ll=C5=B7r=20Pearson?= Date: Mon, 26 Aug 2024 17:13:19 +0100 Subject: [PATCH 3/7] chore: Reorganise search file structure (#3563) --- .../FlowEditor/components/Sidebar/Search.tsx | 271 ------------------ .../Sidebar/Search/ExternalPortalList.tsx | 42 +++ .../components/Sidebar/Search/Headline.tsx | 32 +++ .../Sidebar/Search/NodeSearchResults.tsx | 38 +++ .../Sidebar/Search/SearchResultCard.tsx | 112 ++++++++ .../components/Sidebar/Search/index.tsx | 82 ++++++ 6 files changed, 306 insertions(+), 271 deletions(-) delete mode 100644 editor.planx.uk/src/pages/FlowEditor/components/Sidebar/Search.tsx create mode 100644 editor.planx.uk/src/pages/FlowEditor/components/Sidebar/Search/ExternalPortalList.tsx create mode 100644 editor.planx.uk/src/pages/FlowEditor/components/Sidebar/Search/Headline.tsx create mode 100644 editor.planx.uk/src/pages/FlowEditor/components/Sidebar/Search/NodeSearchResults.tsx create mode 100644 editor.planx.uk/src/pages/FlowEditor/components/Sidebar/Search/SearchResultCard.tsx create mode 100644 editor.planx.uk/src/pages/FlowEditor/components/Sidebar/Search/index.tsx diff --git a/editor.planx.uk/src/pages/FlowEditor/components/Sidebar/Search.tsx b/editor.planx.uk/src/pages/FlowEditor/components/Sidebar/Search.tsx deleted file mode 100644 index 7b25315d9a..0000000000 --- a/editor.planx.uk/src/pages/FlowEditor/components/Sidebar/Search.tsx +++ /dev/null @@ -1,271 +0,0 @@ -import Box from "@mui/material/Box"; -import Container from "@mui/material/Container"; -import List from "@mui/material/List"; -import ListItem from "@mui/material/ListItem"; -import ListItemButton from "@mui/material/ListItemButton"; -import { styled, Theme } from "@mui/material/styles"; -import Typography from "@mui/material/Typography"; -import { ComponentType, IndexedNode } from "@opensystemslab/planx-core/types"; -import { ICONS } from "@planx/components/ui"; -import { useFormik } from "formik"; -import type { SearchResult, SearchResults } from "hooks/useSearch"; -import { useSearch } from "hooks/useSearch"; -import { capitalize, get } from "lodash"; -import { SLUGS } from "pages/FlowEditor/data/types"; -import { useStore } from "pages/FlowEditor/lib/store"; -import React, { useEffect } from "react"; -import { useNavigation } from "react-navi"; -import { FONT_WEIGHT_BOLD, FONT_WEIGHT_SEMI_BOLD } from "theme"; -import ChecklistItem from "ui/shared/ChecklistItem"; -import Input from "ui/shared/Input"; - -const SearchResultRoot = styled(List)(({ theme }) => ({ - width: "100%", - gap: theme.spacing(2), - display: "flex", - flexDirection: "column", -})); - -const SearchResultCardRoot = styled(ListItemButton)(({ theme }) => ({ - padding: theme.spacing(1), - border: `1px solid ${theme.palette.common.black}`, - display: "block", -})); - -const PortalList = 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}`, -})); - -const SearchResults: React.FC<{ results: SearchResults }> = ({ - results, -}) => { - return ( - <> - - {!results.length && "No matches found"} - {results.length === 1 && "1 result:"} - {results.length > 1 && `${results.length} results:`} - - - - {results.map((result) => ( - - - - ))} - - - ); -}; - -interface HeadlineProps { - text: string; - matchIndices: [number, number][]; - variant: "data"; -} - -const Headline: React.FC = ({ text, matchIndices, variant }) => { - const isHighlighted = (index: number) => - matchIndices.some(([start, end]) => index >= start && index <= end); - - return ( - <> - {text.split("").map((char, index) => ( - ({ - fontWeight: isHighlighted(index) ? FONT_WEIGHT_BOLD : "regular", - fontSize: theme.typography.body2.fontSize, - })} - > - {char} - - ))} - - ); -}; - -const SearchResultCard: React.FC<{ result: SearchResult }> = ({ - result, -}) => { - const getDisplayDetailsForResult = ({ - item, - key, - }: SearchResult) => { - const componentType = capitalize( - SLUGS[result.item.type].replaceAll("-", " "), - ); - let title = (item.data?.title as string) || (item.data?.text as string); - let Icon = ICONS[item.type]; - // TODO: Generate display key from key - let displayKey = "Data"; - const headline = get(item, key).toString() || ""; - - // For Answer nodes, update display values to match the parent question - if (item.type === ComponentType.Answer) { - const parentNode = useStore.getState().flow[item.parentId]; - Icon = ICONS[ComponentType.Question]; - title = parentNode!.data.text!; - displayKey = "Option (data)"; - } - - return { - Icon, - componentType, - title, - key: displayKey, - headline, - }; - }; - - const { Icon, componentType, title, key, headline } = - getDisplayDetailsForResult(result); - - // TODO - display portal wrapper - - const handleClick = () => { - console.log("todo!"); - console.log({ nodeId: result.item.id }); - // get path for node - // generate url from path - // navigate to url - }; - - return ( - - - {Icon && } - - {componentType} - - {title && ( - - {` • ${title}`} - - )} - - - {key} - - - - - ); -}; - -const ExternalPortalList: React.FC = () => { - const externalPortals = useStore((state) => state.externalPortals); - const hasExternalPortals = Object.keys(externalPortals).length; - const { navigate } = useNavigation(); - - if (!hasExternalPortals) return null; - - return ( - - - Your service also contains the following external portals, which have - not been searched: - - - {Object.values(externalPortals).map(({ name, href }) => ( - - - - {href.replaceAll("/", " / ")} - - - - ))} - - - ); -}; - -interface SearchNodes { - input: string; - facets: ["data.fn", "data.val"]; -} - -const Search: React.FC = () => { - const [orderedFlow, setOrderedFlow] = useStore((state) => [ - state.orderedFlow, - state.setOrderedFlow, - ]); - - useEffect(() => { - if (!orderedFlow) setOrderedFlow(); - }, [setOrderedFlow]); - - const formik = useFormik({ - initialValues: { input: "", facets: ["data.fn", "data.val"] }, - onSubmit: ({ input }) => { - search(input); - }, - }); - - const { results, search } = useSearch({ - list: orderedFlow || [], - keys: formik.values.facets, - }); - - return ( - -
- - Search this flow and internal portals - - { - formik.setFieldValue("input", e.target.value); - formik.handleSubmit(); - }} - inputProps={{ spellCheck: false }} - /> - {}} - /> - - {formik.values.input && ( - <> - - - - )} - - -
- ); -}; - -export default Search; 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.tsx new file mode 100644 index 0000000000..c3e578fa68 --- /dev/null +++ b/editor.planx.uk/src/pages/FlowEditor/components/Sidebar/Search/ExternalPortalList.tsx @@ -0,0 +1,42 @@ +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"; + +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}`, +})); + +export const ExternalPortalList: React.FC = () => { + const externalPortals = useStore((state) => state.externalPortals); + const hasExternalPortals = Object.keys(externalPortals).length; + + if (!hasExternalPortals) return null; + + return ( + + + 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/Headline.tsx b/editor.planx.uk/src/pages/FlowEditor/components/Sidebar/Search/Headline.tsx new file mode 100644 index 0000000000..5681e19324 --- /dev/null +++ b/editor.planx.uk/src/pages/FlowEditor/components/Sidebar/Search/Headline.tsx @@ -0,0 +1,32 @@ +import Typography from "@mui/material/Typography"; +import React from "react"; +import { FONT_WEIGHT_BOLD } from "theme"; + +interface Props { + text: string; + matchIndices: [number, number][]; + variant: "data"; +} + +export const Headline: React.FC = ({ text, matchIndices }) => { + const isHighlighted = (index: number) => + matchIndices.some(([start, end]) => index >= start && index <= end); + + return ( + <> + {text.split("").map((char, index) => ( + ({ + fontWeight: isHighlighted(index) ? FONT_WEIGHT_BOLD : "regular", + fontSize: theme.typography.body2.fontSize, + })} + > + {char} + + ))} + + ); +}; diff --git a/editor.planx.uk/src/pages/FlowEditor/components/Sidebar/Search/NodeSearchResults.tsx b/editor.planx.uk/src/pages/FlowEditor/components/Sidebar/Search/NodeSearchResults.tsx new file mode 100644 index 0000000000..fcbb5d8daf --- /dev/null +++ b/editor.planx.uk/src/pages/FlowEditor/components/Sidebar/Search/NodeSearchResults.tsx @@ -0,0 +1,38 @@ +import List from "@mui/material/List"; +import ListItem from "@mui/material/ListItem"; +import { styled } from "@mui/material/styles"; +import Typography from "@mui/material/Typography"; +import { IndexedNode } from "@opensystemslab/planx-core/types"; +import type { SearchResults } from "hooks/useSearch"; +import React from "react"; + +import { SearchResultCard } from "./SearchResultCard"; + +export const Root = styled(List)(({ theme }) => ({ + width: "100%", + gap: theme.spacing(2), + display: "flex", + flexDirection: "column", +})); + +export const NodeSearchResults: React.FC<{ + results: SearchResults; +}> = ({ results }) => { + return ( + <> + + {!results.length && "No matches found"} + {results.length === 1 && "1 result:"} + {results.length > 1 && `${results.length} results:`} + + + + {results.map((result) => ( + + + + ))} + + + ); +}; diff --git a/editor.planx.uk/src/pages/FlowEditor/components/Sidebar/Search/SearchResultCard.tsx b/editor.planx.uk/src/pages/FlowEditor/components/Sidebar/Search/SearchResultCard.tsx new file mode 100644 index 0000000000..30f5621d2a --- /dev/null +++ b/editor.planx.uk/src/pages/FlowEditor/components/Sidebar/Search/SearchResultCard.tsx @@ -0,0 +1,112 @@ +import Box from "@mui/material/Box"; +import ListItemButton from "@mui/material/ListItemButton"; +import { styled } from "@mui/material/styles"; +import Typography from "@mui/material/Typography"; +import { ComponentType, IndexedNode } from "@opensystemslab/planx-core/types"; +import { ICONS } from "@planx/components/ui"; +import type { SearchResult } from "hooks/useSearch"; +import { capitalize, get } from "lodash"; +import { SLUGS } from "pages/FlowEditor/data/types"; +import { useStore } from "pages/FlowEditor/lib/store"; +import React from "react"; +import { FONT_WEIGHT_SEMI_BOLD } from "theme"; + +import { Headline } from "./Headline"; + +export const Root = styled(ListItemButton)(({ theme }) => ({ + padding: theme.spacing(1), + border: `1px solid ${theme.palette.common.black}`, + display: "block", +})); + +export const SearchResultCard: React.FC<{ + result: SearchResult; +}> = ({ result }) => { + const getDisplayDetailsForResult = ({ + item, + key, + }: SearchResult) => { + const componentType = capitalize( + SLUGS[result.item.type].replaceAll("-", " "), + ); + let title = (item.data?.title as string) || (item.data?.text as string); + let Icon = ICONS[item.type]; // TODO: Generate display key from key + + let displayKey = "Data"; + const headline = get(item, key).toString() || ""; + + // For Answer nodes, update display values to match the parent question + if (item.type === ComponentType.Answer) { + const parentNode = useStore.getState().flow[item.parentId]; + Icon = ICONS[ComponentType.Question]; + title = parentNode!.data.text!; + displayKey = "Option (data)"; + } + + return { + Icon, + componentType, + title, + key: displayKey, + headline, + }; + }; + + const { Icon, componentType, title, key, headline } = + getDisplayDetailsForResult(result); // TODO - display portal wrapper + + const handleClick = () => { + console.log("todo!"); + console.log({ nodeId: result.item.id }); + // get path for node + // generate url from path + // navigate to url + }; + + return ( + + + {Icon && ( + + )} + + {componentType} + + {title && ( + + {` • ${title}`} + + )} + + + {key} - + + + + ); +}; 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 new file mode 100644 index 0000000000..a7f9a825f5 --- /dev/null +++ b/editor.planx.uk/src/pages/FlowEditor/components/Sidebar/Search/index.tsx @@ -0,0 +1,82 @@ +import Box from "@mui/material/Box"; +import Container from "@mui/material/Container"; +import Typography from "@mui/material/Typography"; +import { useFormik } from "formik"; +import { useSearch } from "hooks/useSearch"; +import { useStore } from "pages/FlowEditor/lib/store"; +import React, { useEffect } from "react"; +import ChecklistItem from "ui/shared/ChecklistItem"; +import Input from "ui/shared/Input"; + +import { ExternalPortalList } from "./ExternalPortalList"; +import { NodeSearchResults } from "./NodeSearchResults"; + +interface SearchNodes { + input: string; + facets: ["data.fn", "data.val"]; +} + +const Search: React.FC = () => { + const [orderedFlow, setOrderedFlow] = useStore((state) => [ + state.orderedFlow, + state.setOrderedFlow, + ]); + + useEffect(() => { + if (!orderedFlow) setOrderedFlow(); + }, [orderedFlow, setOrderedFlow]); + + const formik = useFormik({ + initialValues: { input: "", facets: ["data.fn", "data.val"] }, + onSubmit: ({ input }) => { + search(input); + }, + }); + + const { results, search } = useSearch({ + list: orderedFlow || [], + keys: formik.values.facets, + }); + + return ( + +
+ + Search this flow and internal portals + + { + formik.setFieldValue("input", e.target.value); + formik.handleSubmit(); + }} + inputProps={{ spellCheck: false }} + /> + {}} + /> + + {formik.values.input && ( + <> + + + + )} + + +
+ ); +}; + +export default Search; From 469aa2b863e412bd032c08e494878f3e31dceaa4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dafydd=20Ll=C5=B7r=20Pearson?= Date: Mon, 26 Aug 2024 17:44:07 +0100 Subject: [PATCH 4/7] test: Bump timeout on long running List component test (#3564) --- .../src/@planx/components/List/Public/index.test.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/editor.planx.uk/src/@planx/components/List/Public/index.test.tsx b/editor.planx.uk/src/@planx/components/List/Public/index.test.tsx index b619b1f881..fed2d67c66 100644 --- a/editor.planx.uk/src/@planx/components/List/Public/index.test.tsx +++ b/editor.planx.uk/src/@planx/components/List/Public/index.test.tsx @@ -182,7 +182,7 @@ describe("Building a list", () => { ).toBeInTheDocument(); }); - test("Editing an item", { timeout: 20000 }, async () => { + test("Editing an item", { timeout: 25000 }, async () => { // Setup three cards const { getAllByTestId, getByTestId, user } = setup( , @@ -235,7 +235,7 @@ describe("Building a list", () => { test( "Removing an item when all cards are inactive", - { timeout: 20000 }, + { timeout: 25000 }, async () => { // Setup three cards const { From cc885726a55a6b4db196938aa239c20b7b09c92c Mon Sep 17 00:00:00 2001 From: Jo Humphrey <31373245+jamdelion@users.noreply.github.com> Date: Mon, 26 Aug 2024 22:25:50 +0100 Subject: [PATCH 5/7] feat: Submit new team editor data - happy path (#3500) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Dafydd Llŷr Pearson --- .../components/Team/TeamMembers.tsx | 49 ++-- .../Team/components/AddNewEditorModal.tsx | 221 +++++++++++------- .../Team/components/MembersTable.tsx | 111 +++++---- .../Team/components/lib/filterTeamMembers.tsx | 13 ++ .../lib/optimisticallyUpdateMembersTable.tsx | 18 ++ .../FlowEditor/components/Team/formSchema.tsx | 7 + .../Team/queries/createAndAddUserToTeam.tsx | 45 ++++ .../tests/TeamMembers.addNewEditor.test.tsx | 62 ++++- .../Team/tests/exampleTeamMembersData.tsx | 40 ++-- .../tests/helpers/setupTeamMembersScreen.tsx | 11 +- .../Team/tests/helpers/userEntersInput.tsx | 13 ++ .../pages/FlowEditor/components/Team/types.ts | 14 +- .../src/pages/FlowEditor/lib/store/team.ts | 25 +- editor.planx.uk/src/routes/teamMembers.tsx | 58 +++-- 14 files changed, 436 insertions(+), 251 deletions(-) create mode 100644 editor.planx.uk/src/pages/FlowEditor/components/Team/components/lib/filterTeamMembers.tsx create mode 100644 editor.planx.uk/src/pages/FlowEditor/components/Team/components/lib/optimisticallyUpdateMembersTable.tsx create mode 100644 editor.planx.uk/src/pages/FlowEditor/components/Team/formSchema.tsx create mode 100644 editor.planx.uk/src/pages/FlowEditor/components/Team/queries/createAndAddUserToTeam.tsx create mode 100644 editor.planx.uk/src/pages/FlowEditor/components/Team/tests/helpers/userEntersInput.tsx diff --git a/editor.planx.uk/src/pages/FlowEditor/components/Team/TeamMembers.tsx b/editor.planx.uk/src/pages/FlowEditor/components/Team/TeamMembers.tsx index 868733eb95..3bec5e489a 100644 --- a/editor.planx.uk/src/pages/FlowEditor/components/Team/TeamMembers.tsx +++ b/editor.planx.uk/src/pages/FlowEditor/components/Team/TeamMembers.tsx @@ -1,28 +1,36 @@ import Container from "@mui/material/Container"; import Typography from "@mui/material/Typography"; -import React, { useState } from "react"; +import { Role } from "@opensystemslab/planx-core/types"; +import { groupBy } from "lodash"; +import { useStore } from "pages/FlowEditor/lib/store"; +import React from "react"; import SettingsSection from "ui/editor/SettingsSection"; -import { AddNewEditorModal } from "./components/AddNewEditorModal"; +import { + filterByEmailPresent, + filterExcludingPlatformAdmins, + hasEmailPresent, +} from "./components/lib/filterTeamMembers"; import { MembersTable } from "./components/MembersTable"; -import { TeamMember, TeamMembersProps } from "./types"; +import { TeamMember } from "./types"; -export const TeamMembers = ({ teamMembersByRole }: TeamMembersProps) => { - const [showModal, setShowModal] = useState(false); +export const TeamMembers = () => { + const teamMembers = useStore((state) => state.teamMembers); - const platformAdmins = (teamMembersByRole.platformAdmin || []).filter( - (member) => member.email, - ); - const otherRoles = Object.keys(teamMembersByRole) - .filter((role) => role !== "platformAdmin") - .reduce((acc: TeamMember[], role) => { - return acc.concat(teamMembersByRole[role]); - }, []); + const teamMembersByRole = groupBy(teamMembers, "role") as Record< + Role, + TeamMember[] + >; + + const platformAdmins = + teamMembersByRole.platformAdmin.filter(hasEmailPresent); - const activeMembers = otherRoles.filter((member) => member.email); + const otherRoles = filterExcludingPlatformAdmins(teamMembers); - const archivedMembers = otherRoles.filter( - (member) => member.role !== "platformAdmin" && !member.email, + const activeMembers = filterByEmailPresent(otherRoles); + + const archivedMembers: TeamMember[] = otherRoles.filter( + (member) => !hasEmailPresent(member), ); return ( @@ -34,11 +42,7 @@ export const TeamMembers = ({ teamMembersByRole }: TeamMembersProps) => { Editors have access to edit your services. - + @@ -61,9 +65,6 @@ export const TeamMembers = ({ teamMembersByRole }: TeamMembersProps) => { )} - {showModal && ( - - )} ); }; diff --git a/editor.planx.uk/src/pages/FlowEditor/components/Team/components/AddNewEditorModal.tsx b/editor.planx.uk/src/pages/FlowEditor/components/Team/components/AddNewEditorModal.tsx index e31746f004..a2b24ff48a 100644 --- a/editor.planx.uk/src/pages/FlowEditor/components/Team/components/AddNewEditorModal.tsx +++ b/editor.planx.uk/src/pages/FlowEditor/components/Team/components/AddNewEditorModal.tsx @@ -4,102 +4,143 @@ import Dialog from "@mui/material/Dialog"; import DialogActions from "@mui/material/DialogActions"; import DialogContent from "@mui/material/DialogContent"; import Typography from "@mui/material/Typography"; +import { FormikHelpers, useFormik } from "formik"; +import { useStore } from "pages/FlowEditor/lib/store"; import React from "react"; import InputGroup from "ui/editor/InputGroup"; import InputLabel from "ui/editor/InputLabel"; import Input from "ui/shared/Input"; -import { AddNewEditorModalProps } from "../types"; +import { addNewEditorFormSchema } from "../formSchema"; +import { createAndAddUserToTeam } from "../queries/createAndAddUserToTeam"; +import { AddNewEditorFormValues, AddNewEditorModalProps } from "../types"; +import { optimisticallyUpdateMembersTable } from "./lib/optimisticallyUpdateMembersTable"; export const AddNewEditorModal = ({ showModal, setShowModal, -}: AddNewEditorModalProps) => ( - ({ - width: "100%", - maxWidth: theme.breakpoints.values.md, - borderRadius: 0, - borderTop: `20px solid ${theme.palette.primary.main}`, - background: "#FFF", - margin: theme.spacing(2), - }), - }} - open={showModal} - onClose={() => setShowModal(false)} - > -
- - - - Add a new editor - - - - - { - console.log("bla"); // TODO in next PR - }} - value={""} - errorMessage={""} - id="firstname" - /> - - - { - console.log("bla"); // TODO in next PR - }} - value={""} - errorMessage={""} - id="lastname" - /> - - - { - console.log("bla"); // TODO in next PR - }} - value={""} - errorMessage={""} - id="email" - /> - - - - - - - - - -
-
-); +}: AddNewEditorModalProps) => { + const handleSubmit = async ( + values: AddNewEditorFormValues, + { resetForm }: FormikHelpers, + ) => { + const { teamId, teamSlug } = useStore.getState(); + + const newUserId = await createAndAddUserToTeam( + values.email, + values.firstName, + values.lastName, + teamId, + teamSlug, + ); + + optimisticallyUpdateMembersTable(values, newUserId); + + setShowModal(false); + resetForm({ values }); + }; + + const formik = useFormik({ + initialValues: { + firstName: "", + lastName: "", + email: "", + }, + validationSchema: addNewEditorFormSchema, + onSubmit: handleSubmit, + }); + + return ( + ({ + width: "100%", + maxWidth: theme.breakpoints.values.md, + borderRadius: 0, + borderTop: `20px solid ${theme.palette.primary.main}`, + background: "#FFF", + margin: theme.spacing(2), + }), + }} + open={showModal} + onClose={() => setShowModal(false)} + > +
+ + + + Add a new editor + + + + + + + + + + + + + + + + + + + + +
+
+ ); +}; 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 70139fcc15..9b3a6eb063 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 @@ -7,16 +7,18 @@ import TableHead from "@mui/material/TableHead"; import TableRow from "@mui/material/TableRow"; import { hasFeatureFlag } from "lib/featureFlags"; import { AddButton } from "pages/Team"; -import React from "react"; +import React, { useState } from "react"; import { StyledAvatar, StyledTableRow } from "./../styles"; import { MembersTableProps } from "./../types"; +import { AddNewEditorModal } from "./AddNewEditorModal"; export const MembersTable = ({ members, showAddMemberButton, - setShowModal = () => true, }: MembersTableProps) => { + const [showModal, setShowModal] = useState(false); + const roleLabels: Record = { platformAdmin: "Admin", teamEditor: "Editor", @@ -42,58 +44,65 @@ export const MembersTable = ({ } return ( - - - - - - User - - - Role - - - Email - - - - - {members.map((member) => ( - - - - {member.firstName[0]} - {member.lastName[0]} - - {member.firstName} {member.lastName} + <> + +
+ + + + User + + + Role - + Email - {member.email} - ))} - {showAddMemberButton && hasFeatureFlag("ADD_NEW_EDITOR") && ( - - - setShowModal(true)}> - Add a new editor - - - - )} - -
-
+ + + {members.map((member) => ( + + + + {member.firstName[0]} + {member.lastName[0]} + + {member.firstName} {member.lastName} + + + + + {member.email} + + ))} + {showAddMemberButton && hasFeatureFlag("ADD_NEW_EDITOR") && ( + + + setShowModal(true)}> + Add a new editor + + + + )} + + + + {showModal && ( + + )} + ); }; diff --git a/editor.planx.uk/src/pages/FlowEditor/components/Team/components/lib/filterTeamMembers.tsx b/editor.planx.uk/src/pages/FlowEditor/components/Team/components/lib/filterTeamMembers.tsx new file mode 100644 index 0000000000..df727528d9 --- /dev/null +++ b/editor.planx.uk/src/pages/FlowEditor/components/Team/components/lib/filterTeamMembers.tsx @@ -0,0 +1,13 @@ +import { TeamMember } from "../../types"; + +export const hasEmailPresent = (member: TeamMember) => !!member.email; + +export const filterByEmailPresent = (members: TeamMember[]): TeamMember[] => { + return members.filter(hasEmailPresent); +}; + +export const filterExcludingPlatformAdmins = ( + members: TeamMember[], +): TeamMember[] => { + return members.filter((member) => member.role !== "platformAdmin"); +}; diff --git a/editor.planx.uk/src/pages/FlowEditor/components/Team/components/lib/optimisticallyUpdateMembersTable.tsx b/editor.planx.uk/src/pages/FlowEditor/components/Team/components/lib/optimisticallyUpdateMembersTable.tsx new file mode 100644 index 0000000000..c29b2537ce --- /dev/null +++ b/editor.planx.uk/src/pages/FlowEditor/components/Team/components/lib/optimisticallyUpdateMembersTable.tsx @@ -0,0 +1,18 @@ +import { useStore } from "pages/FlowEditor/lib/store"; + +import { AddNewEditorFormValues, TeamMember } from "../../types"; + +export const optimisticallyUpdateMembersTable = async ( + values: AddNewEditorFormValues, + userId: number, +) => { + const newMember: TeamMember = { + ...values, + role: "teamEditor", + id: userId, + }; + + const existingMembers = useStore.getState().teamMembers; + + await useStore.getState().setTeamMembers([...existingMembers, newMember]); +}; diff --git a/editor.planx.uk/src/pages/FlowEditor/components/Team/formSchema.tsx b/editor.planx.uk/src/pages/FlowEditor/components/Team/formSchema.tsx new file mode 100644 index 0000000000..b2bd831c6f --- /dev/null +++ b/editor.planx.uk/src/pages/FlowEditor/components/Team/formSchema.tsx @@ -0,0 +1,7 @@ +import * as Yup from "yup"; + +export const addNewEditorFormSchema = Yup.object({ + firstName: Yup.string().required("Required"), + lastName: Yup.string().required("Required"), + email: Yup.string().email("Invalid email address").required("Required"), +}); diff --git a/editor.planx.uk/src/pages/FlowEditor/components/Team/queries/createAndAddUserToTeam.tsx b/editor.planx.uk/src/pages/FlowEditor/components/Team/queries/createAndAddUserToTeam.tsx new file mode 100644 index 0000000000..46a4161e55 --- /dev/null +++ b/editor.planx.uk/src/pages/FlowEditor/components/Team/queries/createAndAddUserToTeam.tsx @@ -0,0 +1,45 @@ +import { gql } from "@apollo/client"; +import { GET_USERS_FOR_TEAM_QUERY } from "routes/teamMembers"; + +import { client } from "../../../../../lib/graphql"; + +export const createAndAddUserToTeam = async ( + email: string, + firstName: string, + lastName: string, + teamId: number, + teamSlug: string, +) => { + // NB: the user is hard-coded with the 'teamEditor' role for now + const response = (await client.mutate({ + mutation: gql` + mutation CreateAndAddUserToTeam( + $email: String! + $firstName: String! + $lastName: String! + $teamId: Int! + ) { + insert_users_one( + object: { + email: $email + first_name: $firstName + last_name: $lastName + teams: { data: { role: teamEditor, team_id: $teamId } } + } + ) { + id + } + } + `, + variables: { + email, + firstName, + lastName, + teamId, + }, + refetchQueries: [ + { query: GET_USERS_FOR_TEAM_QUERY, variables: { teamSlug } }, + ], + })) as any; + return response.data.insert_users_one; +}; 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 377959aaf7..43dd52fc21 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 @@ -1,27 +1,31 @@ -/* eslint-disable jest/expect-expect */ -import { screen, within } from "@testing-library/react"; + +import { screen, waitFor, within } from "@testing-library/react"; +import { FullStore, useStore } from "pages/FlowEditor/lib/store"; import { vi } from "vitest"; import { setupTeamMembersScreen } from "./helpers/setupTeamMembersScreen"; +import { userEntersInput } from "./helpers/userEntersInput"; vi.mock("lib/featureFlags.ts", () => ({ hasFeatureFlag: vi.fn().mockReturnValue(true), })); -describe("when a user views the Team members screen with the ADD_NEW_EDITOR feature flag enabled", () => { - beforeEach(async () => { - await setupTeamMembersScreen(); - }); +vi.mock( + "pages/FlowEditor/components/Team/queries/createAndAddUserToTeam.tsx", + () => ({ + createAndAddUserToTeam: vi.fn().mockResolvedValue({ + id: 1, + __typename: "users", + }), + }), +); - it("shows the 'add new editor' button", async () => { - const teamEditorsTable = screen.getByTestId("team-editors"); - await within(teamEditorsTable).findByText("Add a new editor"); - }); -}); +let initialState: FullStore; describe("when a user with the ADD_NEW_EDITOR feature flag enabled presses 'add a new editor'", () => { beforeEach(async () => { const user = await setupTeamMembersScreen(); + const teamEditorsTable = screen.getByTestId("team-editors"); const addEditorButton = await within(teamEditorsTable).findByText( "Add a new editor", @@ -34,3 +38,39 @@ describe("when a user with the ADD_NEW_EDITOR feature flag enabled presses 'add expect(await screen.findByLabelText("First name")).toBeVisible(); }); }); + +describe("when a user fills in the 'add a new editor' form correctly", () => { + afterAll(() => useStore.setState(initialState)); + beforeEach(async () => { + const user = await setupTeamMembersScreen(); + const teamEditorsTable = screen.getByTestId("team-editors"); + const addEditorButton = await within(teamEditorsTable).findByText( + "Add a new editor", + ); + user.click(addEditorButton); + const addNewEditorModal = await screen.findByTestId("modal-create-user"); + await userEntersInput("First name", "Mickey", addNewEditorModal); + await userEntersInput("Last name", "Mouse", addNewEditorModal); + await userEntersInput( + "Email address", + "mickeymouse@email.com", + addNewEditorModal, + ); + + const createUserButton = await screen.findByTestId( + "modal-create-user-button", + ); + + user.click(createUserButton); + }); + + it("adds the new user row to the Team Editors table", async () => { + const membersTable = screen.getByTestId("members-table-add-editor"); + + await waitFor(() => { + expect( + within(membersTable).getByText(/Mickey Mouse/), + ).toBeInTheDocument(); + }); + }); +}); diff --git a/editor.planx.uk/src/pages/FlowEditor/components/Team/tests/exampleTeamMembersData.tsx b/editor.planx.uk/src/pages/FlowEditor/components/Team/tests/exampleTeamMembersData.tsx index f9700d87ed..f1b15fd214 100644 --- a/editor.planx.uk/src/pages/FlowEditor/components/Team/tests/exampleTeamMembersData.tsx +++ b/editor.planx.uk/src/pages/FlowEditor/components/Team/tests/exampleTeamMembersData.tsx @@ -1,26 +1,18 @@ -import { Role } from "@opensystemslab/planx-core/types"; - import { TeamMember } from "../types"; -export const exampleTeamMembersData: Record = { - platformAdmin: [ - { - firstName: "Donella", - lastName: "Meadows", - email: "donella@example.com", - id: 1, - role: "platformAdmin", - }, - ], - teamEditor: [ - { - firstName: "Bill", - lastName: "Sharpe", - email: "bill@example.com", - id: 2, - role: "teamEditor", - }, - ], - teamViewer: [], - public: [], -}; +export const exampleTeamMembersData: TeamMember[] = [ + { + firstName: "Donella", + lastName: "Meadows", + email: "donella@example.com", + id: 1, + role: "platformAdmin", + }, + { + firstName: "Bill", + lastName: "Sharpe", + email: "bill@example.com", + id: 2, + role: "teamEditor", + }, +]; diff --git a/editor.planx.uk/src/pages/FlowEditor/components/Team/tests/helpers/setupTeamMembersScreen.tsx b/editor.planx.uk/src/pages/FlowEditor/components/Team/tests/helpers/setupTeamMembersScreen.tsx index 0c80592bc3..a6eea16445 100644 --- a/editor.planx.uk/src/pages/FlowEditor/components/Team/tests/helpers/setupTeamMembersScreen.tsx +++ b/editor.planx.uk/src/pages/FlowEditor/components/Team/tests/helpers/setupTeamMembersScreen.tsx @@ -1,18 +1,21 @@ import { screen } from "@testing-library/react"; +import { 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 { TeamMembers } from "../../TeamMembers"; -import { exampleTeamMembersData } from "./../exampleTeamMembersData"; +import { exampleTeamMembersData } from "../exampleTeamMembersData"; + +export const setupTeamMembersScreen = async () => { + useStore.setState({ teamMembers: exampleTeamMembersData }); -export async function setupTeamMembersScreen() { const { user } = setup( - + , ); await screen.findByText("Team editors"); return user; -} +}; diff --git a/editor.planx.uk/src/pages/FlowEditor/components/Team/tests/helpers/userEntersInput.tsx b/editor.planx.uk/src/pages/FlowEditor/components/Team/tests/helpers/userEntersInput.tsx new file mode 100644 index 0000000000..546955d2c6 --- /dev/null +++ b/editor.planx.uk/src/pages/FlowEditor/components/Team/tests/helpers/userEntersInput.tsx @@ -0,0 +1,13 @@ +import { fireEvent, within } from "@testing-library/react"; + +export const userEntersInput = async ( + labelText: string, + inputString: string, + container: HTMLElement, +) => { + const inputField = await within(container).findByLabelText(labelText); + + fireEvent.change(inputField, { + target: { value: inputString }, + }); +}; 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 725f959212..bf823c5352 100644 --- a/editor.planx.uk/src/pages/FlowEditor/components/Team/types.ts +++ b/editor.planx.uk/src/pages/FlowEditor/components/Team/types.ts @@ -5,15 +5,17 @@ export type TeamMember = Omit & { role: Role; }; -export interface TeamMembersProps { - teamMembersByRole: Record; -} export interface MembersTableProps { members: TeamMember[]; showAddMemberButton?: boolean; - setShowModal?: React.Dispatch>; } -export type AddNewEditorModalProps = { +export interface AddNewEditorModalProps { showModal: boolean; setShowModal: React.Dispatch>; -}; +} + +export interface AddNewEditorFormValues { + email: string; + firstName: string; + lastName: string; +} diff --git a/editor.planx.uk/src/pages/FlowEditor/lib/store/team.ts b/editor.planx.uk/src/pages/FlowEditor/lib/store/team.ts index 99238abce5..24ab3aeb3d 100644 --- a/editor.planx.uk/src/pages/FlowEditor/lib/store/team.ts +++ b/editor.planx.uk/src/pages/FlowEditor/lib/store/team.ts @@ -6,6 +6,7 @@ import { } from "@opensystemslab/planx-core/types"; import gql from "graphql-tag"; import { client } from "lib/graphql"; +import { TeamMember } from "pages/FlowEditor/components/Team/types"; import type { StateCreator } from "zustand"; import { SharedStore } from "./shared"; @@ -17,6 +18,7 @@ export interface TeamStore { teamSettings: TeamSettings; teamSlug: string; teamTheme: TeamTheme; + teamMembers: TeamMember[]; teamDomain: string; setTeam: (team: Team) => void; @@ -27,6 +29,7 @@ export interface TeamStore { updateTeamTheme: (theme: Partial) => Promise; updateTeamSettings: (teamSettings: Partial) => Promise; createTeam: (newTeam: { name: string; slug: string }) => Promise; + setTeamMembers: (teamMembers: TeamMember[]) => Promise; } export const teamStore: StateCreator< @@ -41,6 +44,7 @@ export const teamStore: StateCreator< teamSettings: {} as TeamSettings, teamSlug: "", teamTheme: {} as TeamTheme, + teamMembers: [] as TeamMember[], teamDomain: "", setTeam: (team) => { @@ -67,13 +71,13 @@ export const teamStore: StateCreator< settings: get().teamSettings, slug: get().teamSlug, theme: get().teamTheme, + members: get().teamMembers, domain: get().teamDomain, }), createTeam: async (newTeam) => { const { $client } = get(); - const isSuccess = await $client.team.create(newTeam); - return isSuccess; + return await $client.team.create(newTeam); }, initTeamStore: async (slug) => { @@ -122,6 +126,7 @@ export const teamStore: StateCreator< teamSettings: undefined, teamSlug: "", teamTheme: undefined, + teamMembers: [], }), /** @@ -130,22 +135,20 @@ export const teamStore: StateCreator< */ fetchCurrentTeam: async () => { const { teamSlug, $client } = get(); - const team = await $client.team.getBySlug(teamSlug); - return team; + return await $client.team.getBySlug(teamSlug); }, updateTeamTheme: async (theme: Partial) => { const { teamId, $client } = get(); - const isSuccess = await $client.team.updateTheme(teamId, theme); - return isSuccess; + return await $client.team.updateTheme(teamId, theme); }, updateTeamSettings: async (teamSettings: Partial) => { const { teamId, $client } = get(); - const isSuccess = await $client.team.updateTeamSettings( - teamId, - teamSettings, - ); - return isSuccess; + return await $client.team.updateTeamSettings(teamId, teamSettings); + }, + + setTeamMembers: async (teamMembers: TeamMember[]) => { + set(() => ({ teamMembers })); }, }); diff --git a/editor.planx.uk/src/routes/teamMembers.tsx b/editor.planx.uk/src/routes/teamMembers.tsx index 331d2ab6ea..c36d7999cc 100644 --- a/editor.planx.uk/src/routes/teamMembers.tsx +++ b/editor.planx.uk/src/routes/teamMembers.tsx @@ -1,6 +1,5 @@ -import { Role, User } from "@opensystemslab/planx-core/types"; +import { User } from "@opensystemslab/planx-core/types"; import gql from "graphql-tag"; -import { groupBy } from "lodash"; import { compose, mount, NotFoundError, route, withData } from "navi"; import { TeamMembers } from "pages/FlowEditor/components/Team/TeamMembers"; import { TeamMember } from "pages/FlowEditor/components/Team/types"; @@ -10,10 +9,33 @@ import React from "react"; import { client } from "../lib/graphql"; import { makeTitle } from "./utils"; -interface GetUsersForTeam { +export interface GetUsersForTeam { users: User[]; } +export const GET_USERS_FOR_TEAM_QUERY = gql` + query GetUsersForTeam($teamSlug: String!) { + users( + where: { + _or: [ + { is_platform_admin: { _eq: true } } + { teams: { team: { slug: { _eq: $teamSlug } } } } + ] + } + order_by: { first_name: asc } + ) { + id + firstName: first_name + lastName: last_name + isPlatformAdmin: is_platform_admin + email + teams(where: { team: { slug: { _eq: $teamSlug } } }) { + role + } + } + } +`; + const teamMembersRoutes = compose( withData((req) => ({ mountpath: req.mountpath, @@ -32,28 +54,7 @@ const teamMembersRoutes = compose( const { data: { users }, } = await client.query({ - query: gql` - query GetUsersForTeam($teamSlug: String!) { - users( - where: { - _or: [ - { is_platform_admin: { _eq: true } } - { teams: { team: { slug: { _eq: $teamSlug } } } } - ] - } - order_by: { first_name: asc } - ) { - id - firstName: first_name - lastName: last_name - isPlatformAdmin: is_platform_admin - email - teams(where: { team: { slug: { _eq: $teamSlug } } }) { - role - } - } - } - `, + query: GET_USERS_FOR_TEAM_QUERY, variables: { teamSlug }, }); @@ -65,14 +66,11 @@ const teamMembersRoutes = compose( role: user.isPlatformAdmin ? "platformAdmin" : user.teams[0].role, })); - const teamMembersByRole = groupBy(teamMembers, "role") as Record< - Role, - TeamMember[] - >; + await useStore.getState().setTeamMembers(teamMembers); return { title: makeTitle("Team Members"), - view: , + view: , }; }), }), From aa6ecf7dd68da558fbac746bdbdb32ad41c33091 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 27 Aug 2024 09:14:01 +0100 Subject: [PATCH 6/7] [skip pizza] Bump micromatch from 4.0.7 to 4.0.8 in /hasura.planx.uk/tests (#3560) Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- hasura.planx.uk/tests/pnpm-lock.yaml | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/hasura.planx.uk/tests/pnpm-lock.yaml b/hasura.planx.uk/tests/pnpm-lock.yaml index 2aa8871dcd..781745a677 100644 --- a/hasura.planx.uk/tests/pnpm-lock.yaml +++ b/hasura.planx.uk/tests/pnpm-lock.yaml @@ -431,7 +431,7 @@ packages: jest-util: 29.7.0 jest-validate: 29.7.0 jest-watcher: 29.7.0 - micromatch: 4.0.7 + micromatch: 4.0.8 pretty-format: 29.7.0 slash: 3.0.0 strip-ansi: 6.0.1 @@ -580,7 +580,7 @@ packages: jest-haste-map: 29.7.0 jest-regex-util: 29.6.3 jest-util: 29.7.0 - micromatch: 4.0.7 + micromatch: 4.0.8 pirates: 4.0.6 slash: 3.0.0 write-file-atomic: 4.0.2 @@ -1447,7 +1447,7 @@ packages: jest-runner: 29.7.0 jest-util: 29.7.0 jest-validate: 29.7.0 - micromatch: 4.0.7 + micromatch: 4.0.8 parse-json: 5.2.0 pretty-format: 29.7.0 slash: 3.0.0 @@ -1515,7 +1515,7 @@ packages: jest-regex-util: 29.6.3 jest-util: 29.7.0 jest-worker: 29.7.0 - micromatch: 4.0.7 + micromatch: 4.0.8 walker: 1.0.8 optionalDependencies: fsevents: 2.3.3 @@ -1548,7 +1548,7 @@ packages: '@types/stack-utils': 2.0.3 chalk: 4.1.2 graceful-fs: 4.2.11 - micromatch: 4.0.7 + micromatch: 4.0.8 pretty-format: 29.7.0 slash: 3.0.0 stack-utils: 2.0.6 @@ -1859,8 +1859,8 @@ packages: resolution: {integrity: sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==} dev: false - /micromatch@4.0.7: - resolution: {integrity: sha512-LPP/3KorzCwBxfeUuZmaR6bG2kdeHSbe0P2tY3FLRU4vYrjYz5hI4QZwV0njUx3jeuKe67YukQ1LSPZBKDqO/Q==} + /micromatch@4.0.8: + resolution: {integrity: sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==} engines: {node: '>=8.6'} dependencies: braces: 3.0.3 From bad5a379a37c94843a7b33bbbd0c6beed6413857 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dafydd=20Ll=C5=B7r=20Pearson?= Date: Tue, 27 Aug 2024 09:22:52 +0100 Subject: [PATCH 7/7] test: Basic tests describing the "search" feature (#3566) --- .../Search/ExternalPortalList.test.tsx | 50 ++++++++++ .../Sidebar/Search/Headline.test.tsx | 44 +++++++++ .../Sidebar/Search/NodeSearchResults.test.tsx | 39 ++++++++ .../components/Sidebar/Search/index.test.tsx | 94 +++++++++++++++++++ .../components/Sidebar/Search/index.tsx | 1 + .../components/Sidebar/Search/mocks/simple.ts | 66 +++++++++++++ 6 files changed, 294 insertions(+) create mode 100644 editor.planx.uk/src/pages/FlowEditor/components/Sidebar/Search/ExternalPortalList.test.tsx create mode 100644 editor.planx.uk/src/pages/FlowEditor/components/Sidebar/Search/Headline.test.tsx create mode 100644 editor.planx.uk/src/pages/FlowEditor/components/Sidebar/Search/NodeSearchResults.test.tsx create mode 100644 editor.planx.uk/src/pages/FlowEditor/components/Sidebar/Search/index.test.tsx create mode 100644 editor.planx.uk/src/pages/FlowEditor/components/Sidebar/Search/mocks/simple.ts 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.test.tsx new file mode 100644 index 0000000000..d5b8cc2e5b --- /dev/null +++ b/editor.planx.uk/src/pages/FlowEditor/components/Sidebar/Search/ExternalPortalList.test.tsx @@ -0,0 +1,50 @@ +import { FullStore, useStore } from "pages/FlowEditor/lib/store"; +import React from "react"; +import { act } from "react-dom/test-utils"; +import { setup } from "testUtils"; +import { axe } from "vitest-axe"; + +import { ExternalPortalList } from "./ExternalPortalList"; + +const { getState, setState } = useStore; + +let initialState: FullStore; + +beforeAll(() => (initialState = getState())); +afterEach(() => act(() => setState(initialState))); + +const externalPortals: FullStore["externalPortals"] = { + abc: { name: "Portal 1", href: "myTeam/portalOne" }, + def: { name: "Portal 2", href: "myTeam/portalTwo" }, +}; + +it("does not display if there are no external portals in the flow", () => { + const { container } = setup(); + + expect(container).toBeEmptyDOMElement(); +}); + +it("displays a list of external portals if present in the flow", () => { + act(() => setState({ externalPortals })); + const { container, getAllByRole } = setup(); + + expect(container).not.toBeEmptyDOMElement(); + expect(getAllByRole("listitem")).toHaveLength(2); +}); + +it("allows users to navigate to the external portals", () => { + act(() => setState({ externalPortals })); + const { container, getAllByRole } = setup(); + + expect(container).not.toBeEmptyDOMElement(); + const [first, second] = getAllByRole("link") as HTMLAnchorElement[]; + expect(first).toHaveAttribute("href", "../myTeam/portalOne"); + expect(second).toHaveAttribute("href", "../myTeam/portalTwo"); +}); + +it("should not have any accessibility violations on initial load", async () => { + act(() => setState({ externalPortals })); + const { container } = setup(); + const results = await axe(container); + expect(results).toHaveNoViolations(); +}); diff --git a/editor.planx.uk/src/pages/FlowEditor/components/Sidebar/Search/Headline.test.tsx b/editor.planx.uk/src/pages/FlowEditor/components/Sidebar/Search/Headline.test.tsx new file mode 100644 index 0000000000..b026dcad2d --- /dev/null +++ b/editor.planx.uk/src/pages/FlowEditor/components/Sidebar/Search/Headline.test.tsx @@ -0,0 +1,44 @@ +import React from "react"; +import { setup } from "testUtils"; +import { FONT_WEIGHT_BOLD } from "theme"; +import { axe } from "vitest-axe"; + +import { Headline } from "./Headline"; + +const sampleText = "The quick fox jumps..."; +const foxIndices: [number, number][] = [[10, 13]]; + +const DEFAULT_FONT_WEIGHT = "400"; + +it("displays matches from the headline in bold", () => { + const { getByText } = setup( + , + ); + + // Input text is split into characters in order to highlight a substring + const tStyle = window.getComputedStyle(getByText("T")); + const hStyle = window.getComputedStyle(getByText("h")); + const eStyle = window.getComputedStyle(getByText("e")); + + // Non matching text is not in bold + expect(tStyle.fontWeight).toEqual(DEFAULT_FONT_WEIGHT); + expect(hStyle.fontWeight).toEqual(DEFAULT_FONT_WEIGHT); + expect(eStyle.fontWeight).toEqual(DEFAULT_FONT_WEIGHT); + + const fStyle = window.getComputedStyle(getByText("f")); + const oStyle = window.getComputedStyle(getByText("o")); + const xStyle = window.getComputedStyle(getByText("x")); + + // Matching text is in bold + expect(fStyle.fontWeight).toEqual(FONT_WEIGHT_BOLD); + expect(oStyle.fontWeight).toEqual(FONT_WEIGHT_BOLD); + expect(xStyle.fontWeight).toEqual(FONT_WEIGHT_BOLD); +}); + +it("should not have any accessibility violations on initial load", async () => { + const { container } = setup( + , + ); + const results = await axe(container); + expect(results).toHaveNoViolations(); +}); diff --git a/editor.planx.uk/src/pages/FlowEditor/components/Sidebar/Search/NodeSearchResults.test.tsx b/editor.planx.uk/src/pages/FlowEditor/components/Sidebar/Search/NodeSearchResults.test.tsx new file mode 100644 index 0000000000..042d9b4755 --- /dev/null +++ b/editor.planx.uk/src/pages/FlowEditor/components/Sidebar/Search/NodeSearchResults.test.tsx @@ -0,0 +1,39 @@ +import { useStore } from "pages/FlowEditor/lib/store"; +import React from "react"; +import { setup } from "testUtils"; +import { axe } from "vitest-axe"; + +import { flow, results } from "./mocks/simple"; +import { NodeSearchResults } from "./NodeSearchResults"; + +beforeAll(() => useStore.setState({ flow })); + +it("Displays a warning if no results are returned", () => { + const { getByText, getByRole } = setup(); + expect(getByText("No matches found")).toBeInTheDocument(); + expect(getByRole("list")).toBeEmptyDOMElement(); +}); + +it("Displays the count for a single result", () => { + const { getByText, getByRole, getAllByRole } = setup( + , + ); + expect(getByText("1 result:")).toBeInTheDocument(); + expect(getByRole("list")).not.toBeEmptyDOMElement(); + expect(getAllByRole("listitem")).toHaveLength(1); +}); + +it("Displays the count for multiple results", () => { + const { getByText, getByRole, getAllByRole } = setup( + , + ); + expect(getByText("2 results:")).toBeInTheDocument(); + expect(getByRole("list")).not.toBeEmptyDOMElement(); + expect(getAllByRole("listitem")).toHaveLength(2); +}); + +it("should not have any accessibility violations on initial load", async () => { + const { container } = setup(); + const axeResults = await axe(container); + expect(axeResults).toHaveNoViolations(); +}); diff --git a/editor.planx.uk/src/pages/FlowEditor/components/Sidebar/Search/index.test.tsx b/editor.planx.uk/src/pages/FlowEditor/components/Sidebar/Search/index.test.tsx new file mode 100644 index 0000000000..a8b6e0bc74 --- /dev/null +++ b/editor.planx.uk/src/pages/FlowEditor/components/Sidebar/Search/index.test.tsx @@ -0,0 +1,94 @@ +import * as planxCore from "@opensystemslab/planx-core"; +import { waitFor } from "@testing-library/react"; +import { FullStore, useStore } from "pages/FlowEditor/lib/store"; +import React from "react"; +import { act } from "react-dom/test-utils"; +import { setup } from "testUtils"; +import { vi } from "vitest"; +import { axe } from "vitest-axe"; + +import Search from "."; +import { flow } from "./mocks/simple"; + +const { setState, getState } = useStore; + +let initialState: FullStore; + +beforeAll(() => (initialState = getState())); + +beforeEach(() => setState({ flow })); +afterEach(() => act(() => setState(initialState))); + +vi.mock("@opensystemslab/planx-core", async (originalModule) => { + const actualModule = await originalModule(); + return { + ...actualModule, + // Spy on sortFlow while keeping its original implementation + sortFlow: vi.fn(actualModule.sortFlow), + }; +}); + +test("data field checkbox is checked and disabled", () => { + const { getByLabelText } = setup(); + const checkbox = getByLabelText("Search only data fields"); + + expect(checkbox).toBeInTheDocument(); + expect(checkbox).toBeChecked(); + expect(checkbox).toBeDisabled(); +}); + +test("entering a search term displays a series of cards", async () => { + const { user, queryByRole, getByRole, getAllByRole, getByLabelText } = setup( + , + ); + + expect(queryByRole("list")).not.toBeInTheDocument(); + + const searchInput = getByLabelText("Search this flow and internal portals"); + user.type(searchInput, "ind"); + + await waitFor(() => expect(getByRole("list")).toBeInTheDocument()); + await waitFor(() => expect(getAllByRole("listitem")).toHaveLength(2)); +}); + +test.todo("cards link to their associated nodes", async () => { + const { user, getAllByRole, getByLabelText } = setup(); + + const searchInput = getByLabelText("Search this flow and internal portals"); + user.type(searchInput, "ind"); + + await waitFor(() => expect(getAllByRole("listitem")).toHaveLength(2)); + + const [first, second] = getAllByRole("listitem"); + // TODO! + expect(first).toHaveAttribute("href", "link to tR9tdaWOvF (India)"); + expect(second).toHaveAttribute("href", "link to tvUxd2IoPo (Indonesia)"); +}); + +it("orderedFlow is set in the store on render of Search", async () => { + expect(getState().orderedFlow).toBeUndefined(); + + setup(); + + expect(getState().orderedFlow).toBeDefined(); +}); + +test("setOrderedFlow is only called once on initial render", async () => { + const sortFlowSpy = vi.spyOn(planxCore, "sortFlow"); + expect(sortFlowSpy).not.toHaveBeenCalled(); + + const { user, getAllByRole, getByLabelText } = setup(); + + const searchInput = getByLabelText("Search this flow and internal portals"); + user.type(searchInput, "ind"); + + await waitFor(() => expect(getAllByRole("listitem")).toHaveLength(2)); + + expect(sortFlowSpy).toHaveBeenCalledTimes(1); +}); + +it("should not have any accessibility violations on initial load", async () => { + const { container } = setup(); + const results = await axe(container); + expect(results).toHaveNoViolations(); +}); 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 a7f9a825f5..9aaecb84de 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 @@ -51,6 +51,7 @@ const Search: React.FC = () => { Search this flow and internal portals { diff --git a/editor.planx.uk/src/pages/FlowEditor/components/Sidebar/Search/mocks/simple.ts b/editor.planx.uk/src/pages/FlowEditor/components/Sidebar/Search/mocks/simple.ts new file mode 100644 index 0000000000..275c397f34 --- /dev/null +++ b/editor.planx.uk/src/pages/FlowEditor/components/Sidebar/Search/mocks/simple.ts @@ -0,0 +1,66 @@ +import { FlowGraph, IndexedNode } from "@opensystemslab/planx-core/types"; +import { SearchResults } from "hooks/useSearch"; + +export const flow: FlowGraph = { + _root: { + edges: ["Ej0xpn4l8u"], + }, + Ej0xpn4l8u: { + type: 100, + data: { + fn: "country", + text: "Pick a country", + }, + edges: ["VhSydY2fTe", "tR9tdaWOvF", "tvUxd2IoPo"], + }, + VhSydY2fTe: { + type: 200, + data: { + text: "Spain", + val: "spain", + }, + }, + tR9tdaWOvF: { + type: 200, + data: { + text: "India", + val: "india", + }, + }, + tvUxd2IoPo: { + type: 200, + data: { + text: "Indonesia", + val: "indonesia", + }, + }, +}; + +export const results: SearchResults = [ + { + item: { + id: "tR9tdaWOvF", + parentId: "Ej0xpn4l8u", + type: 200, + data: { + text: "India", + val: "india", + }, + }, + key: "data.val", + matchIndices: [[0, 2]], + }, + { + item: { + id: "tvUxd2IoPo", + parentId: "Ej0xpn4l8u", + type: 200, + data: { + text: "Indonesia", + val: "indonesia", + }, + }, + key: "data.val", + matchIndices: [[0, 2]], + }, +];