diff --git a/editor.planx.uk/package.json b/editor.planx.uk/package.json index 65f0b04c73..e58ac8ab55 100644 --- a/editor.planx.uk/package.json +++ b/editor.planx.uk/package.json @@ -14,7 +14,7 @@ "@mui/material": "^5.15.10", "@mui/utils": "^5.15.11", "@opensystemslab/map": "^0.8.3", - "@opensystemslab/planx-core": "git+https://github.com/theopensystemslab/planx-core#dd24b0a", + "@opensystemslab/planx-core": "git+https://github.com/theopensystemslab/planx-core#0336fd4", "@tiptap/core": "^2.4.0", "@tiptap/extension-bold": "^2.0.3", "@tiptap/extension-bubble-menu": "^2.1.13", @@ -51,6 +51,7 @@ "dompurify": "^3.0.6", "dotenv": "^16.4.5", "formik": "^2.4.5", + "fuse.js": "^7.0.0", "graphql": "^16.8.1", "graphql-tag": "^2.12.6", "immer": "^9.0.21", diff --git a/editor.planx.uk/pnpm-lock.yaml b/editor.planx.uk/pnpm-lock.yaml index a690673baa..b8e8c27e9c 100644 --- a/editor.planx.uk/pnpm-lock.yaml +++ b/editor.planx.uk/pnpm-lock.yaml @@ -46,8 +46,8 @@ dependencies: specifier: ^0.8.3 version: 0.8.3 '@opensystemslab/planx-core': - specifier: git+https://github.com/theopensystemslab/planx-core#dd24b0a - version: github.com/theopensystemslab/planx-core/dd24b0a(@types/react@18.2.45) + specifier: git+https://github.com/theopensystemslab/planx-core#0336fd4 + version: github.com/theopensystemslab/planx-core/0336fd4(@types/react@18.2.45) '@tiptap/core': specifier: ^2.4.0 version: 2.4.0(@tiptap/pm@2.0.3) @@ -156,6 +156,9 @@ dependencies: formik: specifier: ^2.4.5 version: 2.4.5(react@18.2.0) + fuse.js: + specifier: ^7.0.0 + version: 7.0.0 graphql: specifier: ^16.8.1 version: 16.8.1 @@ -6846,7 +6849,7 @@ packages: '@storybook/preview-api': 7.6.7 '@storybook/theming': 7.6.7(react-dom@18.2.0)(react@18.2.0) '@storybook/types': 7.6.7 - '@types/lodash': 4.14.202 + '@types/lodash': 4.17.7 color-convert: 2.0.1 dequal: 2.0.3 lodash: 4.17.21 @@ -6867,11 +6870,11 @@ packages: - supports-color dev: true - /@storybook/builder-manager@8.1.10(prettier@3.0.0): + /@storybook/builder-manager@8.1.10(prettier@3.3.3): resolution: {integrity: sha512-dhg54zpaglR9XKNAiwMqm5/IONMCEG/hO/iTfNHJI1rAGeWhvM71cmhF+VlKUcjpTlIfHe7J19+TL+sWQJNgtg==} dependencies: '@fal-works/esbuild-plugin-global-externals': 2.1.2 - '@storybook/core-common': 8.1.10(prettier@3.0.0) + '@storybook/core-common': 8.1.10(prettier@3.3.3) '@storybook/manager': 8.1.10 '@storybook/node-logger': 8.1.10 '@types/ejs': 3.1.5 @@ -6975,12 +6978,12 @@ packages: '@babel/types': 7.25.0 '@ndelangen/get-tarball': 3.0.9 '@storybook/codemod': 8.1.10 - '@storybook/core-common': 8.1.10(prettier@3.0.0) + '@storybook/core-common': 8.1.10(prettier@3.3.3) '@storybook/core-events': 8.1.10 - '@storybook/core-server': 8.1.10(prettier@3.0.0)(react-dom@18.2.0)(react@18.2.0) + '@storybook/core-server': 8.1.10(prettier@3.3.3)(react-dom@18.2.0)(react@18.2.0) '@storybook/csf-tools': 8.1.10 '@storybook/node-logger': 8.1.10 - '@storybook/telemetry': 8.1.10(prettier@3.0.0) + '@storybook/telemetry': 8.1.10(prettier@3.3.3) '@storybook/types': 8.1.10 '@types/semver': 7.5.8 '@yarnpkg/fslib': 2.10.3 @@ -7119,7 +7122,7 @@ packages: - supports-color dev: true - /@storybook/core-common@8.1.10(prettier@3.0.0): + /@storybook/core-common@8.1.10(prettier@3.3.3): resolution: {integrity: sha512-+0GhgDRQwUlXu1lY77NdLnVBVycCEW0DG7eu7rvLYYkTyNRxbdl2RWsQpjr/j4sxqT6u82l9/b+RWpmsl4MgMQ==} peerDependencies: prettier: ^2 || ^3 @@ -7148,8 +7151,8 @@ packages: node-fetch: 2.7.0 picomatch: 2.3.1 pkg-dir: 5.0.0 - prettier: 3.0.0 - prettier-fallback: /prettier@3.0.0 + prettier: 3.3.3 + prettier-fallback: /prettier@3.3.3 pretty-hrtime: 1.0.3 resolve-from: 5.0.0 semver: 7.6.3 @@ -7181,16 +7184,16 @@ packages: ts-dedent: 2.2.0 dev: true - /@storybook/core-server@8.1.10(prettier@3.0.0)(react-dom@18.2.0)(react@18.2.0): + /@storybook/core-server@8.1.10(prettier@3.3.3)(react-dom@18.2.0)(react@18.2.0): resolution: {integrity: sha512-jNL5/daNyo7Rcu+y/bOmSB1P65pmcaLwvpr31EUEIISaAqvgruaneS3GKHg2TR0wcxEoHaM4abqhW6iwkI/XYQ==} dependencies: '@aw-web-design/x-default-browser': 1.4.126 '@babel/core': 7.24.9 '@babel/parser': 7.25.0 '@discoveryjs/json-ext': 0.5.7 - '@storybook/builder-manager': 8.1.10(prettier@3.0.0) + '@storybook/builder-manager': 8.1.10(prettier@3.3.3) '@storybook/channels': 8.1.10 - '@storybook/core-common': 8.1.10(prettier@3.0.0) + '@storybook/core-common': 8.1.10(prettier@3.3.3) '@storybook/core-events': 8.1.10 '@storybook/csf': 0.1.11 '@storybook/csf-tools': 8.1.10 @@ -7200,7 +7203,7 @@ packages: '@storybook/manager-api': 8.1.10(react-dom@18.2.0)(react@18.2.0) '@storybook/node-logger': 8.1.10 '@storybook/preview-api': 8.1.10 - '@storybook/telemetry': 8.1.10(prettier@3.0.0) + '@storybook/telemetry': 8.1.10(prettier@3.3.3) '@storybook/types': 8.1.10 '@types/detect-port': 1.3.5 '@types/diff': 5.2.1 @@ -7642,11 +7645,11 @@ packages: qs: 6.12.3 dev: true - /@storybook/telemetry@8.1.10(prettier@3.0.0): + /@storybook/telemetry@8.1.10(prettier@3.3.3): resolution: {integrity: sha512-pwiMWrq85D0AnaAgYNfB2w2BDgqnetQ+tXwsUAw4fUEFwA4oPU6r0uqekRbNNE6wmSSYjiiFP3JgknBFqjd2hg==} dependencies: '@storybook/client-logger': 8.1.10 - '@storybook/core-common': 8.1.10(prettier@3.0.0) + '@storybook/core-common': 8.1.10(prettier@3.3.3) '@storybook/csf-tools': 8.1.10 chalk: 4.1.2 detect-package-manager: 2.0.1 @@ -8611,7 +8614,6 @@ packages: /@types/lodash@4.17.7: resolution: {integrity: sha512-8wTvZawATi/lsmNu10/j2hk1KEP0IvjubqPE3cu1Xz7xfXXt5oCq3SNUz4fMIP4XGF9Ky+Ue2tBA3hcS7LSBlA==} - dev: false /@types/mdast@3.0.15: resolution: {integrity: sha512-LnwD+mUEfxWMa1QpDraczIn6k0Ee3SMicuYSSzS6ZYl2gKS09EClnJYGd8Du6rfc5r/GZEk5o1mRb8TaTj03sQ==} @@ -10701,7 +10703,7 @@ packages: dev: false /concat-map@0.0.1: - resolution: {integrity: sha1-2Klr13/Wjfd5OnMDajug1UBdR3s=} + resolution: {integrity: sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==} /confbox@0.1.7: resolution: {integrity: sha512-uJcB/FKZtBMCJpK8MQji6bJHgu1tixKPxRLeGkNzBoOZzpnZUJm0jm2/sBDWcuBx1dYgxV4JU+g5hmNxCyAmdA==} @@ -13174,6 +13176,11 @@ packages: /functions-have-names@1.2.3: resolution: {integrity: sha512-xckBUXyTIqT97tq2x2AMb+g163b5JFysYk0x4qxNFwbfQkmNZoiRHb6sPzI9/QV33WeuvVYBUIiD4NzNIyqaRQ==} + /fuse.js@7.0.0: + resolution: {integrity: sha512-14F4hBIxqKvD4Zz/XjDc3y94mNZN6pRv3U13Udo0lNLCWRBUsrMv2xwcF/y/Z5sV6+FQW+/ow68cHpm4sunt8Q==} + engines: {node: '>=10'} + dev: false + /gensync@1.0.0-beta.2: resolution: {integrity: sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==} engines: {node: '>=6.9.0'} @@ -21891,9 +21898,9 @@ packages: use-sync-external-store: 1.2.0(react@18.2.0) dev: false - github.com/theopensystemslab/planx-core/dd24b0a(@types/react@18.2.45): - resolution: {tarball: https://codeload.github.com/theopensystemslab/planx-core/tar.gz/dd24b0a} - id: github.com/theopensystemslab/planx-core/dd24b0a + github.com/theopensystemslab/planx-core/0336fd4(@types/react@18.2.45): + resolution: {tarball: https://codeload.github.com/theopensystemslab/planx-core/tar.gz/0336fd4} + id: github.com/theopensystemslab/planx-core/0336fd4 name: '@opensystemslab/planx-core' version: 1.0.0 prepare: true diff --git a/editor.planx.uk/src/hooks/useSearch.ts b/editor.planx.uk/src/hooks/useSearch.ts new file mode 100644 index 0000000000..410473237f --- /dev/null +++ b/editor.planx.uk/src/hooks/useSearch.ts @@ -0,0 +1,53 @@ +import Fuse, { IFuseOptions } from "fuse.js"; +import { useEffect, useMemo, useState } from "react"; + +interface UseSearchProps { + list: T[]; + keys: string[]; +} + +export interface SearchResult { + item: T; + key: string; + matchIndices?: [number, number][]; +} + +export type SearchResults = SearchResult[]; + +export const useSearch = ({ + list, + keys, +}: UseSearchProps) => { + const [pattern, setPattern] = useState(""); + const [results, setResults] = useState>([]); + + const fuseOptions: IFuseOptions = useMemo( + () => ({ + useExtendedSearch: true, + includeMatches: true, + minMatchCharLength: 3, + keys, + }), + [keys], + ); + + const fuse = useMemo( + () => new Fuse(list, fuseOptions), + [list, fuseOptions], + ); + + useEffect(() => { + const fuseResults = fuse.search(pattern); + setResults( + fuseResults.map((result) => ({ + item: result.item, + key: result.matches?.[0].key || "", + // We only display the first match + matchIndices: + (result.matches?.[0].indices as [number, number][]) || undefined, + })), + ); + }, [pattern]); + + return { results, search: setPattern }; +}; diff --git a/editor.planx.uk/src/pages/FlowEditor/components/Flow/components/Portal.tsx b/editor.planx.uk/src/pages/FlowEditor/components/Flow/components/Portal.tsx index db541551ba..5e00ef5a2d 100644 --- a/editor.planx.uk/src/pages/FlowEditor/components/Flow/components/Portal.tsx +++ b/editor.planx.uk/src/pages/FlowEditor/components/Flow/components/Portal.tsx @@ -14,7 +14,10 @@ import Hanger from "./Hanger"; import Question from "./Question"; const ExternalPortal: React.FC = (props) => { - const copyNode = useStore((state) => state.copyNode); + const [copyNode, addExternalPortal] = useStore((state) => [ + state.copyNode, + state.addExternalPortal, + ]); const [href, setHref] = useState("Loading..."); const { data, loading } = useQuery( @@ -23,6 +26,7 @@ const ExternalPortal: React.FC = (props) => { flows_by_pk(id: $id) { id slug + name team { slug } @@ -31,8 +35,17 @@ const ExternalPortal: React.FC = (props) => { `, { variables: { id: props.data.flowId }, - onCompleted: (data) => - setHref([data.flows_by_pk.team.slug, data.flows_by_pk.slug].join("/")), + onCompleted: (data) => { + const href = [data.flows_by_pk.team.slug, data.flows_by_pk.slug].join( + "/", + ); + setHref(href); + addExternalPortal({ + id: props.data.flowId, + name: data.flows_by_pk.name, + href, + }); + }, }, ); diff --git a/editor.planx.uk/src/pages/FlowEditor/components/Sidebar/Search.tsx b/editor.planx.uk/src/pages/FlowEditor/components/Sidebar/Search.tsx index 3d130a8584..e46d7b5e59 100644 --- a/editor.planx.uk/src/pages/FlowEditor/components/Sidebar/Search.tsx +++ b/editor.planx.uk/src/pages/FlowEditor/components/Sidebar/Search.tsx @@ -1,75 +1,145 @@ 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 } from "@opensystemslab/planx-core/types"; +import { ComponentType, IndexedNode } from "@opensystemslab/planx-core/types"; import { ICONS } from "@planx/components/ui"; -import { debounce } from "lodash"; +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, { ChangeEvent, useCallback, useState } from "react"; -import useSWR from "swr"; -import { FONT_WEIGHT_SEMI_BOLD } from "theme"; -import InputLabel from "ui/editor/InputLabel"; +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 mockData: SearchResult[] = [ - { - nodeId: "abc123", - nodeType: ComponentType.Question, - nodeTitle: "Is the property in Lambeth?", - text: "Lambeth example biodiversity text", - path: ["_root", "xyz123", "abc123"], - }, - { - nodeId: "abc456", - nodeType: ComponentType.Notice, - nodeTitle: "It looks like the property is not in Lambeth", - text: "Lambeth example biodiversity text", - path: ["_root", "xyz123", "abc123"], - }, - { - nodeId: "abc789", - nodeType: ComponentType.Question, - nodeTitle: "What are you applying about?", - text: "Lambeth example biodiversity text", - path: ["_root", "xyz123", "abc123"], - }, -]; - -export interface SearchResult { - nodeId: string; - nodeType: ComponentType; - nodeTitle?: string; +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; - path: string[]; + matchIndices: [number, number][]; + variant: "data"; } -const SearchResults: React.FC<{ results: SearchResult[] }> = ({ results }) => { +const Headline: React.FC = ({ text, matchIndices, variant }) => { + const isHighlighted = (index: number) => + matchIndices.some(([start, end]) => index >= start && index <= end); + return ( - - {results.map((result) => ( - + <> + {text.split("").map((char, index) => ( + + {char} + ))} - + ); }; -const SearchResultCard: React.FC = ({ - nodeTitle, - text, - nodeType, +const SearchResultCard: React.FC<{ result: SearchResult }> = ({ + result, }) => { - const Icon = ICONS[nodeType]; + 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 ( - ({ - pb: 2, - borderBottom: `1px solid ${theme.palette.border.main}`, - })} - > + {Icon && } = ({ fontSize={14} fontWeight={FONT_WEIGHT_SEMI_BOLD} > - Question - {nodeTitle && ` - ${nodeTitle}`} + {componentType} + {title && ( + + {` • ${title}`} + + )} - {text} + + {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 [flowId] = useStore((state) => [state.id]); - const [query, setQuery] = useState(""); - const [debouncedQuery, setDebouncedQuery] = useState(query); + const [orderedFlow, setOrderedFlow] = useStore((state) => [ + state.orderedFlow, + state.setOrderedFlow, + ]); - const debounceSearch = useCallback( - debounce((input) => setDebouncedQuery(input), 500), - [], - ); + useEffect(() => { + if (!orderedFlow) setOrderedFlow(); + }, [setOrderedFlow]); - const handleChange = (e: ChangeEvent) => { - const input = e.target.value; - setQuery(input); - debounceSearch(input); - }; + const formik = useFormik({ + initialValues: { input: "", facets: ["data.fn", "data.val"] }, + onSubmit: ({ input }) => { + search(input); + }, + }); - const fetcher = (url: string) => fetch(url).then((r) => r.json()); - const endpoint = `${process.env.REACT_APP_API_URL}/flows/${flowId}/search`; - const { error } = useSWR( - debouncedQuery ? `${endpoint}?find=${debouncedQuery}` : null, - fetcher, - ); + const { results, search } = useSearch({ + list: orderedFlow || [], + keys: formik.values.facets, + }); return ( - - - - {}} - /> - - {error && "Something went wrong"} - {mockData ? : "Loading..."} - +
+ + Search this flow and internal portals + + { + formik.setFieldValue("input", e.target.value); + formik.handleSubmit(); + }} + inputProps={{ spellCheck: false }} + /> + {}} + /> + + {formik.values.input && ( + <> + + + + )} + +
); }; diff --git a/editor.planx.uk/src/pages/FlowEditor/lib/store/editor.ts b/editor.planx.uk/src/pages/FlowEditor/lib/store/editor.ts index 16a452b78a..70542ddd5e 100644 --- a/editor.planx.uk/src/pages/FlowEditor/lib/store/editor.ts +++ b/editor.planx.uk/src/pages/FlowEditor/lib/store/editor.ts @@ -1,4 +1,6 @@ import { gql } from "@apollo/client"; +import { sortFlow } from "@opensystemslab/planx-core"; +import { FlowGraph, OrderedFlow } from "@opensystemslab/planx-core/types"; import { add, clone, @@ -97,6 +99,14 @@ export interface EditorStore extends Store.Store { removeNode: (id: Store.nodeId, parent: Store.nodeId) => void; updateNode: (node: any, relationships?: any) => void; undoOperation: (ops: OT.Op[]) => void; + orderedFlow?: OrderedFlow; + setOrderedFlow: () => void; + externalPortals: Record; + addExternalPortal: (portal: { + id: string; + name: string; + href: string; + }) => void; } export const editorStore: StateCreator< @@ -461,4 +471,20 @@ export const editorStore: StateCreator< const inverseOps: OT.Op[] = type.invert(ops); send(inverseOps); }, + + orderedFlow: undefined, + + setOrderedFlow: () => { + const flow = get().flow as FlowGraph; + const orderedFlow = sortFlow(flow); + set({ orderedFlow }); + }, + + externalPortals: {}, + + addExternalPortal: ({ id, name, href }) => { + const externalPortals = get().externalPortals; + externalPortals[id] = { name, href }; + set({ externalPortals }); + }, }); diff --git a/editor.planx.uk/src/pages/FlowEditor/lib/store/shared.ts b/editor.planx.uk/src/pages/FlowEditor/lib/store/shared.ts index 80ea07475e..f21461b5db 100644 --- a/editor.planx.uk/src/pages/FlowEditor/lib/store/shared.ts +++ b/editor.planx.uk/src/pages/FlowEditor/lib/store/shared.ts @@ -93,7 +93,15 @@ export const sharedStore: StateCreator< }, setFlow({ id, flow, flowSlug, flowName, flowStatus }) { - set({ id, flow, flowSlug, flowName, flowStatus }); + set({ + id, + flow, + flowSlug, + flowName, + flowStatus, + orderedFlow: undefined, + externalPortals: {}, + }); get().initNavigationStore(); },