From b45b2dcbac5ff9439a0b420f7114825eea98dfa6 Mon Sep 17 00:00:00 2001 From: 2427021 <s.a.vink@students.uu.nl> Date: Mon, 25 Mar 2024 09:26:45 +0100 Subject: [PATCH] feat(visManager): moved search to sidebar --- apps/web/src/components/navbar/navbar.tsx | 1 - .../components/navbar/search/SearchBar.tsx | 260 ------------------ libs/shared/lib/info/infoPanel.tsx | 4 +- libs/shared/lib/info/search.tsx | 7 - libs/shared/lib/info/search/searchbar.tsx | 183 ++++++++++++ .../shared/lib/info}/search/similarity.ts | 0 6 files changed, 185 insertions(+), 270 deletions(-) delete mode 100644 apps/web/src/components/navbar/search/SearchBar.tsx delete mode 100644 libs/shared/lib/info/search.tsx create mode 100644 libs/shared/lib/info/search/searchbar.tsx rename {apps/web/src/components/navbar => libs/shared/lib/info}/search/similarity.ts (100%) diff --git a/apps/web/src/components/navbar/navbar.tsx b/apps/web/src/components/navbar/navbar.tsx index 585ac8b82..15820f87e 100644 --- a/apps/web/src/components/navbar/navbar.tsx +++ b/apps/web/src/components/navbar/navbar.tsx @@ -12,7 +12,6 @@ import React, { useState, useRef, useEffect } from 'react'; import logo_white from './gp-logo-white.svg'; import logo from './gp-logo.svg'; import { useAuthorizationCache, useAuth } from '@graphpolaris/shared/lib/data-access'; -import { SearchBar } from './search/SearchBar'; import DatabaseSelector from './DatabaseManagement/dbConnectionSelector'; import { DropdownItem, DropdownItemContainer } from '@graphpolaris/shared/lib/components/dropdowns'; import ColorMode from '@graphpolaris/shared/lib/components/color-mode'; diff --git a/apps/web/src/components/navbar/search/SearchBar.tsx b/apps/web/src/components/navbar/search/SearchBar.tsx deleted file mode 100644 index e5182032b..000000000 --- a/apps/web/src/components/navbar/search/SearchBar.tsx +++ /dev/null @@ -1,260 +0,0 @@ -import React from 'react'; -import { - useAppDispatch, - useGraphQueryResult, - useSchemaGraph, - useQuerybuilderGraph, - useSearchResult, - AppDispatch, - useRecentSearches, -} from '@graphpolaris/shared/lib/data-access'; -import { filterData } from './similarity'; -import { Search as SearchIcon } from '@mui/icons-material'; -import { - addSearchResultData, - addSearchResultSchema, - addSearchResultQueryBuilder, - CATEGORY_KEYS, - addRecentSearch, -} from '@graphpolaris/shared/lib/data-access/store/searchResultSlice'; -import { QueryMultiGraph } from '@graphpolaris/shared/lib/querybuilder/model/graphology/utils'; - -const SIMILARITY_THRESHOLD = 0.7; - -const CATEGORY_ACTIONS: { - [key in CATEGORY_KEYS]: (payload: { nodes: Record<string, any>[]; edges: Record<string, any>[] }, dispatch: AppDispatch) => void; -} = { - data: (payload: { nodes: Record<string, any>[]; edges: Record<string, any>[] }, dispatch: AppDispatch) => { - dispatch(addSearchResultData(payload)); - }, - schema: (payload: { nodes: Record<string, any>[]; edges: Record<string, any>[] }, dispatch: AppDispatch) => { - dispatch(addSearchResultSchema(payload)); - }, - querybuilder: (payload: { nodes: Record<string, any>[]; edges: Record<string, any>[] }, dispatch: AppDispatch) => { - dispatch(addSearchResultQueryBuilder(payload)); - }, -}; - -const SEARCH_CATEGORIES: CATEGORY_KEYS[] = Object.keys(CATEGORY_ACTIONS) as CATEGORY_KEYS[]; - -export function SearchBar({}) { - const inputRef = React.useRef<HTMLInputElement>(null); - const searchbarRef = React.useRef<HTMLDivElement>(null); - const dispatch = useAppDispatch(); - const results = useSearchResult(); - const recentSearches = useRecentSearches(); - const schema = useSchemaGraph(); - const graphData = useGraphQueryResult(); - const querybuilderData = useQuerybuilderGraph(); - const [search, setSearch] = React.useState<string>(''); - const [searchOpen, setSearchOpen] = React.useState<boolean>(false); - - const dataSources: { - [key: string]: { nodes: Record<string, any>[]; edges: Record<string, any>[] }; - } = { - data: graphData, - schema: schema, - querybuilder: querybuilderData as QueryMultiGraph, - }; - - const toggleSearch = () => { - setSearchOpen(true); - if (!searchOpen && inputRef.current) { - inputRef.current.focus(); - } - }; - - React.useEffect(() => { - const handleKeyPress = (event: KeyboardEvent) => { - if (event.key === 'Enter') { - if (searchOpen && search !== '') { - dispatch(addRecentSearch(search)); - } - } - }; - window.addEventListener('keydown', handleKeyPress); - return () => window.removeEventListener('keydown', handleKeyPress); - }, [searchOpen, search]); - - React.useEffect(() => { - const handleKeyPress = (event: KeyboardEvent) => { - if (event.key === '/') { - if (!searchOpen) { - setSearchOpen(true); - setSearch(''); - } - } - }; - window.addEventListener('keydown', handleKeyPress); - return () => window.removeEventListener('keydown', handleKeyPress); - }, [searchOpen]); - - React.useEffect(() => { - if (searchOpen && inputRef.current) { - inputRef.current.focus(); - setSearch(''); - } - }, [searchOpen]); - - React.useEffect(() => { - const handleKeyPress = (event: KeyboardEvent) => { - if (event.key === 'Escape') { - if (searchOpen) { - setSearchOpen(false); - setSearch(''); - } - } - }; - window.addEventListener('keydown', handleKeyPress); - return () => window.removeEventListener('keydown', handleKeyPress); - }, [searchOpen]); - - React.useEffect(() => { - handleSearch(); - }, [search]); - - const handleSearch = () => { - let query = search.toLowerCase(); - const categories = search.match(/@[^ ]+/g); - - if (categories) { - categories.map((category) => { - query = query.replace(category, '').trim(); - const cat = category.substring(1); - - if (cat in CATEGORY_ACTIONS) { - const categoryAction = CATEGORY_ACTIONS[cat as CATEGORY_KEYS]; - const data = dataSources[cat]; - - const payload = { - nodes: filterData(query, data.nodes, SIMILARITY_THRESHOLD), - edges: filterData(query, data.edges, SIMILARITY_THRESHOLD), - }; - categoryAction(payload, dispatch); - } - }); - } else { - for (const category of SEARCH_CATEGORIES) { - const categoryAction = CATEGORY_ACTIONS[category]; - const data = dataSources[category]; - - const payload = { - nodes: filterData(query, data.nodes, SIMILARITY_THRESHOLD), - edges: filterData(query, data.edges, SIMILARITY_THRESHOLD), - }; - - categoryAction(payload, dispatch); - } - } - }; - - React.useEffect(() => { - const handleClickOutside = ({ target }: MouseEvent) => { - if (inputRef.current && target && !inputRef.current.contains(target as Node) && !searchbarRef?.current?.contains(target as Node)) { - setSearch(''); - setSearchOpen(false); - } - }; - document.addEventListener('click', handleClickOutside); - return () => { - document.removeEventListener('click', handleClickOutside); - }; - }, []); - - return ( - <div className="searchbar"> - {searchOpen && <div className="fixed inset-0 bg-black bg-opacity-50 z-40"></div>} - <div className="mr-2" ref={searchbarRef}> - <div - className="flex items-center border border-secondary-300 hover:bg-secondary-50 px-2 rounded text-sm w-44 h-8 text-secondary-900 cursor-pointer" - onClick={toggleSearch} - > - <SearchIcon /> - <span className="ml-1 text-secondary-900"> - Type <span className="border border-secondary-900 rounded px-1">/</span> to search - </span> - </div> - - {searchOpen && ( - <div className="fixed flex flex-col left-1/2 -translate-x-1/2 w-9/12 max-h-1/2 top-2 items-start justify-center z-50 bg-secondary-200 rounded"> - <div className="p-3 w-full"> - {/* <label className="sr-only">Search</label> */} - <div className="relative"> - <div className="absolute inset-y-0 start-0 flex items-center ps-3 pointer-events-none"> - <SearchIcon className="text-secondary500" /> - </div> - <input - type="text" - ref={inputRef} - value={search} - onChange={(e) => setSearch(e.target.value)} - id="input-group-search" - className="block w-full p-2 ps-10 text-sm text-secondary-900 border border-secondary-300 rounded bg-secondary-50 focus:ring-blue-500 focus:border-blue-500 focus:ring-0" - placeholder="Search database" - ></input> - </div> - </div> - - {recentSearches.length !== 0 && ( - <div className="px-3 pb-3"> - <p className="text-sm">Recent searches</p> - {recentSearches.slice(0, 3).map((term) => ( - <p key={term} className="ml-1 text-sm text-secondary-500 cursor-pointer" onClick={() => setSearch(term)}> - {term} - </p> - ))} - </div> - )} - {search !== '' && ( - <div className="z-10 rounded card-bordered w-full overflow-auto max-h-[60vh] px-3 pb-3"> - <p className="font-bold text-sm">Results</p> - {SEARCH_CATEGORIES.every((category) => results[category].nodes.length === 0 && results[category].edges.length === 0) ? ( - <div className="ml-1 text-sm"> - <p className="text-secondary-500">Found no matches...</p> - </div> - ) : ( - SEARCH_CATEGORIES.map((category, index) => { - if (results[category].nodes.length > 0 || results[category].edges.length > 0) { - return ( - <div key={index}> - <div className="flex justify-between p-2 text-lg"> - <p className="font-bold text-sm">{category.charAt(0).toUpperCase() + category.slice(1)}</p> - <p className="font-bold text-sm">{results[category].nodes.length + results[category].edges.length} results</p> - </div> - <div className="h-[1px] w-full bg-secondary-200"></div> - {Object.values(Object.values(results[category])) - .flat() - .map((item, index) => ( - <div - key={index} - className="flex flex-col hover:bg-secondary-300 px-2 py-1 cursor-pointer rounded ml-2" - title={JSON.stringify(item)} - onClick={() => { - CATEGORY_ACTIONS[category]( - { - nodes: results[category].nodes.includes(item) ? [item] : [], - edges: results[category].edges.includes(item) ? [item] : [], - }, - dispatch, - ); - }} - > - <div className="font-bold text-sm"> - {item?.key?.slice(0, 18) || item?.id?.slice(0, 18) || Object.values(item)?.[0]?.slice(0, 18)} - </div> - <div className="font-light text-secondary-800 text-xs">{JSON.stringify(item).substring(0, 40)}...</div> - </div> - ))} - </div> - ); - } else return <></>; - }) - )} - </div> - )} - </div> - )} - </div> - </div> - ); -} diff --git a/libs/shared/lib/info/infoPanel.tsx b/libs/shared/lib/info/infoPanel.tsx index 0392aa05d..52a08ca4c 100644 --- a/libs/shared/lib/info/infoPanel.tsx +++ b/libs/shared/lib/info/infoPanel.tsx @@ -2,7 +2,7 @@ import React, { useState } from 'react'; import { Button } from '../components'; import { Addchart, Schema as SchemaIcon, Search as SearchIcon } from '@mui/icons-material'; import Schema from '../schema/panel'; -import Search from './search'; +import Searchbar from './search/searchbar'; import Settings from './settings'; export default function InfoPanel({ auth, manager }: { auth: boolean; manager: any }) { @@ -40,7 +40,7 @@ export default function InfoPanel({ auth, manager }: { auth: boolean; manager: a <div className="relative flex items-center justify-between z-[2] py-0 px-2 bg-secondary-100 border-b border-secondary-200"> <h1 className="text-xs font-semibold text-secondary-800">{tab}</h1> </div> - {tab === 'Search' && <Search />} + {tab === 'Search' && <Searchbar />} {tab === 'Schema' && <Schema auth={auth} />} {tab === 'Visualization' && <Settings manager={manager} />} </div> diff --git a/libs/shared/lib/info/search.tsx b/libs/shared/lib/info/search.tsx deleted file mode 100644 index 2ab6a4ae5..000000000 --- a/libs/shared/lib/info/search.tsx +++ /dev/null @@ -1,7 +0,0 @@ -import React from 'react'; - -type Props = {}; - -export default function Search({}: Props) { - return <div>Search to be implemented here</div>; -} diff --git a/libs/shared/lib/info/search/searchbar.tsx b/libs/shared/lib/info/search/searchbar.tsx new file mode 100644 index 000000000..abafc6acd --- /dev/null +++ b/libs/shared/lib/info/search/searchbar.tsx @@ -0,0 +1,183 @@ +import React, { useRef, useState, useEffect } from 'react'; +import { + AppDispatch, + useAppDispatch, + useGraphQueryResult, + useQuerybuilderGraph, + useRecentSearches, + useSchemaGraph, + useSearchResult, +} from '../../data-access'; +import { QueryMultiGraph } from '../../querybuilder'; +import { + CATEGORY_KEYS, + addRecentSearch, + addSearchResultData, + addSearchResultQueryBuilder, + addSearchResultSchema, +} from '../../data-access/store/searchResultSlice'; +import { filterData } from './similarity'; + +const SIMILARITY_THRESHOLD = 0.7; + +const CATEGORY_ACTIONS: { + [key in CATEGORY_KEYS]: (payload: { nodes: Record<string, any>[]; edges: Record<string, any>[] }, dispatch: AppDispatch) => void; +} = { + data: (payload: { nodes: Record<string, any>[]; edges: Record<string, any>[] }, dispatch: AppDispatch) => { + dispatch(addSearchResultData(payload)); + }, + schema: (payload: { nodes: Record<string, any>[]; edges: Record<string, any>[] }, dispatch: AppDispatch) => { + dispatch(addSearchResultSchema(payload)); + }, + querybuilder: (payload: { nodes: Record<string, any>[]; edges: Record<string, any>[] }, dispatch: AppDispatch) => { + dispatch(addSearchResultQueryBuilder(payload)); + }, +}; + +const SEARCH_CATEGORIES: CATEGORY_KEYS[] = Object.keys(CATEGORY_ACTIONS) as CATEGORY_KEYS[]; + +export default function Searchbar() { + const inputRef = useRef<HTMLInputElement>(null); + const [search, setSearch] = useState<string>(''); + const searchbarRef = useRef<HTMLDivElement>(null); + const dispatch = useAppDispatch(); + const results = useSearchResult(); + const recentSearches = useRecentSearches(); + const schema = useSchemaGraph(); + const graphData = useGraphQueryResult(); + const querybuilderData = useQuerybuilderGraph(); + + const dataSources: { + [key: string]: { nodes: Record<string, any>[]; edges: Record<string, any>[] }; + } = { + data: graphData, + schema: schema, + querybuilder: querybuilderData as QueryMultiGraph, + }; + + useEffect(() => { + const handleKeyPress = (event: KeyboardEvent) => { + if (event.key === 'Enter') { + if (search !== '') { + dispatch(addRecentSearch(search)); + } + } + }; + window.addEventListener('keydown', handleKeyPress); + return () => window.removeEventListener('keydown', handleKeyPress); + }, [search]); + + useEffect(() => { + handleSearch(); + }, [search]); + + const handleSearch = () => { + let query = search.toLowerCase(); + const categories = search.match(/@[^ ]+/g); + + if (categories) { + categories.map((category) => { + query = query.replace(category, '').trim(); + const cat = category.substring(1); + + if (cat in CATEGORY_ACTIONS) { + const categoryAction = CATEGORY_ACTIONS[cat as CATEGORY_KEYS]; + const data = dataSources[cat]; + + const payload = { + nodes: filterData(query, data.nodes, SIMILARITY_THRESHOLD), + edges: filterData(query, data.edges, SIMILARITY_THRESHOLD), + }; + categoryAction(payload, dispatch); + } + }); + } else { + for (const category of SEARCH_CATEGORIES) { + const categoryAction = CATEGORY_ACTIONS[category]; + const data = dataSources[category]; + + const payload = { + nodes: filterData(query, data.nodes, SIMILARITY_THRESHOLD), + edges: filterData(query, data.edges, SIMILARITY_THRESHOLD), + }; + + categoryAction(payload, dispatch); + } + } + }; + + return ( + <div className="flex flex-col w-full p-2"> + <div className="w-full"> + <input + type="text" + ref={inputRef} + value={search} + onChange={(e) => setSearch(e.target.value)} + id="input-group-search" + className="block w-full p-2 ps-10 text-sm text-secondary-900 border border-secondary-300 rounded bg-secondary-50 focus:ring-blue-500 focus:border-blue-500 focus:ring-0" + placeholder="Search database" + ></input> + </div> + <div> + {recentSearches.length !== 0 && ( + <div className="px-3 pb-3"> + <p className="text-sm">Recent searches</p> + {recentSearches.slice(0, 3).map((term) => ( + <p key={term} className="ml-1 text-sm text-secondary-500 cursor-pointer" onClick={() => setSearch(term)}> + {term} + </p> + ))} + </div> + )} + {search !== '' && ( + <div className="z-10 rounded card-bordered w-full overflow-auto max-h-[60vh] px-3 pb-3"> + <p className="font-bold text-sm">Results</p> + {SEARCH_CATEGORIES.every((category) => results[category].nodes.length === 0 && results[category].edges.length === 0) ? ( + <div className="ml-1 text-sm"> + <p className="text-secondary-500">Found no matches...</p> + </div> + ) : ( + SEARCH_CATEGORIES.map((category, index) => { + if (results[category].nodes.length > 0 || results[category].edges.length > 0) { + return ( + <div key={index}> + <div className="flex justify-between p-2 text-lg"> + <p className="font-bold text-sm">{category.charAt(0).toUpperCase() + category.slice(1)}</p> + <p className="font-bold text-sm">{results[category].nodes.length + results[category].edges.length} results</p> + </div> + <div className="h-[1px] w-full bg-secondary-200"></div> + {Object.values(Object.values(results[category])) + .flat() + .map((item, index) => ( + <div + key={index} + className="flex flex-col hover:bg-secondary-300 px-2 py-1 cursor-pointer rounded ml-2" + title={JSON.stringify(item)} + onClick={() => { + CATEGORY_ACTIONS[category]( + { + nodes: results[category].nodes.includes(item) ? [item] : [], + edges: results[category].edges.includes(item) ? [item] : [], + }, + dispatch, + ); + }} + > + <div className="font-bold text-sm"> + {item?.key?.slice(0, 18) || item?.id?.slice(0, 18) || Object.values(item)?.[0]?.slice(0, 18)} + </div> + <div className="font-light text-secondary-800 text-xs">{JSON.stringify(item).substring(0, 40)}...</div> + </div> + ))} + </div> + ); + } else return <></>; + }) + )} + </div> + )} + </div> + </div> + ); +} diff --git a/apps/web/src/components/navbar/search/similarity.ts b/libs/shared/lib/info/search/similarity.ts similarity index 100% rename from apps/web/src/components/navbar/search/similarity.ts rename to libs/shared/lib/info/search/similarity.ts -- GitLab