From 76964b36cf09f1304cb2e3a3e34fd8585a29b69f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dafydd=20Ll=C5=B7r=20Pearson?= Date: Wed, 17 Jul 2024 08:58:37 +0100 Subject: [PATCH 1/8] feat(wip): Initial proof of concept with Fuse.js --- editor.planx.uk/package.json | 1 + editor.planx.uk/pnpm-lock.yaml | 41 ++++--- editor.planx.uk/src/hooks/useSearch.ts | 48 ++++++++ .../FlowEditor/components/Sidebar/Search.tsx | 103 ++++++++---------- 4 files changed, 118 insertions(+), 75 deletions(-) create mode 100644 editor.planx.uk/src/hooks/useSearch.ts diff --git a/editor.planx.uk/package.json b/editor.planx.uk/package.json index 65f0b04c73..7c10dbf5fb 100644 --- a/editor.planx.uk/package.json +++ b/editor.planx.uk/package.json @@ -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..56f581ed7c 100644 --- a/editor.planx.uk/pnpm-lock.yaml +++ b/editor.planx.uk/pnpm-lock.yaml @@ -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'} diff --git a/editor.planx.uk/src/hooks/useSearch.ts b/editor.planx.uk/src/hooks/useSearch.ts new file mode 100644 index 0000000000..1b4488f600 --- /dev/null +++ b/editor.planx.uk/src/hooks/useSearch.ts @@ -0,0 +1,48 @@ +import Fuse, { IFuseOptions } from "fuse.js"; +import { useEffect, useMemo, useState } from "react"; + +const DEFAULT_OPTIONS: Required = { + limit: 20, +}; + +interface SearchOptions { + limit?: number; +} + +interface UseSearchProps> { + list: T[]; + keys: string[]; + options?: SearchOptions; +} + +export const useSearch = >({ + list, + keys, + options, +}: UseSearchProps) => { + const [pattern, setPattern] = useState(""); + const [results, setResults] = useState([]); + + const fuseOptions: IFuseOptions = useMemo( + () => ({ + threshold: 0.3, + useExtendedSearch: true, + keys, + }), + [keys], + ); + + const fuse = useMemo( + () => new Fuse(list, fuseOptions), + [list, fuseOptions], + ); + + useEffect(() => { + const fuseResults = fuse.search(pattern, { + limit: options?.limit || DEFAULT_OPTIONS.limit, + }); + setResults(fuseResults.map((result) => result.item)); + }, [pattern]); + + return { results, search: setPattern }; +}; 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..a128c66e65 100644 --- a/editor.planx.uk/src/pages/FlowEditor/components/Sidebar/Search.tsx +++ b/editor.planx.uk/src/pages/FlowEditor/components/Sidebar/Search.tsx @@ -3,45 +3,20 @@ import Container from "@mui/material/Container"; import Typography from "@mui/material/Typography"; import { ComponentType } from "@opensystemslab/planx-core/types"; import { ICONS } from "@planx/components/ui"; -import { debounce } from "lodash"; -import { useStore } from "pages/FlowEditor/lib/store"; -import React, { ChangeEvent, useCallback, useState } from "react"; -import useSWR from "swr"; +import { useSearch } from "hooks/useSearch"; +import { Store, useStore } from "pages/FlowEditor/lib/store"; +import React, { ChangeEvent, useMemo } from "react"; import { FONT_WEIGHT_SEMI_BOLD } from "theme"; import InputLabel from "ui/editor/InputLabel"; 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 { + id?: Store.nodeId; + type?: ComponentType; + data?: any; + edges?: Store.nodeId[]; nodeId: string; - nodeType: ComponentType; - nodeTitle?: string; - text: string; - path: string[]; } const SearchResults: React.FC<{ results: SearchResult[] }> = ({ results }) => { @@ -56,12 +31,9 @@ const SearchResults: React.FC<{ results: SearchResult[] }> = ({ results }) => { ); }; -const SearchResultCard: React.FC = ({ - nodeTitle, - text, - nodeType, -}) => { - const Icon = ICONS[nodeType]; +// TODO: This likely needs to be related to facets? +const SearchResultCard: React.FC = ({ data, type }) => { + const Icon = ICONS[type!]; return ( = ({ fontWeight={FONT_WEIGHT_SEMI_BOLD} > Question - {nodeTitle && ` - ${nodeTitle}`} + {data.text && ` - ${data.text}`} - {text} + + {data.fn || data.val} + ); }; const Search: React.FC = () => { - const [flowId] = useStore((state) => [state.id]); - const [query, setQuery] = useState(""); - const [debouncedQuery, setDebouncedQuery] = useState(query); - - const debounceSearch = useCallback( - debounce((input) => setDebouncedQuery(input), 500), - [], + const nodes = useStore((state) => state.flow); + // TODO: add to store? + // TODO: think about parentIds + const nodeList = useMemo( + () => + Object.entries(nodes).map(([nodeId, nodeData]) => ({ + nodeId, + ...nodeData, + })), + [nodes], ); + /** Map of search facets to associated node keys */ + const facets = { + data: ["data.fn", "data.val"], + }; + + const { results, search } = useSearch({ + list: nodeList, + keys: facets.data, + }); + const handleChange = (e: ChangeEvent) => { const input = e.target.value; - setQuery(input); - debounceSearch(input); + console.log({ input }); + search(input); + console.log(results); }; - 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, - ); - return ( @@ -124,8 +112,7 @@ const Search: React.FC = () => { onChange={() => {}} /> - {error && "Something went wrong"} - {mockData ? : "Loading..."} + {results ? : "Loading..."} ); From 9948cf0de2855fc21525392fbcfdc32e5bd99724 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dafydd=20Ll=C5=B7r=20Pearson?= Date: Thu, 18 Jul 2024 20:01:51 +0100 Subject: [PATCH 2/8] feat: Use sortFlow() from planx-core --- editor.planx.uk/src/hooks/useSearch.ts | 19 +------ .../FlowEditor/components/Sidebar/Search.tsx | 55 +++++++++---------- .../src/pages/FlowEditor/lib/store/editor.ts | 12 ++++ .../src/pages/FlowEditor/lib/store/shared.ts | 2 +- 4 files changed, 42 insertions(+), 46 deletions(-) diff --git a/editor.planx.uk/src/hooks/useSearch.ts b/editor.planx.uk/src/hooks/useSearch.ts index 1b4488f600..fd173ec36a 100644 --- a/editor.planx.uk/src/hooks/useSearch.ts +++ b/editor.planx.uk/src/hooks/useSearch.ts @@ -1,31 +1,20 @@ import Fuse, { IFuseOptions } from "fuse.js"; import { useEffect, useMemo, useState } from "react"; -const DEFAULT_OPTIONS: Required = { - limit: 20, -}; - -interface SearchOptions { - limit?: number; -} - -interface UseSearchProps> { +interface UseSearchProps { list: T[]; keys: string[]; - options?: SearchOptions; } -export const useSearch = >({ +export const useSearch = ({ list, keys, - options, }: UseSearchProps) => { const [pattern, setPattern] = useState(""); const [results, setResults] = useState([]); const fuseOptions: IFuseOptions = useMemo( () => ({ - threshold: 0.3, useExtendedSearch: true, keys, }), @@ -38,9 +27,7 @@ export const useSearch = >({ ); useEffect(() => { - const fuseResults = fuse.search(pattern, { - limit: options?.limit || DEFAULT_OPTIONS.limit, - }); + const fuseResults = fuse.search(pattern); setResults(fuseResults.map((result) => result.item)); }, [pattern]); 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 a128c66e65..ecb7142351 100644 --- a/editor.planx.uk/src/pages/FlowEditor/components/Sidebar/Search.tsx +++ b/editor.planx.uk/src/pages/FlowEditor/components/Sidebar/Search.tsx @@ -1,46 +1,46 @@ import Box from "@mui/material/Box"; import Container from "@mui/material/Container"; import Typography from "@mui/material/Typography"; -import { ComponentType } from "@opensystemslab/planx-core/types"; +import { IndexedNode, OrderedFlow } from "@opensystemslab/planx-core/types"; import { ICONS } from "@planx/components/ui"; import { useSearch } from "hooks/useSearch"; -import { Store, useStore } from "pages/FlowEditor/lib/store"; -import React, { ChangeEvent, useMemo } from "react"; +import { useStore } from "pages/FlowEditor/lib/store"; +import React, { ChangeEvent, useEffect } from "react"; import { FONT_WEIGHT_SEMI_BOLD } from "theme"; import InputLabel from "ui/editor/InputLabel"; import ChecklistItem from "ui/shared/ChecklistItem"; import Input from "ui/shared/Input"; -export interface SearchResult { - id?: Store.nodeId; - type?: ComponentType; - data?: any; - edges?: Store.nodeId[]; - nodeId: string; -} - -const SearchResults: React.FC<{ results: SearchResult[] }> = ({ results }) => { +const SearchResults: React.FC<{ results: OrderedFlow }> = ({ results }) => { return ( {results.map((result) => ( - + ))} ); }; // TODO: This likely needs to be related to facets? -const SearchResultCard: React.FC = ({ data, type }) => { +const SearchResultCard: React.FC = ({ data, type }) => { const Icon = ICONS[type!]; + const handleClick = () => { + console.log("todo!") + // get path for node + // generate url from path + // navigate to url + }; + return ( ({ pb: 2, borderBottom: `1px solid ${theme.palette.border.main}`, })} + onClick={handleClick} > {Icon && } @@ -50,7 +50,7 @@ const SearchResultCard: React.FC = ({ data, type }) => { fontWeight={FONT_WEIGHT_SEMI_BOLD} > Question - {data.text && ` - ${data.text}`} + {data?.text && ` - ${data.text}`} = ({ data, type }) => { fontFamily: `"Source Code Pro", monospace;`, }} > - {data.fn || data.val} + {(data?.fn as string) || (data?.val as string)} ); }; const Search: React.FC = () => { - const nodes = useStore((state) => state.flow); - // TODO: add to store? - // TODO: think about parentIds - const nodeList = useMemo( - () => - Object.entries(nodes).map(([nodeId, nodeData]) => ({ - nodeId, - ...nodeData, - })), - [nodes], - ); + const [orderedFlow, setOrderedFlow] = useStore((state) => [ + state.orderedFlow, + state.setOrderedFlow, + ]); + + useEffect(() => { + if (!orderedFlow) setOrderedFlow() + }, [setOrderedFlow]); /** Map of search facets to associated node keys */ const facets = { @@ -86,7 +83,7 @@ const Search: React.FC = () => { }; const { results, search } = useSearch({ - list: nodeList, + list: orderedFlow || [], keys: facets.data, }); @@ -111,7 +108,7 @@ const Search: React.FC = () => { }} onChange={() => {}} /> - + {results ? : "Loading..."} 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..e1a0a2278d 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,8 @@ 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; } export const editorStore: StateCreator< @@ -461,4 +465,12 @@ 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 }); + }, }); 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..75faaf3b08 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,7 @@ export const sharedStore: StateCreator< }, setFlow({ id, flow, flowSlug, flowName, flowStatus }) { - set({ id, flow, flowSlug, flowName, flowStatus }); + set({ id, flow, flowSlug, flowName, flowStatus, orderedFlow: undefined }); get().initNavigationStore(); }, From 1e09065ad96bd2b996eb57d61fb6e16bb349073f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dafydd=20Ll=C5=B7r=20Pearson?= Date: Fri, 19 Jul 2024 13:28:09 +0100 Subject: [PATCH 3/8] feat: Style updates --- editor.planx.uk/src/hooks/useSearch.ts | 22 +- .../components/Flow/components/Portal.tsx | 19 +- .../FlowEditor/components/Sidebar/Search.tsx | 236 ++++++++++++++---- .../src/pages/FlowEditor/lib/store/editor.ts | 14 ++ .../src/pages/FlowEditor/lib/store/shared.ts | 10 +- 5 files changed, 252 insertions(+), 49 deletions(-) diff --git a/editor.planx.uk/src/hooks/useSearch.ts b/editor.planx.uk/src/hooks/useSearch.ts index fd173ec36a..410473237f 100644 --- a/editor.planx.uk/src/hooks/useSearch.ts +++ b/editor.planx.uk/src/hooks/useSearch.ts @@ -6,16 +6,26 @@ interface UseSearchProps { 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 [results, setResults] = useState>([]); const fuseOptions: IFuseOptions = useMemo( () => ({ useExtendedSearch: true, + includeMatches: true, + minMatchCharLength: 3, keys, }), [keys], @@ -28,7 +38,15 @@ export const useSearch = ({ useEffect(() => { const fuseResults = fuse.search(pattern); - setResults(fuseResults.map((result) => result.item)); + 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 ecb7142351..6da4671347 100644 --- a/editor.planx.uk/src/pages/FlowEditor/components/Sidebar/Search.tsx +++ b/editor.planx.uk/src/pages/FlowEditor/components/Sidebar/Search.tsx @@ -1,47 +1,128 @@ import Box from "@mui/material/Box"; import Container from "@mui/material/Container"; +import List from "@mui/material/List"; +import ListItemButton from "@mui/material/ListItemButton"; +import { styled } from "@mui/material/styles"; import Typography from "@mui/material/Typography"; -import { IndexedNode, OrderedFlow } from "@opensystemslab/planx-core/types"; +import { ComponentType, IndexedNode } from "@opensystemslab/planx-core/types"; import { ICONS } from "@planx/components/ui"; +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, useEffect } from "react"; -import { FONT_WEIGHT_SEMI_BOLD } from "theme"; -import InputLabel from "ui/editor/InputLabel"; +import { FONT_WEIGHT_BOLD, FONT_WEIGHT_SEMI_BOLD } from "theme"; import ChecklistItem from "ui/shared/ChecklistItem"; import Input from "ui/shared/Input"; -const SearchResults: React.FC<{ results: OrderedFlow }> = ({ results }) => { +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 SearchResults: React.FC<{ results: SearchResults }> = ({ + results, +}) => { + return ( + <> + + {results.length} {results.length > 1 ? "results" : "result"}: + + + {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 ( - - {results.map((result) => ( - + <> + {text.split("").map((char, index) => ( + + {char} + ))} - + ); }; -// TODO: This likely needs to be related to facets? -const SearchResultCard: React.FC = ({ data, type }) => { - const Icon = ICONS[type!]; +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 = "Answer (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("todo!"); // get path for node // generate url from path // navigate to url }; return ( - ({ - pb: 2, - borderBottom: `1px solid ${theme.palette.border.main}`, - })} - onClick={handleClick} - > + {Icon && } = ({ data, type }) => { fontSize={14} fontWeight={FONT_WEIGHT_SEMI_BOLD} > - Question - {data?.text && ` - ${data.text}`} + {componentType} + {title && ( + + {` • ${title}`} + + )} - - {(data?.fn as string) || (data?.val as string)} + + {key} - + + + ); +}; + +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 }) => ( + ({ + backgroundColor: "black", + color: theme.palette.common.white, + mb: 2, + p: 1, + })} + key={`external-portal-card-${name}`} + > + + External portal • + + ({ + color: theme.palette.common.white, + textDecoration: "none", + borderBottom: "1px solid rgba(255, 255, 255, 0.75)", + })} + > + {href} + + + ))} ); }; @@ -74,7 +212,7 @@ const Search: React.FC = () => { ]); useEffect(() => { - if (!orderedFlow) setOrderedFlow() + if (!orderedFlow) setOrderedFlow(); }, [setOrderedFlow]); /** Map of search facets to associated node keys */ @@ -89,27 +227,39 @@ const Search: React.FC = () => { const handleChange = (e: ChangeEvent) => { const input = e.target.value; - console.log({ input }); search(input); - console.log(results); }; return ( - - - + + Search this flow and internal portals + + {}} /> - - {results ? : "Loading..."} + + {results.length > 0 && ( + <> + + + + )} ); 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 e1a0a2278d..70542ddd5e 100644 --- a/editor.planx.uk/src/pages/FlowEditor/lib/store/editor.ts +++ b/editor.planx.uk/src/pages/FlowEditor/lib/store/editor.ts @@ -101,6 +101,12 @@ export interface EditorStore extends Store.Store { undoOperation: (ops: OT.Op[]) => void; orderedFlow?: OrderedFlow; setOrderedFlow: () => void; + externalPortals: Record; + addExternalPortal: (portal: { + id: string; + name: string; + href: string; + }) => void; } export const editorStore: StateCreator< @@ -473,4 +479,12 @@ export const editorStore: StateCreator< 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 75faaf3b08..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, orderedFlow: undefined }); + set({ + id, + flow, + flowSlug, + flowName, + flowStatus, + orderedFlow: undefined, + externalPortals: {}, + }); get().initNavigationStore(); }, From 2de78d0580a475ebd58dbbc1839f626edecfe8da Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dafydd=20Ll=C5=B7r=20Pearson?= Date: Fri, 19 Jul 2024 14:58:53 +0100 Subject: [PATCH 4/8] chore: Styles and correct semantic html lists --- .../FlowEditor/components/Sidebar/Search.tsx | 69 ++++++++++--------- 1 file changed, 37 insertions(+), 32 deletions(-) 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 6da4671347..4c7dedcf26 100644 --- a/editor.planx.uk/src/pages/FlowEditor/components/Sidebar/Search.tsx +++ b/editor.planx.uk/src/pages/FlowEditor/components/Sidebar/Search.tsx @@ -1,6 +1,7 @@ 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 } from "@mui/material/styles"; import Typography from "@mui/material/Typography"; @@ -12,6 +13,7 @@ import { capitalize, get } from "lodash"; import { SLUGS } from "pages/FlowEditor/data/types"; import { useStore } from "pages/FlowEditor/lib/store"; import React, { ChangeEvent, 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"; @@ -26,7 +28,18 @@ const SearchResultRoot = styled(List)(({ theme }) => ({ const SearchResultCardRoot = styled(ListItemButton)(({ theme }) => ({ padding: theme.spacing(1), border: `1px solid ${theme.palette.common.black}`, - display: "block", + display: "block" +})); + +const ExternalPortalCard = styled(ListItemButton)(({ theme }) => ({ + backgroundColor: "black", + color: theme.palette.common.white, + "&:hover": { + backgroundColor: "black", + "& p:last-of-type": { + textDecoration: "underline", + } + } })); const SearchResults: React.FC<{ results: SearchResults }> = ({ @@ -39,7 +52,9 @@ const SearchResults: React.FC<{ results: SearchResults }> = ({ {results.map((result) => ( - + + + ))} @@ -160,6 +175,7 @@ const SearchResultCard: React.FC<{ result: SearchResult }> = ({ const ExternalPortalList: React.FC = () => { const externalPortals = useStore((state) => state.externalPortals); const hasExternalPortals = Object.keys(externalPortals).length; + const { navigate } = useNavigation(); if (!hasExternalPortals) return null; @@ -169,38 +185,27 @@ const ExternalPortalList: React.FC = () => { Your service also contains the following external portals, which have not been searched: + {Object.values(externalPortals).map(({ name, href }) => ( - ({ - backgroundColor: "black", - color: theme.palette.common.white, - mb: 2, - p: 1, - })} - key={`external-portal-card-${name}`} - > - - External portal • - - ({ - color: theme.palette.common.white, - textDecoration: "none", - borderBottom: "1px solid rgba(255, 255, 255, 0.75)", - })} - > - {href} - - + + navigate("../" + href)}> + + External portal • + + + {href.replaceAll("/", " / ")} + + + ))} + ); }; From f28baa4d81b4d5941971f8bb8671cac59db31605 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dafydd=20Ll=C5=B7r=20Pearson?= Date: Fri, 19 Jul 2024 16:51:12 +0100 Subject: [PATCH 5/8] feat: Setup formik --- .../FlowEditor/components/Sidebar/Search.tsx | 134 ++++++++++-------- 1 file changed, 73 insertions(+), 61 deletions(-) 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 4c7dedcf26..516121f920 100644 --- a/editor.planx.uk/src/pages/FlowEditor/components/Sidebar/Search.tsx +++ b/editor.planx.uk/src/pages/FlowEditor/components/Sidebar/Search.tsx @@ -7,12 +7,13 @@ 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 { 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, useEffect } from "react"; +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"; @@ -48,8 +49,11 @@ const SearchResults: React.FC<{ results: SearchResults }> = ({ return ( <> - {results.length} {results.length > 1 ? "results" : "result"}: + {!results.length && "No matches found"} + {results.length === 1 && "1 result:"} + {results.length > 1 && `${results.length} results:`} + {results.map((result) => ( @@ -83,6 +87,7 @@ const Headline: React.FC = ({ text, matchIndices, variant }) => { variant === "data" ? '"Source Code Pro", monospace' : "inherit" } variant="body2" + display={"inline-block"} > {char} @@ -131,6 +136,7 @@ const SearchResultCard: React.FC<{ result: SearchResult }> = ({ const handleClick = () => { console.log("todo!"); + console.log({ nodeId: result.item.id }) // get path for node // generate url from path // navigate to url @@ -180,36 +186,41 @@ const ExternalPortalList: React.FC = () => { if (!hasExternalPortals) return null; return ( - + Your service also contains the following external portals, which have not been searched: - {Object.values(externalPortals).map(({ name, href }) => ( - - navigate("../" + href)}> - - External portal • - - - {href.replaceAll("/", " / ")} - - - - ))} + {Object.values(externalPortals).map(({ name, href }) => ( + + navigate("../" + href)}> + + External portal • + + + {href.replaceAll("/", " / ")} + + + + ))} ); }; +interface SearchNodes { + input: string; + facets: ["data.fn", "data.val"], +} + const Search: React.FC = () => { const [orderedFlow, setOrderedFlow] = useStore((state) => [ state.orderedFlow, @@ -220,52 +231,53 @@ const Search: React.FC = () => { if (!orderedFlow) setOrderedFlow(); }, [setOrderedFlow]); - /** Map of search facets to associated node keys */ - const facets = { - data: ["data.fn", "data.val"], - }; + const formik = useFormik({ + initialValues: { input: "", facets: ["data.fn", "data.val"] }, + onSubmit: ({ input }) => { search(input) }, + }); const { results, search } = useSearch({ list: orderedFlow || [], - keys: facets.data, + keys: formik.values.facets }); - const handleChange = (e: ChangeEvent) => { - const input = e.target.value; - search(input); - }; - return ( - - Search this flow and internal portals - - - {}} - /> - - {results.length > 0 && ( - <> - - - - )} - +
+ + Search this flow and internal portals + + { + formik.setFieldValue("input", e.target.value) + formik.handleSubmit(); + }} + inputProps={{ spellCheck: false }} + /> + { }} + /> + + {formik.values.input && ( + <> + + + + )} + +
); }; From 21b59a2ee7e0e9b4d6c492e0587259e7499bc94c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dafydd=20Ll=C5=B7r=20Pearson?= Date: Mon, 22 Jul 2024 12:39:46 +0100 Subject: [PATCH 6/8] chore: Bump planx-core to simplified version with just type changes --- editor.planx.uk/package.json | 2 +- editor.planx.uk/pnpm-lock.yaml | 10 +++++----- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/editor.planx.uk/package.json b/editor.planx.uk/package.json index 7c10dbf5fb..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", diff --git a/editor.planx.uk/pnpm-lock.yaml b/editor.planx.uk/pnpm-lock.yaml index 56f581ed7c..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) @@ -21898,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 From 2886b05f609c1071037a8b762327c48358cd7605 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dafydd=20Ll=C5=B7r=20Pearson?= Date: Tue, 30 Jul 2024 11:41:29 +0100 Subject: [PATCH 7/8] fix: PR feedback --- .../FlowEditor/components/Sidebar/Search.tsx | 32 +++++++++++-------- 1 file changed, 18 insertions(+), 14 deletions(-) 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 516121f920..96c92ebd7b 100644 --- a/editor.planx.uk/src/pages/FlowEditor/components/Sidebar/Search.tsx +++ b/editor.planx.uk/src/pages/FlowEditor/components/Sidebar/Search.tsx @@ -29,7 +29,7 @@ const SearchResultRoot = styled(List)(({ theme }) => ({ const SearchResultCardRoot = styled(ListItemButton)(({ theme }) => ({ padding: theme.spacing(1), border: `1px solid ${theme.palette.common.black}`, - display: "block" + display: "block", })); const ExternalPortalCard = styled(ListItemButton)(({ theme }) => ({ @@ -39,8 +39,8 @@ const ExternalPortalCard = styled(ListItemButton)(({ theme }) => ({ backgroundColor: "black", "& p:last-of-type": { textDecoration: "underline", - } - } + }, + }, })); const SearchResults: React.FC<{ results: SearchResults }> = ({ @@ -117,7 +117,7 @@ const SearchResultCard: React.FC<{ result: SearchResult }> = ({ const parentNode = useStore.getState().flow[item.parentId]; Icon = ICONS[ComponentType.Question]; title = parentNode!.data.text!; - displayKey = "Answer (data)"; + displayKey = "Option (data)"; } return { @@ -136,7 +136,7 @@ const SearchResultCard: React.FC<{ result: SearchResult }> = ({ const handleClick = () => { console.log("todo!"); - console.log({ nodeId: result.item.id }) + console.log({ nodeId: result.item.id }); // get path for node // generate url from path // navigate to url @@ -193,7 +193,11 @@ const ExternalPortalList: React.FC = () => { {Object.values(externalPortals).map(({ name, href }) => ( - + navigate("../" + href)}> { > External portal • - + {href.replaceAll("/", " / ")} @@ -218,7 +220,7 @@ const ExternalPortalList: React.FC = () => { interface SearchNodes { input: string; - facets: ["data.fn", "data.val"], + facets: ["data.fn", "data.val"]; } const Search: React.FC = () => { @@ -233,12 +235,14 @@ const Search: React.FC = () => { const formik = useFormik({ initialValues: { input: "", facets: ["data.fn", "data.val"] }, - onSubmit: ({ input }) => { search(input) }, + onSubmit: ({ input }) => { + search(input); + }, }); const { results, search } = useSearch({ list: orderedFlow || [], - keys: formik.values.facets + keys: formik.values.facets, }); return ( @@ -257,7 +261,7 @@ const Search: React.FC = () => { name="search" value={formik.values.input} onChange={(e) => { - formik.setFieldValue("input", e.target.value) + formik.setFieldValue("input", e.target.value); formik.handleSubmit(); }} inputProps={{ spellCheck: false }} @@ -267,7 +271,7 @@ const Search: React.FC = () => { id={"search-data-field-facet"} checked inputProps={{ disabled: true }} - onChange={() => { }} + onChange={() => {}} /> {formik.values.input && ( From 78262fb2fe1f3e53a61c20c64d077f79e7644968 Mon Sep 17 00:00:00 2001 From: Ian Jones <51156018+ianjon3s@users.noreply.github.com> Date: Tue, 30 Jul 2024 12:10:59 +0100 Subject: [PATCH 8/8] style: External portals within search results (#3465) --- .../FlowEditor/components/Sidebar/Search.tsx | 38 ++++++------------- 1 file changed, 11 insertions(+), 27 deletions(-) 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 96c92ebd7b..e46d7b5e59 100644 --- a/editor.planx.uk/src/pages/FlowEditor/components/Sidebar/Search.tsx +++ b/editor.planx.uk/src/pages/FlowEditor/components/Sidebar/Search.tsx @@ -3,7 +3,7 @@ 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 } from "@mui/material/styles"; +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"; @@ -32,15 +32,11 @@ const SearchResultCardRoot = styled(ListItemButton)(({ theme }) => ({ display: "block", })); -const ExternalPortalCard = styled(ListItemButton)(({ theme }) => ({ - backgroundColor: "black", - color: theme.palette.common.white, - "&:hover": { - backgroundColor: "black", - "& p:last-of-type": { - textDecoration: "underline", - }, - }, +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 }> = ({ @@ -191,29 +187,17 @@ const ExternalPortalList: React.FC = () => { Your service also contains the following external portals, which have not been searched: - + {Object.values(externalPortals).map(({ name, href }) => ( - - navigate("../" + href)}> - - External portal • - + + {href.replaceAll("/", " / ")} - + ))} - + ); };