From e64dec4ea79dab766ab309e1fc4334a41b80f2ca 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] feat: Style updates --- editor.planx.uk/src/hooks/useSearch.ts | 21 +- .../components/Flow/components/Portal.tsx | 10 +- .../FlowEditor/components/Sidebar/Search.tsx | 201 ++++++++++++++---- .../src/pages/FlowEditor/lib/store/editor.ts | 42 ++-- .../src/pages/FlowEditor/lib/store/shared.ts | 2 +- 5 files changed, 211 insertions(+), 65 deletions(-) diff --git a/editor.planx.uk/src/hooks/useSearch.ts b/editor.planx.uk/src/hooks/useSearch.ts index fd173ec36a..5630ccf8d2 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,14 @@ 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..04e4677b6e 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,7 @@ 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 +23,7 @@ const ExternalPortal: React.FC = (props) => { flows_by_pk(id: $id) { id slug + name team { slug } @@ -31,8 +32,11 @@ 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..d768a0d90d 100644 --- a/editor.planx.uk/src/pages/FlowEditor/components/Sidebar/Search.tsx +++ b/editor.planx.uk/src/pages/FlowEditor/components/Sidebar/Search.tsx @@ -1,31 +1,106 @@ +import { styled } from "@mui/material/styles"; import Box from "@mui/material/Box"; import Container from "@mui/material/Container"; 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 { useSearch } from "hooks/useSearch"; +import type { SearchResults, 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, { 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"; +import List from "@mui/material/List"; +import ListItemButton from "@mui/material/ListItemButton"; -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.map((result) => ( - + <> + + {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 ( + <> + {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!") @@ -35,13 +110,7 @@ const SearchResultCard: React.FC = ({ data, type }) => { }; 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} + + + ))} + + ) +} + const Search: React.FC = () => { const [orderedFlow, setOrderedFlow] = useStore((state) => [ state.orderedFlow, @@ -89,27 +193,38 @@ 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 + + {}} + inputProps={{ disabled: true }} + onChange={() => { }} /> - - {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..72e8e598bd 100644 --- a/editor.planx.uk/src/pages/FlowEditor/lib/store/editor.ts +++ b/editor.planx.uk/src/pages/FlowEditor/lib/store/editor.ts @@ -89,18 +89,20 @@ export interface EditorStore extends Store.Store { id: Store.nodeId, parent?: Store.nodeId, toBefore?: Store.nodeId, - toParent?: Store.nodeId, + toParent?: Store.nodeId ) => void; pasteNode: (toParent: Store.nodeId, toBefore: Store.nodeId) => void; publishFlow: ( flowId: string, - summary?: string, + summary?: string ) => Promise; 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< @@ -111,11 +113,11 @@ export const editorStore: StateCreator< > = (set, get) => ({ addNode: ( { id = undefined, type, data }, - { children = undefined, parent = ROOT_NODE_KEY, before = undefined } = {}, + { children = undefined, parent = ROOT_NODE_KEY, before = undefined } = {} ) => { const [, ops] = add( { id, type, data }, - { children, parent, before }, + { children, parent, before } )(get().flow); send(ops); }, @@ -123,7 +125,7 @@ export const editorStore: StateCreator< connect: (src, tgt, { before = undefined } = {}) => { try { const [, ops] = clone(tgt, { toParent: src, toBefore: before })( - get().flow, + get().flow ); send(ops); } catch (err: any) { @@ -162,7 +164,7 @@ export const editorStore: StateCreator< const cloneStateFromRemoteOps = debounce(cloneStateFromShareDb, 500); doc.on("op", (_op: any, isLocalOp?: boolean) => - isLocalOp ? cloneStateFromLocalOps() : cloneStateFromRemoteOps(), + isLocalOp ? cloneStateFromLocalOps() : cloneStateFromRemoteOps() ); }, @@ -174,7 +176,7 @@ export const editorStore: StateCreator< // but when accessed from the editor we generate a string using the same method as in src/@planx/graph/index.ts const randomReplacementCharacters = customAlphabet(en)( "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789", - 5, // a full nodeId is 10 characters long + 5 // a full nodeId is 10 characters long ); return axios.post( @@ -187,7 +189,7 @@ export const editorStore: StateCreator< headers: { Authorization: `Bearer ${token}`, }, - }, + } ); }, @@ -278,7 +280,7 @@ export const editorStore: StateCreator< headers: { Authorization: `Bearer ${token}`, }, - }, + } ); }, @@ -368,7 +370,7 @@ export const editorStore: StateCreator< const valid = get().canUserEditTeam(teamSlug); if (!valid) { alert( - `You do not have permission to move this flow into ${teamSlug}, try again`, + `You do not have permission to move this flow into ${teamSlug}, try again` ); return Promise.resolve(); } @@ -383,13 +385,13 @@ export const editorStore: StateCreator< headers: { Authorization: `Bearer ${token}`, }, - }, + } ) .then((res) => alert(res?.data?.message)) .catch(() => alert( - "Failed to move this flow. Make sure you're entering a valid team name and try again", - ), + "Failed to move this flow. Make sure you're entering a valid team name and try again" + ) ); }, @@ -397,7 +399,7 @@ export const editorStore: StateCreator< id: string, parent = undefined, toBefore = undefined, - toParent = undefined, + toParent = undefined ) { try { const [, ops] = move(id, parent as unknown as string, { @@ -433,14 +435,14 @@ export const editorStore: StateCreator< const { data } = await axios.post( urlWithParams( `${process.env.REACT_APP_API_URL}/flows/${flowId}/publish`, - { summary }, + { summary } ), null, { headers: { Authorization: `Bearer ${token}`, }, - }, + } ); set({ isFlowPublished: true }); @@ -473,4 +475,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..5d1a43355c 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, orderedFlow: undefined }); + set({ id, flow, flowSlug, flowName, flowStatus, orderedFlow: undefined, externalPortals: {} }); get().initNavigationStore(); },