From b247e6b751ddb71a07d2f2e394ea9f4b10dc8bef 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] feat(wip): Initial proof of concept with Fuse.js --- editor.planx.uk/package.json | 1 + editor.planx.uk/pnpm-lock.yaml | 8 ++ editor.planx.uk/src/hooks/useSearch.ts | 48 ++++++++ .../FlowEditor/components/Sidebar/Search.tsx | 103 ++++++++---------- 4 files changed, 102 insertions(+), 58 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 359c449e10..29c99dd2e7 100644 --- a/editor.planx.uk/package.json +++ b/editor.planx.uk/package.json @@ -50,6 +50,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 d9c7e1ada3..ba748b11fe 100644 --- a/editor.planx.uk/pnpm-lock.yaml +++ b/editor.planx.uk/pnpm-lock.yaml @@ -153,6 +153,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 @@ -13145,6 +13148,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 679377c8cb..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 { data, 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..."} );