diff --git a/package-lock.json b/package-lock.json index ff30a5f424e87ddc41de6f6b36b62cd92e0cd417..f01c2eceb3119c16c2f36825d84407d1df93e80f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,87 +9,89 @@ "version": "0.1.0", "hasInstallScript": true, "dependencies": { - "@next/font": "^13.5.2", - "@reduxjs/toolkit": "^1.9.6", - "@tanstack/react-table": "^8.11.7", + "@next/font": "13.5.6", + "@reduxjs/toolkit": "1.9.7", + "@tanstack/react-table": "8.11.7", "@types/node": "20.6.2", - "@types/openlayers": "^4.6.20", + "@types/openlayers": "4.6.23", "@types/react": "18.2.21", "@types/react-dom": "18.2.7", "autoprefixer": "10.4.15", - "axios": "^1.5.1", - "axios-hooks": "^5.0.0", - "crypto-js": "^4.2.0", - "downshift": "^8.2.3", + "axios": "1.6.3", + "axios-hooks": "5.0.2", + "crypto-js": "4.2.0", + "downshift": "8.3.1", "eslint-config-next": "13.4.19", - "is-uuid": "^1.0.2", + "is-uuid": "1.0.2", "molart": "github:davidhoksza/MolArt", "next": "13.4.19", - "ol": "^8.1.0", - "polished": "^4.3.1", + "ol": "8.2.0", + "polished": "4.3.1", "postcss": "8.4.29", "query-string": "7.1.3", "react": "18.2.0", - "react-accessible-accordion": "^5.0.0", - "react-dnd": "^16.0.1", - "react-dnd-html5-backend": "^16.0.1", + "react-accessible-accordion": "5.0.0", + "react-autosuggest": "^10.1.0", + "react-dnd": "16.0.1", + "react-dnd-html5-backend": "16.0.1", "react-dom": "18.2.0", - "react-dropzone": "^14.2.3", - "react-redux": "^8.1.2", - "sonner": "^1.4.3", - "tailwind-merge": "^1.14.0", + "react-dropzone": "14.2.3", + "react-redux": "8.1.3", + "sonner": "1.4.3", + "tailwind-merge": "1.14.0", "tailwindcss": "3.3.3", - "ts-deepmerge": "^6.2.0", - "use-debounce": "^9.0.4", - "uuid": "^9.0.1", - "zod": "^3.22.2", - "zod-to-json-schema": "^3.22.4" + "ts-deepmerge": "6.2.0", + "use-debounce": "9.0.4", + "uuid": "9.0.1", + "zod": "3.22.4", + "zod-to-json-schema": "3.22.4" }, "devDependencies": { - "@commitlint/cli": "^17.7.1", - "@commitlint/config-conventional": "^17.7.0", - "@testing-library/jest-dom": "^6.1.3", - "@testing-library/react": "^14.0.0", - "@testing-library/user-event": "^14.5.2", - "@types/crypto-js": "^4.2.2", - "@types/is-uuid": "^1.0.2", - "@types/jest": "^29.5.5", - "@types/react-redux": "^7.1.26", - "@types/redux-mock-store": "^1.0.6", - "@types/uuid": "^9.0.8", - "@typescript-eslint/eslint-plugin": "^6.7.0", - "@typescript-eslint/parser": "^6.7.0", - "axios-mock-adapter": "^1.22.0", - "cypress": "^13.2.0", - "cz-conventional-changelog": "^3.3.0", - "eslint": "^8.49.0", - "eslint-config-airbnb": "^19.0.4", - "eslint-config-prettier": "^9.0.0", - "eslint-config-standard-with-typescript": "^39.0.0", - "eslint-plugin-import": "^2.28.1", - "eslint-plugin-jsx-a11y": "^6.7.1", - "eslint-plugin-n": "^16.1.0", - "eslint-plugin-prettier": "^5.0.1", - "eslint-plugin-promise": "^6.1.1", - "eslint-plugin-react": "^7.33.2", - "eslint-plugin-react-hooks": "^4.6.0", - "eslint-plugin-tailwindcss": "^3.13.0", - "eslint-plugin-testing-library": "^6.0.1", - "husky": "^8.0.0", - "jest": "^29.7.0", - "jest-canvas-mock": "^2.5.2", - "jest-environment-jsdom": "^29.7.0", - "jest-junit": "^16.0.0", - "jest-watch-typeahead": "^2.2.2", - "lint-staged": "^14.0.1", - "next-router-mock": "^0.9.10", - "prettier": "^3.0.3", - "prettier-2": "npm:prettier@^2", - "prettier-plugin-tailwindcss": "^0.5.6", - "redux-mock-store": "^1.5.4", - "redux-thunk": "^2.4.2", - "typescript": "^5.2.2", - "zod-fixture": "^2.5.0" + "@commitlint/cli": "17.8.1", + "@commitlint/config-conventional": "17.8.1", + "@testing-library/jest-dom": "6.1.6", + "@testing-library/react": "14.1.2", + "@testing-library/user-event": "14.5.2", + "@types/crypto-js": "4.2.2", + "@types/is-uuid": "1.0.2", + "@types/jest": "29.5.11", + "@types/react-autosuggest": "^10.1.11", + "@types/react-redux": "7.1.33", + "@types/redux-mock-store": "1.0.6", + "@types/uuid": "9.0.8", + "@typescript-eslint/eslint-plugin": "6.17.0", + "@typescript-eslint/parser": "6.17.0", + "axios-mock-adapter": "1.22.0", + "cypress": "13.6.2", + "cz-conventional-changelog": "3.3.0", + "eslint": "8.56.0", + "eslint-config-airbnb": "19.0.4", + "eslint-config-prettier": "9.1.0", + "eslint-config-standard-with-typescript": "39.1.1", + "eslint-plugin-import": "2.29.1", + "eslint-plugin-jsx-a11y": "6.8.0", + "eslint-plugin-n": "16.6.1", + "eslint-plugin-prettier": "5.1.2", + "eslint-plugin-promise": "6.1.1", + "eslint-plugin-react": "7.33.2", + "eslint-plugin-react-hooks": "4.6.0", + "eslint-plugin-tailwindcss": "3.13.1", + "eslint-plugin-testing-library": "6.2.0", + "husky": "8.0.3", + "jest": "29.7.0", + "jest-canvas-mock": "2.5.2", + "jest-environment-jsdom": "29.7.0", + "jest-junit": "16.0.0", + "jest-watch-typeahead": "2.2.2", + "lint-staged": "14.0.1", + "next-router-mock": "0.9.11", + "prettier": "3.1.1", + "prettier-2": "npm:prettier@2.8.8", + "prettier-plugin-tailwindcss": "0.5.6", + "redux-mock-store": "1.5.4", + "redux-thunk": "2.4.2", + "typescript": "5.3.3", + "zod-fixture": "2.5.1" } }, "node_modules/@aashutoshrathi/word-wrap": { @@ -2448,6 +2450,15 @@ "csstype": "^3.0.2" } }, + "node_modules/@types/react-autosuggest": { + "version": "10.1.11", + "resolved": "https://registry.npmjs.org/@types/react-autosuggest/-/react-autosuggest-10.1.11.tgz", + "integrity": "sha512-lneJrX/5TZJzKHPJ6UuUjsh9OfeyQHKYEVHyBh5Y7LeRbCZxyIsjBmpxdPy1iH++Ger0qcyW+phPpYH+g3naLA==", + "dev": true, + "dependencies": { + "@types/react": "*" + } + }, "node_modules/@types/react-dom": { "version": "18.2.7", "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-18.2.7.tgz", @@ -5330,6 +5341,11 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/es6-promise": { + "version": "4.2.8", + "resolved": "https://registry.npmjs.org/es6-promise/-/es6-promise-4.2.8.tgz", + "integrity": "sha512-HJDGx5daxeIvxdBxvG2cb9g4tEvwIk3i8+nhX0yGrYmZUzbkdg8QbDevheDB8gd0//uPj4c1EQua8Q+MViT0/w==" + }, "node_modules/escalade": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.1.1.tgz", @@ -11666,6 +11682,21 @@ "react-dom": "^16.3.3 || ^17.0.0 || ^18.0.0" } }, + "node_modules/react-autosuggest": { + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/react-autosuggest/-/react-autosuggest-10.1.0.tgz", + "integrity": "sha512-/azBHmc6z/31s/lBf6irxPf/7eejQdR0IqnZUzjdSibtlS8+Rw/R79pgDAo6Ft5QqCUTyEQ+f0FhL+1olDQ8OA==", + "dependencies": { + "es6-promise": "^4.2.8", + "prop-types": "^15.7.2", + "react-themeable": "^1.1.0", + "section-iterator": "^2.0.0", + "shallow-equal": "^1.2.1" + }, + "peerDependencies": { + "react": ">=16.3.0" + } + }, "node_modules/react-dnd": { "version": "16.0.1", "resolved": "https://registry.npmjs.org/react-dnd/-/react-dnd-16.0.1.tgz", @@ -11774,6 +11805,22 @@ } } }, + "node_modules/react-themeable": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/react-themeable/-/react-themeable-1.1.0.tgz", + "integrity": "sha512-kl5tQ8K+r9IdQXZd8WLa+xxYN04lLnJXRVhHfdgwsUJr/SlKJxIejoc9z9obEkx1mdqbTw1ry43fxEUwyD9u7w==", + "dependencies": { + "object-assign": "^3.0.0" + } + }, + "node_modules/react-themeable/node_modules/object-assign": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-3.0.0.tgz", + "integrity": "sha512-jHP15vXVGeVh1HuaA2wY6lxk+whK/x4KBG88VXeRma7CCun7iGD5qPc4eYykQ9sdQvg8jkwFKsSxHln2ybW3xQ==", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/read-cache": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/read-cache/-/read-cache-1.0.0.tgz", @@ -12260,6 +12307,11 @@ "loose-envify": "^1.1.0" } }, + "node_modules/section-iterator": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/section-iterator/-/section-iterator-2.0.0.tgz", + "integrity": "sha512-xvTNwcbeDayXotnV32zLb3duQsP+4XosHpb/F+tu6VzEZFmIjzPdNk6/O+QOOx5XTh08KL2ufdXeCO33p380pQ==" + }, "node_modules/semantic-ui-button": { "version": "2.2.12", "resolved": "https://registry.npmjs.org/semantic-ui-button/-/semantic-ui-button-2.2.12.tgz", @@ -12337,6 +12389,11 @@ "resolved": "https://registry.npmjs.org/setimmediate/-/setimmediate-1.0.5.tgz", "integrity": "sha512-MATJdZp8sLqDl/68LfQmbP8zKPLQNV6BIZoIgrscFDQ+RsvK/BxeDQOgyxKKoh0y/8h3BqVFnCqQ/gd+reiIXA==" }, + "node_modules/shallow-equal": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/shallow-equal/-/shallow-equal-1.2.1.tgz", + "integrity": "sha512-S4vJDjHHMBaiZuT9NPb616CSmLf618jawtv3sufLl6ivK8WocjAo58cXwbRV1cgqxH0Qbv+iUt6m05eqEa2IRA==" + }, "node_modules/shebang-command": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", @@ -15877,6 +15934,15 @@ "csstype": "^3.0.2" } }, + "@types/react-autosuggest": { + "version": "10.1.11", + "resolved": "https://registry.npmjs.org/@types/react-autosuggest/-/react-autosuggest-10.1.11.tgz", + "integrity": "sha512-lneJrX/5TZJzKHPJ6UuUjsh9OfeyQHKYEVHyBh5Y7LeRbCZxyIsjBmpxdPy1iH++Ger0qcyW+phPpYH+g3naLA==", + "dev": true, + "requires": { + "@types/react": "*" + } + }, "@types/react-dom": { "version": "18.2.7", "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-18.2.7.tgz", @@ -18012,6 +18078,11 @@ "is-symbol": "^1.0.2" } }, + "es6-promise": { + "version": "4.2.8", + "resolved": "https://registry.npmjs.org/es6-promise/-/es6-promise-4.2.8.tgz", + "integrity": "sha512-HJDGx5daxeIvxdBxvG2cb9g4tEvwIk3i8+nhX0yGrYmZUzbkdg8QbDevheDB8gd0//uPj4c1EQua8Q+MViT0/w==" + }, "escalade": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.1.1.tgz", @@ -22449,6 +22520,18 @@ "integrity": "sha512-MT2obYpTgLIIfPr9d7hEyvPB5rg8uJcHpgA83JSRlEUHvzH48+8HJPvzSs+nM+XprTugDgLfhozO5qyJpBvYRQ==", "requires": {} }, + "react-autosuggest": { + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/react-autosuggest/-/react-autosuggest-10.1.0.tgz", + "integrity": "sha512-/azBHmc6z/31s/lBf6irxPf/7eejQdR0IqnZUzjdSibtlS8+Rw/R79pgDAo6Ft5QqCUTyEQ+f0FhL+1olDQ8OA==", + "requires": { + "es6-promise": "^4.2.8", + "prop-types": "^15.7.2", + "react-themeable": "^1.1.0", + "section-iterator": "^2.0.0", + "shallow-equal": "^1.2.1" + } + }, "react-dnd": { "version": "16.0.1", "resolved": "https://registry.npmjs.org/react-dnd/-/react-dnd-16.0.1.tgz", @@ -22506,6 +22589,21 @@ "use-sync-external-store": "^1.0.0" } }, + "react-themeable": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/react-themeable/-/react-themeable-1.1.0.tgz", + "integrity": "sha512-kl5tQ8K+r9IdQXZd8WLa+xxYN04lLnJXRVhHfdgwsUJr/SlKJxIejoc9z9obEkx1mdqbTw1ry43fxEUwyD9u7w==", + "requires": { + "object-assign": "^3.0.0" + }, + "dependencies": { + "object-assign": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-3.0.0.tgz", + "integrity": "sha512-jHP15vXVGeVh1HuaA2wY6lxk+whK/x4KBG88VXeRma7CCun7iGD5qPc4eYykQ9sdQvg8jkwFKsSxHln2ybW3xQ==" + } + } + }, "read-cache": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/read-cache/-/read-cache-1.0.0.tgz", @@ -22868,6 +22966,11 @@ "loose-envify": "^1.1.0" } }, + "section-iterator": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/section-iterator/-/section-iterator-2.0.0.tgz", + "integrity": "sha512-xvTNwcbeDayXotnV32zLb3duQsP+4XosHpb/F+tu6VzEZFmIjzPdNk6/O+QOOx5XTh08KL2ufdXeCO33p380pQ==" + }, "semantic-ui-button": { "version": "2.2.12", "resolved": "https://registry.npmjs.org/semantic-ui-button/-/semantic-ui-button-2.2.12.tgz", @@ -22932,6 +23035,11 @@ "resolved": "https://registry.npmjs.org/setimmediate/-/setimmediate-1.0.5.tgz", "integrity": "sha512-MATJdZp8sLqDl/68LfQmbP8zKPLQNV6BIZoIgrscFDQ+RsvK/BxeDQOgyxKKoh0y/8h3BqVFnCqQ/gd+reiIXA==" }, + "shallow-equal": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/shallow-equal/-/shallow-equal-1.2.1.tgz", + "integrity": "sha512-S4vJDjHHMBaiZuT9NPb616CSmLf618jawtv3sufLl6ivK8WocjAo58cXwbRV1cgqxH0Qbv+iUt6m05eqEa2IRA==" + }, "shebang-command": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", diff --git a/package.json b/package.json index 7446c416ffab49ba8b230715f99607f328e93cce..e6b1adc41339ca81db3eea08ca1f05331a9ddc19 100644 --- a/package.json +++ b/package.json @@ -45,6 +45,7 @@ "query-string": "7.1.3", "react": "18.2.0", "react-accessible-accordion": "5.0.0", + "react-autosuggest": "^10.1.0", "react-dnd": "16.0.1", "react-dnd-html5-backend": "16.0.1", "react-dom": "18.2.0", @@ -68,6 +69,7 @@ "@types/crypto-js": "4.2.2", "@types/is-uuid": "1.0.2", "@types/jest": "29.5.11", + "@types/react-autosuggest": "^10.1.11", "@types/react-redux": "7.1.33", "@types/redux-mock-store": "1.0.6", "@types/uuid": "9.0.8", diff --git a/src/components/FunctionalArea/TopBar/SearchBar/SearchBar.component.tsx b/src/components/FunctionalArea/TopBar/SearchBar/SearchBar.component.tsx index 128d5b2365f02947d0190760654c736b8dbc0c50..fc28ddc3b711bc72190ee2aa0be5538c74f6165f 100644 --- a/src/components/FunctionalArea/TopBar/SearchBar/SearchBar.component.tsx +++ b/src/components/FunctionalArea/TopBar/SearchBar/SearchBar.component.tsx @@ -1,3 +1,8 @@ +import { + autocompleteChemicalSelector, + autocompleteDrugSelector, + autocompleteSearchSelector, +} from '@/redux/autocomplete/autocomplete.selectors'; import { currentSelectedSearchElement, searchDrawerOpenSelector, @@ -14,23 +19,33 @@ import { import { getSearchData } from '@/redux/search/search.thunks'; import Image from 'next/image'; import { useRouter } from 'next/router'; -import { ChangeEvent, KeyboardEvent, useCallback, useEffect, useState } from 'react'; +import { useCallback, KeyboardEvent, useEffect, useState } from 'react'; import { useSelector } from 'react-redux'; -import { ONE, ZERO } from '@/constants/common'; +import { FIVE, ONE, ZERO } from '@/constants/common'; +import Autosuggest from 'react-autosuggest'; import { clearEntityNumberData } from '@/redux/entityNumber/entityNumber.slice'; import { getDefaultSearchTab, getSearchValuesArrayAndTrimToSeven } from './SearchBar.utils'; +import './autocomplete.css'; + +type Suggestion = { + name: string; +}; const ENTER_KEY_CODE = 'Enter'; export const SearchBar = (): JSX.Element => { const [searchValue, setSearchValue] = useState<string>(''); + const [filteredSuggestions, setFilteredSuggestions] = useState<Suggestion[]>([]); const isPendingSearchStatus = useSelector(isPendingSearchStatusSelector); const isSearchDrawerOpen = useSelector(searchDrawerOpenSelector); const isPerfectMatch = useSelector(perfectMatchSelector); const searchValueState = useSelector(searchValueSelector); - const currentTab = useSelector(currentSelectedSearchElement); + const searchAutocompleteState = useSelector(autocompleteSearchSelector); + const drugAutocompleteState = useSelector(autocompleteDrugSelector); + const chemicalAutocompleteState = useSelector(autocompleteChemicalSelector); const dispatch = useAppDispatch(); const router = useRouter(); + const currentTab = useSelector(currentSelectedSearchElement); const updateSearchValueFromQueryParam = useCallback((): void => { const { searchValue: searchValueQueryParam } = router.query; @@ -53,10 +68,6 @@ export const SearchBar = (): JSX.Element => { } }; - const onSearchChange = (event: ChangeEvent<HTMLInputElement>): void => { - setSearchValue(event.target.value); - }; - const onSearchClick = (): void => { const searchValues = getSearchValuesArrayAndTrimToSeven(searchValue); @@ -82,6 +93,60 @@ export const SearchBar = (): JSX.Element => { openSearchDrawerIfClosed(currentTab); }; + const suggestions = searchAutocompleteState.searchValues + .concat(drugAutocompleteState.searchValues, chemicalAutocompleteState.searchValues) + .map(entry => { + return { name: entry }; + }) + .sort((a: Suggestion, b: Suggestion) => a.name.localeCompare(b.name)); + + const getSuggestions = (value: string): Suggestion[] => { + const inputValue = value.trim().toLowerCase(); + const inputLength = inputValue.length; + if (inputLength === ZERO) { + return []; + } + return suggestions + .filter(lang => lang.name.toLowerCase().slice(ZERO, inputLength) === inputValue) + .slice(ZERO, FIVE); + }; + + const renderSuggestion = (suggestion: Suggestion): JSX.Element => { + return <div>{suggestion.name}</div>; + }; + + // Autosuggest will call this function every time you need to update suggestions. + // You already implemented this logic above, so just use it. + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + const onSuggestionsFetchRequested = ({ value }): void => { + setFilteredSuggestions(getSuggestions(value)); + }; + + // Autosuggest will call this function every time you need to clear suggestions. + const onSuggestionsClearRequested = (): void => { + setFilteredSuggestions([]); + }; + + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + const onChange = (event, { newValue }): void => { + setSearchValue(newValue); + }; + // Autosuggest will pass through all these props to the input. + const inputProps = { + placeholder: '', + value: searchValue, + name: 'search-input', + onChange, + onKeyDown: handleKeyPress, + onClick: handleSearchClick, + 'data-testid': 'search-input', + disabled: isPendingSearchStatus, + }; + + const getSuggestionValue = (suggestion: Suggestion): string => suggestion.name; + useEffect(() => { updateSearchValueFromQueryParam(); }, [updateSearchValueFromQueryParam]); @@ -89,19 +154,34 @@ export const SearchBar = (): JSX.Element => { clearSearchValueFromClearedState(); }, [clearSearchValueFromClearedState]); + const theme = { + input: + 'h-9 w-72 rounded-[64px] border border-transparent bg-cultured px-4 py-2.5 text-xs font-medium text-font-400 outline-none hover:border-greyscale-600 focus:border-greyscale-600', + container: 'react-autosuggest__container', + inputFocused: 'react-autosuggest__input--focused', + suggestionsContainer: 'react-autosuggest__suggestions-container', + suggestionsContainerOpen: 'react-autosuggest__suggestions-container--open', + suggestionsList: 'react-autosuggest__suggestions-list', + suggestion: 'react-autosuggest__suggestion', + suggestionFirst: 'react-autosuggest__suggestion--first', + suggestionHighlighted: 'bg-primary-100', + sectionContainer: 'react-autosuggest__section-container', + sectionContainerFirst: 'react-autosuggest__section-container--first', + sectionTitle: 'react-autosuggest__section-title', + }; + return ( - <div className="relative" data-testid="search-bar"> - <input - value={searchValue} - name="search-input" - aria-label="search-input" - data-testid="search-input" - onKeyDown={handleKeyPress} - onChange={onSearchChange} - disabled={isPendingSearchStatus} - onClick={handleSearchClick} - className="h-9 w-72 rounded-[64px] border border-transparent bg-cultured px-4 py-2.5 text-xs font-medium text-font-400 outline-none hover:border-greyscale-600 focus:border-greyscale-600" + <div className="relative mt-5" data-testid="search-bar"> + <Autosuggest + suggestions={filteredSuggestions} + onSuggestionsFetchRequested={onSuggestionsFetchRequested} + onSuggestionsClearRequested={onSuggestionsClearRequested} + getSuggestionValue={getSuggestionValue} + renderSuggestion={renderSuggestion} + inputProps={inputProps} + theme={theme} /> + <button disabled={isPendingSearchStatus} type="button" diff --git a/src/components/FunctionalArea/TopBar/SearchBar/autocomplete.css b/src/components/FunctionalArea/TopBar/SearchBar/autocomplete.css new file mode 100644 index 0000000000000000000000000000000000000000..294dbea4fb31ff166e36bb14b7123095686dab79 --- /dev/null +++ b/src/components/FunctionalArea/TopBar/SearchBar/autocomplete.css @@ -0,0 +1,55 @@ +.react-autosuggest__container { + position: relative; +} + +.react-autosuggest__input { + width: 100%; + height: 36px; + padding: 10px; + border: 1px solid; + border-radius: 4px; + border-bottom-right-radius: 0; + border-top-right-radius: 0; +} + +.react-autosuggest__input--focused { + outline: none; +} + +.react-autosuggest__input--open { + border-bottom-left-radius: 0; + border-bottom-right-radius: 0; +} + +.react-autosuggest__suggestions-container { + display: none; +} + +.react-autosuggest__suggestions-container--open { + display: block; + position: absolute; + top: 33px; + width: 100%; + min-width: 160px; + margin-left: 1px; + background-color: #ffffff; + border-radius: 0 0 4px 4px; + z-index: 2; + box-shadow: 0 6px 12px rgba(0, 0, 0, 0.175); + background-clip: padding-box; +} + +.react-autosuggest__suggestions-list { + margin: 0; + padding: 0; + list-style-type: none; +} + +.react-autosuggest__suggestion { + cursor: pointer; + padding: 10px 20px; +} + +.react-autosuggest__suggestion--highlighted { + background-color: blue; +} diff --git a/src/components/FunctionalArea/TopBar/TopBar.component.test.tsx b/src/components/FunctionalArea/TopBar/TopBar.component.test.tsx index 09a15505096ac668987969dc11ba86e21dda3107..0fb6117915a0eae1c6631f65133822a9bf64fc12 100644 --- a/src/components/FunctionalArea/TopBar/TopBar.component.test.tsx +++ b/src/components/FunctionalArea/TopBar/TopBar.component.test.tsx @@ -20,6 +20,7 @@ import { SEARCH_STATE_INITIAL_MOCK } from '@/redux/search/search.mock'; import { ZOD_SEED } from '@/constants'; import { createFixture } from 'zod-fixture'; import { overviewImageView } from '@/models/overviewImageView'; +import { AUTOCOMPLETE_INITIAL_STATE } from '@/redux/autocomplete/autocomplete.constants'; import { TopBar } from './TopBar.component'; const renderComponent = (initialStoreState: InitialStoreState = {}): { store: StoreType } => { @@ -117,6 +118,9 @@ describe('TopBar - component', () => { user: USER_INITIAL_STATE_MOCK, map: initialMapStateFixture, search: SEARCH_STATE_INITIAL_MOCK, + autocompleteSearch: AUTOCOMPLETE_INITIAL_STATE, + autocompleteDrug: AUTOCOMPLETE_INITIAL_STATE, + autocompleteChemical: AUTOCOMPLETE_INITIAL_STATE, backgrounds: { ...BACKGROUND_INITIAL_STATE_MOCK, data: BACKGROUNDS_MOCK }, }); @@ -133,6 +137,9 @@ describe('TopBar - component', () => { renderComponentWithActionListener({ user: USER_INITIAL_STATE_MOCK, search: SEARCH_STATE_INITIAL_MOCK, + autocompleteSearch: AUTOCOMPLETE_INITIAL_STATE, + autocompleteDrug: AUTOCOMPLETE_INITIAL_STATE, + autocompleteChemical: AUTOCOMPLETE_INITIAL_STATE, drawer: initialStateFixture, project: { ...PROJECT_STATE_INITIAL_MOCK, diff --git a/src/constants/common.ts b/src/constants/common.ts index 9bdcd6487cb3cd51ff146c5a2d60796ddccb4c99..3480fb272b2a221814efbb1d0d04ff70ada7dbd9 100644 --- a/src/constants/common.ts +++ b/src/constants/common.ts @@ -12,6 +12,8 @@ export const SECOND_ARRAY_ELEMENT = 1; export const TWO = 2; +export const FIVE = 5; + export const THIRD_ARRAY_ELEMENT = 2; export const NOOP = (): void => {}; diff --git a/src/models/autocompleteSchema.ts b/src/models/autocompleteSchema.ts new file mode 100644 index 0000000000000000000000000000000000000000..1f8e1095d21fb7906b4f5f57f8c76628b1bd8de4 --- /dev/null +++ b/src/models/autocompleteSchema.ts @@ -0,0 +1,3 @@ +import { z } from 'zod'; + +export const autocompleteSchema = z.array(z.string()); diff --git a/src/redux/apiPath.ts b/src/redux/apiPath.ts index 29aa41741fa7d60ed7c1d72aa6c67709749fd7aa..423aefbfbe19d9c5ab79eb7077d6cd0af7e0b2e0 100644 --- a/src/redux/apiPath.ts +++ b/src/redux/apiPath.ts @@ -109,4 +109,9 @@ export const apiPath = { getComments: (): string => `projects/${PROJECT_ID}/comments/models/*/`, addComment: (modelId: number, x: number, y: number): string => `projects/${PROJECT_ID}/comments/models/${modelId}/points/${x},${y}/`, + + getSearchAutocomplete: (): string => + `projects/${PROJECT_ID}/models/*/bioEntities/suggestedQueryList`, + getDrugAutocomplete: (): string => `projects/${PROJECT_ID}/drugs/suggestedQueryList`, + getChemicalAutocomplete: (): string => `projects/${PROJECT_ID}/chemicals/suggestedQueryList`, }; diff --git a/src/redux/autocomplete/autocomplete.constants.ts b/src/redux/autocomplete/autocomplete.constants.ts new file mode 100644 index 0000000000000000000000000000000000000000..486a494b599f548981dd4978f2099fc76e5cb445 --- /dev/null +++ b/src/redux/autocomplete/autocomplete.constants.ts @@ -0,0 +1,6 @@ +import { AutocompleteState } from './autocomplete.types'; + +export const AUTOCOMPLETE_INITIAL_STATE: AutocompleteState = { + searchValues: [''], + loading: 'idle', +}; diff --git a/src/redux/autocomplete/autocomplete.reducers.ts b/src/redux/autocomplete/autocomplete.reducers.ts new file mode 100644 index 0000000000000000000000000000000000000000..a36fa9a370add7af40898ef2cac8c441aac40072 --- /dev/null +++ b/src/redux/autocomplete/autocomplete.reducers.ts @@ -0,0 +1,55 @@ +import { AutocompleteState } from '@/redux/autocomplete/autocomplete.types'; +import { ActionReducerMapBuilder } from '@reduxjs/toolkit'; +import { + getChemicalAutocomplete, + getDrugAutocomplete, + getSearchAutocomplete, +} from '@/redux/autocomplete/autocomplete.thunks'; + +export const getSearchAutocompleteReducer = ( + builder: ActionReducerMapBuilder<AutocompleteState>, +): void => { + builder.addCase(getSearchAutocomplete.pending, state => { + state.loading = 'pending'; + }); + builder.addCase(getSearchAutocomplete.fulfilled, (state, action) => { + state.searchValues = action.payload ? action.payload : []; + state.loading = 'succeeded'; + }); + builder.addCase(getSearchAutocomplete.rejected, state => { + state.loading = 'failed'; + // TODO: error management to be discussed in the team + }); +}; + +export const getDrugAutocompleteReducer = ( + builder: ActionReducerMapBuilder<AutocompleteState>, +): void => { + builder.addCase(getDrugAutocomplete.pending, state => { + state.loading = 'pending'; + }); + builder.addCase(getDrugAutocomplete.fulfilled, (state, action) => { + state.searchValues = action.payload ? action.payload : []; + state.loading = 'succeeded'; + }); + builder.addCase(getDrugAutocomplete.rejected, state => { + state.loading = 'failed'; + // TODO: error management to be discussed in the team + }); +}; + +export const getChemicalAutocompleteReducer = ( + builder: ActionReducerMapBuilder<AutocompleteState>, +): void => { + builder.addCase(getChemicalAutocomplete.pending, state => { + state.loading = 'pending'; + }); + builder.addCase(getChemicalAutocomplete.fulfilled, (state, action) => { + state.searchValues = action.payload ? action.payload : []; + state.loading = 'succeeded'; + }); + builder.addCase(getChemicalAutocomplete.rejected, state => { + state.loading = 'failed'; + // TODO: error management to be discussed in the team + }); +}; diff --git a/src/redux/autocomplete/autocomplete.selectors.ts b/src/redux/autocomplete/autocomplete.selectors.ts new file mode 100644 index 0000000000000000000000000000000000000000..d1ee002ae613cfa11a49029ca8c5b12c9bb092d1 --- /dev/null +++ b/src/redux/autocomplete/autocomplete.selectors.ts @@ -0,0 +1,17 @@ +import { rootSelector } from '@/redux/root/root.selectors'; +import { createSelector } from '@reduxjs/toolkit'; + +export const autocompleteSearchSelector = createSelector( + rootSelector, + state => state.autocompleteSearch, +); + +export const autocompleteDrugSelector = createSelector( + rootSelector, + state => state.autocompleteDrug, +); + +export const autocompleteChemicalSelector = createSelector( + rootSelector, + state => state.autocompleteChemical, +); diff --git a/src/redux/autocomplete/autocomplete.slice.ts b/src/redux/autocomplete/autocomplete.slice.ts new file mode 100644 index 0000000000000000000000000000000000000000..6d042065c4d7edd5ceaeb5a6c3f4059df4e9aa8a --- /dev/null +++ b/src/redux/autocomplete/autocomplete.slice.ts @@ -0,0 +1,40 @@ +import { createSlice } from '@reduxjs/toolkit'; +import { AUTOCOMPLETE_INITIAL_STATE } from '@/redux/autocomplete/autocomplete.constants'; +import { + getChemicalAutocompleteReducer, + getDrugAutocompleteReducer, + getSearchAutocompleteReducer, +} from '@/redux/autocomplete/autocomplete.reducers'; + +export const autocompleteSearchSlice = createSlice({ + name: 'autocompleteSearch', + initialState: AUTOCOMPLETE_INITIAL_STATE, + reducers: {}, + extraReducers(builder) { + getSearchAutocompleteReducer(builder); + }, +}); + +export const autocompleteSearchReducer = autocompleteSearchSlice.reducer; + +export const autocompleteDrugSlice = createSlice({ + name: 'autocompleteDrug', + initialState: AUTOCOMPLETE_INITIAL_STATE, + reducers: {}, + extraReducers(builder) { + getDrugAutocompleteReducer(builder); + }, +}); + +export const autocompleteDrugReducer = autocompleteDrugSlice.reducer; + +export const autocompleteChemicalSlice = createSlice({ + name: 'autocompleteChemical', + initialState: AUTOCOMPLETE_INITIAL_STATE, + reducers: {}, + extraReducers(builder) { + getChemicalAutocompleteReducer(builder); + }, +}); + +export const autocompleteChemicalReducer = autocompleteChemicalSlice.reducer; diff --git a/src/redux/autocomplete/autocomplete.thunks.ts b/src/redux/autocomplete/autocomplete.thunks.ts new file mode 100644 index 0000000000000000000000000000000000000000..d9204b9e906e38e46c6d2478dec8ce706db734ef --- /dev/null +++ b/src/redux/autocomplete/autocomplete.thunks.ts @@ -0,0 +1,68 @@ +import { ThunkConfig } from '@/types/store'; +import { createAsyncThunk } from '@reduxjs/toolkit'; +import { getError } from '@/utils/error-report/getError'; +import { axiosInstance } from '@/services/api/utils/axiosInstance'; +import { apiPath } from '@/redux/apiPath'; +import { validateDataUsingZodSchema } from '@/utils/validateDataUsingZodSchema'; +import { autocompleteSchema } from '@/models/autocompleteSchema'; +import type { RootState } from '../store'; + +export const getSearchAutocomplete = createAsyncThunk< + string[] | undefined, + void, + { state: RootState } & ThunkConfig +>( + 'project/getSearchAutocomplete', + // eslint-disable-next-line consistent-return + async () => { + try { + const response = await axiosInstance.get<string[]>(apiPath.getSearchAutocomplete()); + + const isDataValid = validateDataUsingZodSchema(response.data, autocompleteSchema); + + return isDataValid ? response.data : undefined; + } catch (error) { + return Promise.reject(getError({ error })); + } + }, +); + +export const getDrugAutocomplete = createAsyncThunk< + string[] | undefined, + void, + { state: RootState } & ThunkConfig +>( + 'project/getDrugAutocomplete', + // eslint-disable-next-line consistent-return + async () => { + try { + const response = await axiosInstance.get<string[]>(apiPath.getDrugAutocomplete()); + + const isDataValid = validateDataUsingZodSchema(response.data, autocompleteSchema); + + return isDataValid ? response.data : undefined; + } catch (error) { + return Promise.reject(getError({ error })); + } + }, +); + +export const getChemicalAutocomplete = createAsyncThunk< + string[] | undefined, + void, + { state: RootState } & ThunkConfig +>( + 'project/getChemicalAutocomplete', + // eslint-disable-next-line consistent-return + async () => { + try { + const response = await axiosInstance.get<string[]>(apiPath.getChemicalAutocomplete()); + + const isDataValid = validateDataUsingZodSchema(response.data, autocompleteSchema); + + return isDataValid ? response.data : undefined; + } catch (error) { + return Promise.reject(getError({ error })); + } + }, +); diff --git a/src/redux/autocomplete/autocomplete.types.ts b/src/redux/autocomplete/autocomplete.types.ts new file mode 100644 index 0000000000000000000000000000000000000000..4d359029df20f73f4ad603d96c5b0fcbaf756bee --- /dev/null +++ b/src/redux/autocomplete/autocomplete.types.ts @@ -0,0 +1,6 @@ +import { Loading } from '@/types/loadingState'; + +export interface AutocompleteState { + searchValues: string[]; + loading: Loading; +} diff --git a/src/redux/root/init.thunks.ts b/src/redux/root/init.thunks.ts index 18dabadf7d56d2deb7dc92b21d8ccbedce474aed..9387f79cb62e54c63200f673d2f09781e412faed 100644 --- a/src/redux/root/init.thunks.ts +++ b/src/redux/root/init.thunks.ts @@ -7,6 +7,11 @@ import { PluginsManager } from '@/services/pluginsManager'; import { createAsyncThunk } from '@reduxjs/toolkit'; import { ZERO } from '@/constants/common'; import { getConstant } from '@/redux/constant/constant.thunks'; +import { + getChemicalAutocomplete, + getDrugAutocomplete, + getSearchAutocomplete, +} from '@/redux/autocomplete/autocomplete.thunks'; import { getAllBackgroundsByProjectId } from '../backgrounds/backgrounds.thunks'; import { getConfiguration, getConfigurationOptions } from '../configuration/configuration.thunks'; import { @@ -86,6 +91,11 @@ export const fetchInitialAppData = createAsyncThunk< // Fetch plugins list dispatch(getAllPlugins()); + // autocomplete + dispatch(getSearchAutocomplete()); + dispatch(getDrugAutocomplete()); + dispatch(getChemicalAutocomplete()); + /** Trigger search */ if (queryData.searchValue) { dispatch(setPerfectMatch(queryData.perfectMatch)); diff --git a/src/redux/root/root.fixtures.ts b/src/redux/root/root.fixtures.ts index 421151bd9d870052cc5137a3f66f7c3451b494e8..e260f230ea8bd4a0060fe58399cb9ef2b9965087 100644 --- a/src/redux/root/root.fixtures.ts +++ b/src/redux/root/root.fixtures.ts @@ -2,6 +2,7 @@ import { CONSTANT_INITIAL_STATE } from '@/redux/constant/constant.adapter'; import { PROJECTS_STATE_INITIAL_MOCK } from '@/redux/projects/projects.mock'; import { OAUTH_INITIAL_STATE_MOCK } from '@/redux/oauth/oauth.mock'; import { COMMENT_INITIAL_STATE_MOCK } from '@/redux/comment/comment.mock'; +import { AUTOCOMPLETE_INITIAL_STATE } from '@/redux/autocomplete/autocomplete.constants'; import { BACKGROUND_INITIAL_STATE_MOCK } from '../backgrounds/background.mock'; import { BIOENTITY_INITIAL_STATE_MOCK } from '../bioEntity/bioEntity.mock'; import { CHEMICALS_INITIAL_STATE_MOCK } from '../chemicals/chemicals.mock'; @@ -30,6 +31,9 @@ import { RootState } from '../store'; import { USER_INITIAL_STATE_MOCK } from '../user/user.mock'; export const INITIAL_STORE_STATE_MOCK: RootState = { + autocompleteSearch: AUTOCOMPLETE_INITIAL_STATE, + autocompleteDrug: AUTOCOMPLETE_INITIAL_STATE, + autocompleteChemical: AUTOCOMPLETE_INITIAL_STATE, search: SEARCH_STATE_INITIAL_MOCK, project: PROJECT_STATE_INITIAL_MOCK, projects: PROJECTS_STATE_INITIAL_MOCK, diff --git a/src/redux/store.ts b/src/redux/store.ts index ebc65642cab5db9e0a830989779bdaaceec80450..f11f6e57248b0a405241aa54ee29d321e1b29ffc 100644 --- a/src/redux/store.ts +++ b/src/redux/store.ts @@ -18,6 +18,11 @@ import projectsReducer from '@/redux/projects/projects.slice'; import reactionsReducer from '@/redux/reactions/reactions.slice'; import searchReducer from '@/redux/search/search.slice'; import userReducer from '@/redux/user/user.slice'; +import { + autocompleteChemicalReducer, + autocompleteDrugReducer, + autocompleteSearchReducer, +} from '@/redux/autocomplete/autocomplete.slice'; import { AnyAction, ListenerEffectAPI, @@ -38,6 +43,9 @@ import publicationsReducer from './publications/publications.slice'; import statisticsReducer from './statistics/statistics.slice'; export const reducers = { + autocompleteSearch: autocompleteSearchReducer, + autocompleteDrug: autocompleteDrugReducer, + autocompleteChemical: autocompleteChemicalReducer, search: searchReducer, project: projectReducer, projects: projectsReducer,