From 30ef93fc90291ad55d637ad10c5d6327722c6b42 Mon Sep 17 00:00:00 2001 From: Piotr Gawron <p.gawron@atcomp.pl> Date: Wed, 4 Sep 2024 10:35:35 +0200 Subject: [PATCH] autocomplete for search queries --- .../TopBar/SearchBar/SearchBar.component.tsx | 22 ++++++++++----- .../TopBar/TopBar.component.test.tsx | 3 ++ src/constants/common.ts | 2 ++ src/models/autocompleteSchema.ts | 3 ++ src/redux/apiPath.ts | 3 ++ .../autocomplete/autocomplete.constants.ts | 6 ++++ .../autocomplete/autocomplete.reducers.ts | 19 +++++++++++++ .../autocomplete/autocomplete.selectors.ts | 4 +++ src/redux/autocomplete/autocomplete.slice.ts | 14 ++++++++++ src/redux/autocomplete/autocomplete.thunks.ts | 28 +++++++++++++++++++ src/redux/autocomplete/autocomplete.types.ts | 6 ++++ src/redux/root/init.thunks.ts | 4 +++ src/redux/root/root.fixtures.ts | 2 ++ src/redux/store.ts | 2 ++ 14 files changed, 111 insertions(+), 7 deletions(-) create mode 100644 src/models/autocompleteSchema.ts create mode 100644 src/redux/autocomplete/autocomplete.constants.ts create mode 100644 src/redux/autocomplete/autocomplete.reducers.ts create mode 100644 src/redux/autocomplete/autocomplete.selectors.ts create mode 100644 src/redux/autocomplete/autocomplete.slice.ts create mode 100644 src/redux/autocomplete/autocomplete.thunks.ts create mode 100644 src/redux/autocomplete/autocomplete.types.ts diff --git a/src/components/FunctionalArea/TopBar/SearchBar/SearchBar.component.tsx b/src/components/FunctionalArea/TopBar/SearchBar/SearchBar.component.tsx index d5a4c4a5..8ef41e48 100644 --- a/src/components/FunctionalArea/TopBar/SearchBar/SearchBar.component.tsx +++ b/src/components/FunctionalArea/TopBar/SearchBar/SearchBar.component.tsx @@ -1,3 +1,4 @@ +import { autocompleteSelector } from '@/redux/autocomplete/autocomplete.selectors'; import { currentSelectedSearchElement, searchDrawerOpenSelector, @@ -16,7 +17,7 @@ import Image from 'next/image'; import { useRouter } from 'next/router'; 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'; @@ -35,6 +36,7 @@ export const SearchBar = (): JSX.Element => { const isSearchDrawerOpen = useSelector(searchDrawerOpenSelector); const isPerfectMatch = useSelector(perfectMatchSelector); const searchValueState = useSelector(searchValueSelector); + const searchAutocompleteState = useSelector(autocompleteSelector); const dispatch = useAppDispatch(); const router = useRouter(); const currentTab = useSelector(currentSelectedSearchElement); @@ -85,15 +87,21 @@ export const SearchBar = (): JSX.Element => { openSearchDrawerIfClosed(currentTab); }; - const suggestions = [{ name: 'alpha' }, { name: 'amigo' }, { name: 'beta' }, { name: 'omega' }]; + // eslint-disable-next-line no-console + console.log(searchAutocompleteState.searchValues); + const suggestions = searchAutocompleteState.searchValues.map(entry => { + return { name: entry }; + }); - const getSuggestions = function (value: string): Suggestion[] { + const getSuggestions = (value: string): Suggestion[] => { const inputValue = value.trim().toLowerCase(); const inputLength = inputValue.length; - - return inputLength === ZERO - ? [] - : suggestions.filter(lang => lang.name.toLowerCase().slice(ZERO, inputLength) === inputValue); + if (inputLength === ZERO) { + return []; + } + return suggestions + .filter(lang => lang.name.toLowerCase().slice(ZERO, inputLength) === inputValue) + .slice(ZERO, FIVE); }; const renderSuggestion = (suggestion: Suggestion): JSX.Element => { diff --git a/src/components/FunctionalArea/TopBar/TopBar.component.test.tsx b/src/components/FunctionalArea/TopBar/TopBar.component.test.tsx index 09a15505..d5428a92 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,7 @@ describe('TopBar - component', () => { user: USER_INITIAL_STATE_MOCK, map: initialMapStateFixture, search: SEARCH_STATE_INITIAL_MOCK, + autocomplete: AUTOCOMPLETE_INITIAL_STATE, backgrounds: { ...BACKGROUND_INITIAL_STATE_MOCK, data: BACKGROUNDS_MOCK }, }); @@ -133,6 +135,7 @@ describe('TopBar - component', () => { renderComponentWithActionListener({ user: USER_INITIAL_STATE_MOCK, search: SEARCH_STATE_INITIAL_MOCK, + autocomplete: AUTOCOMPLETE_INITIAL_STATE, drawer: initialStateFixture, project: { ...PROJECT_STATE_INITIAL_MOCK, diff --git a/src/constants/common.ts b/src/constants/common.ts index 9bdcd648..3480fb27 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 00000000..1f8e1095 --- /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 29aa4174..b8002874 100644 --- a/src/redux/apiPath.ts +++ b/src/redux/apiPath.ts @@ -109,4 +109,7 @@ 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`, }; diff --git a/src/redux/autocomplete/autocomplete.constants.ts b/src/redux/autocomplete/autocomplete.constants.ts new file mode 100644 index 00000000..486a494b --- /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 00000000..8fb6ce0e --- /dev/null +++ b/src/redux/autocomplete/autocomplete.reducers.ts @@ -0,0 +1,19 @@ +import { AutocompleteState } from '@/redux/autocomplete/autocomplete.types'; +import { ActionReducerMapBuilder } from '@reduxjs/toolkit'; +import { 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 + }); +}; diff --git a/src/redux/autocomplete/autocomplete.selectors.ts b/src/redux/autocomplete/autocomplete.selectors.ts new file mode 100644 index 00000000..6d6b97bc --- /dev/null +++ b/src/redux/autocomplete/autocomplete.selectors.ts @@ -0,0 +1,4 @@ +import { rootSelector } from '@/redux/root/root.selectors'; +import { createSelector } from '@reduxjs/toolkit'; + +export const autocompleteSelector = createSelector(rootSelector, state => state.autocomplete); diff --git a/src/redux/autocomplete/autocomplete.slice.ts b/src/redux/autocomplete/autocomplete.slice.ts new file mode 100644 index 00000000..4cb0f5b1 --- /dev/null +++ b/src/redux/autocomplete/autocomplete.slice.ts @@ -0,0 +1,14 @@ +import { createSlice } from '@reduxjs/toolkit'; +import { AUTOCOMPLETE_INITIAL_STATE } from '@/redux/autocomplete/autocomplete.constants'; +import { getSearchAutocompleteReducer } from '@/redux/autocomplete/autocomplete.reducers'; + +export const autocompleteSlice = createSlice({ + name: 'autocomplete', + initialState: AUTOCOMPLETE_INITIAL_STATE, + reducers: {}, + extraReducers(builder) { + getSearchAutocompleteReducer(builder); + }, +}); + +export default autocompleteSlice.reducer; diff --git a/src/redux/autocomplete/autocomplete.thunks.ts b/src/redux/autocomplete/autocomplete.thunks.ts new file mode 100644 index 00000000..6397ea42 --- /dev/null +++ b/src/redux/autocomplete/autocomplete.thunks.ts @@ -0,0 +1,28 @@ +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 })); + } + }, +); diff --git a/src/redux/autocomplete/autocomplete.types.ts b/src/redux/autocomplete/autocomplete.types.ts new file mode 100644 index 00000000..4d359029 --- /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 18dabadf..476e5b40 100644 --- a/src/redux/root/init.thunks.ts +++ b/src/redux/root/init.thunks.ts @@ -7,6 +7,7 @@ import { PluginsManager } from '@/services/pluginsManager'; import { createAsyncThunk } from '@reduxjs/toolkit'; import { ZERO } from '@/constants/common'; import { getConstant } from '@/redux/constant/constant.thunks'; +import { getSearchAutocomplete } from '@/redux/autocomplete/autocomplete.thunks'; import { getAllBackgroundsByProjectId } from '../backgrounds/backgrounds.thunks'; import { getConfiguration, getConfigurationOptions } from '../configuration/configuration.thunks'; import { @@ -86,6 +87,9 @@ export const fetchInitialAppData = createAsyncThunk< // Fetch plugins list dispatch(getAllPlugins()); + // autocomplete + dispatch(getSearchAutocomplete()); + /** 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 421151bd..3c9189d5 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,7 @@ import { RootState } from '../store'; import { USER_INITIAL_STATE_MOCK } from '../user/user.mock'; export const INITIAL_STORE_STATE_MOCK: RootState = { + autocomplete: 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 ebc65642..fedbb126 100644 --- a/src/redux/store.ts +++ b/src/redux/store.ts @@ -18,6 +18,7 @@ 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 autocompleteReducer from '@/redux/autocomplete/autocomplete.slice'; import { AnyAction, ListenerEffectAPI, @@ -38,6 +39,7 @@ import publicationsReducer from './publications/publications.slice'; import statisticsReducer from './statistics/statistics.slice'; export const reducers = { + autocomplete: autocompleteReducer, search: searchReducer, project: projectReducer, projects: projectsReducer, -- GitLab