diff --git a/frontend/src/component/changeRequest/ProjectChangeRequests/ChangeRequestsTabs/ChangeRequestsTabs.tsx b/frontend/src/component/changeRequest/ProjectChangeRequests/ChangeRequestsTabs/ChangeRequestsTabs.tsx index b5b19757b297..0f707a401e57 100644 --- a/frontend/src/component/changeRequest/ProjectChangeRequests/ChangeRequestsTabs/ChangeRequestsTabs.tsx +++ b/frontend/src/component/changeRequest/ProjectChangeRequests/ChangeRequestsTabs/ChangeRequestsTabs.tsx @@ -307,6 +307,7 @@ export const ChangeRequestsTabs = ({ onChange={setSearchValue} hasFilters getSearchContext={getSearchContext} + id="changeRequestList" /> } /> diff --git a/frontend/src/component/common/Search/Search.test.tsx b/frontend/src/component/common/Search/Search.test.tsx new file mode 100644 index 000000000000..200d4828e049 --- /dev/null +++ b/frontend/src/component/common/Search/Search.test.tsx @@ -0,0 +1,59 @@ +import { createLocalStorage } from 'utils/createLocalStorage'; +import { render } from 'utils/testRenderer'; +import { fireEvent, screen } from '@testing-library/react'; +import { UIProviderContainer } from '../../providers/UIProvider/UIProviderContainer'; +import { Search } from './Search'; +import { SEARCH_INPUT } from 'utils/testIds'; + +const testDisplayComponent = ( + + {}} + id="localStorageId" + getSearchContext={() => ({ + data: [], + columns: [], + searchValue: '', + })} + /> + +); + +test('should read saved query from local storage', async () => { + const { value, setValue } = createLocalStorage( + 'Search:localStorageId:v1', + {} + ); + setValue({ + query: 'oldquery', + }); + + render(testDisplayComponent); + + const input = screen.getByTestId(SEARCH_INPUT); + + input.focus(); + + await screen.findByText('oldquery'); // local storage saved search query + + screen.getByText('oldquery').click(); // click history hint + + expect(screen.getByDisplayValue('oldquery')).toBeInTheDocument(); // check if input updates + + fireEvent.change(input, { target: { value: 'newquery' } }); + + expect(screen.getByText('newquery')).toBeInTheDocument(); // new saved query updated +}); + +test('should update saved query without local storage', async () => { + render(testDisplayComponent); + + const input = screen.getByTestId(SEARCH_INPUT); + + input.focus(); + + fireEvent.change(input, { target: { value: 'newquery' } }); + + expect(screen.getByText('newquery')).toBeInTheDocument(); // new saved query updated +}); diff --git a/frontend/src/component/common/Search/Search.tsx b/frontend/src/component/common/Search/Search.tsx index 85b5a3b380db..31f5cfb9338d 100644 --- a/frontend/src/component/common/Search/Search.tsx +++ b/frontend/src/component/common/Search/Search.tsx @@ -1,15 +1,17 @@ import React, { useRef, useState } from 'react'; import { useAsyncDebounce } from 'react-table'; import { Box, IconButton, InputBase, styled, Tooltip } from '@mui/material'; -import { Search as SearchIcon, Close } from '@mui/icons-material'; +import { Close, Search as SearchIcon } from '@mui/icons-material'; import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender'; import { SearchSuggestions } from './SearchSuggestions/SearchSuggestions'; import { IGetSearchContextOutput } from 'hooks/useSearch'; import { useKeyboardShortcut } from 'hooks/useKeyboardShortcut'; import { SEARCH_INPUT } from 'utils/testIds'; import { useOnClickOutside } from 'hooks/useOnClickOutside'; +import { useSavedQuery } from './useSavedQuery'; interface ISearchProps { + id?: string; initialValue?: string; onChange: (value: string) => void; onFocus?: () => void; @@ -66,6 +68,7 @@ const StyledClose = styled(Close)(({ theme }) => ({ export const Search = ({ initialValue = '', + id, onChange, onFocus, onBlur, @@ -86,12 +89,15 @@ export const Search = ({ onBlur?.(); }; + const { savedQuery, setSavedQuery } = useSavedQuery(id); + const [value, setValue] = useState(initialValue); const debouncedOnChange = useAsyncDebounce(onChange, debounceTime); const onSearchChange = (value: string) => { debouncedOnChange(value); setValue(value); + setSavedQuery(value); }; const hotkey = useKeyboardShortcut( @@ -163,6 +169,7 @@ export const Search = ({ /> + } diff --git a/frontend/src/component/common/Search/SearchSuggestions/SearchInstructions/SearchInstructions.tsx b/frontend/src/component/common/Search/SearchSuggestions/SearchInstructions/SearchInstructions.tsx index 651320843942..bfdf6cf6fe1d 100644 --- a/frontend/src/component/common/Search/SearchSuggestions/SearchInstructions/SearchInstructions.tsx +++ b/frontend/src/component/common/Search/SearchSuggestions/SearchInstructions/SearchInstructions.tsx @@ -7,12 +7,16 @@ const StyledHeader = styled('span')(({ theme }) => ({ color: theme.palette.text.primary, })); -const StyledCode = styled('span')(({ theme }) => ({ +export const StyledCode = styled('span')(({ theme }) => ({ backgroundColor: theme.palette.background.elevation2, color: theme.palette.text.primary, padding: theme.spacing(0.2, 1), borderRadius: theme.spacing(0.5), cursor: 'pointer', + '&:hover': { + transition: 'background-color 0.2s ease-in-out', + backgroundColor: theme.palette.seen.primary, + }, })); const StyledFilterHint = styled('p')(({ theme }) => ({ @@ -49,11 +53,18 @@ export const SearchInstructions: VFC = ({ {filters.map(filter => ( {filter.header}:{' '} - onClick(firstFilterOption(filter))} - > - {firstFilterOption(filter)} - + 0} + show={ + + onClick(firstFilterOption(filter)) + } + > + {firstFilterOption(filter)} + + } + /> 1} show={ diff --git a/frontend/src/component/common/Search/SearchSuggestions/SearchSuggestions.tsx b/frontend/src/component/common/Search/SearchSuggestions/SearchSuggestions.tsx index 653887c7846c..5c10f57ac4c9 100644 --- a/frontend/src/component/common/Search/SearchSuggestions/SearchSuggestions.tsx +++ b/frontend/src/component/common/Search/SearchSuggestions/SearchSuggestions.tsx @@ -1,4 +1,4 @@ -import { FilterList } from '@mui/icons-material'; +import { FilterList, History } from '@mui/icons-material'; import { Box, Divider, Paper, styled } from '@mui/material'; import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender'; import { @@ -7,9 +7,12 @@ import { getFilterValues, IGetSearchContextOutput, } from 'hooks/useSearch'; -import { useMemo, VFC } from 'react'; +import { VFC } from 'react'; import { SearchDescription } from './SearchDescription/SearchDescription'; -import { SearchInstructions } from './SearchInstructions/SearchInstructions'; +import { + SearchInstructions, + StyledCode, +} from './SearchInstructions/SearchInstructions'; const StyledPaper = styled(Paper)(({ theme }) => ({ position: 'absolute', @@ -31,6 +34,10 @@ const StyledBox = styled(Box)(({ theme }) => ({ gap: theme.spacing(2), })); +const StyledHistory = styled(History)(({ theme }) => ({ + color: theme.palette.text.secondary, +})); + const StyledFilterList = styled(FilterList)(({ theme }) => ({ color: theme.palette.text.secondary, })); @@ -40,17 +47,10 @@ const StyledDivider = styled(Divider)(({ theme }) => ({ margin: theme.spacing(1.5, 0), })); -const StyledCode = styled('span')(({ theme }) => ({ - backgroundColor: theme.palette.background.elevation2, - color: theme.palette.text.primary, - padding: theme.spacing(0.2, 0.5), - borderRadius: theme.spacing(0.5), - cursor: 'pointer', -})); - interface SearchSuggestionsProps { getSearchContext: () => IGetSearchContextOutput; onSuggestion: (suggestion: string) => void; + savedQuery?: string; } const quote = (item: string) => (item.includes(' ') ? `"${item}"` : item); @@ -60,6 +60,7 @@ const randomIndex = (arr: any[]) => Math.floor(Math.random() * arr.length); export const SearchSuggestions: VFC = ({ getSearchContext, onSuggestion, + savedQuery, }) => { const searchContext = getSearchContext(); @@ -108,6 +109,23 @@ export const SearchSuggestions: VFC = ({ return ( + + + + onSuggestion(savedQuery || '')} + > + {savedQuery} + + + + + } + /> + diff --git a/frontend/src/component/common/Search/useSavedQuery.ts b/frontend/src/component/common/Search/useSavedQuery.ts new file mode 100644 index 000000000000..841b9ee97fff --- /dev/null +++ b/frontend/src/component/common/Search/useSavedQuery.ts @@ -0,0 +1,28 @@ +import { createLocalStorage } from 'utils/createLocalStorage'; +import { useEffect, useState } from 'react'; + +// if you provided persistent id the query will be persisted in local storage +export const useSavedQuery = (id?: string) => { + const { value, setValue } = createLocalStorage( + `Search:${id || 'default'}:v1`, + { + query: '', + } + ); + const [savedQuery, setSavedQuery] = useState(value.query); + + useEffect(() => { + if (id && savedQuery.trim().length > 0) { + setValue({ query: savedQuery }); + } + }, [id, savedQuery]); + + return { + savedQuery, + setSavedQuery: (newValue: string) => { + if (newValue.trim().length > 0) { + setSavedQuery(newValue); + } + }, + }; +}; diff --git a/frontend/src/component/project/Project/ProjectFeatureToggles/ProjectFeatureToggles.tsx b/frontend/src/component/project/Project/ProjectFeatureToggles/ProjectFeatureToggles.tsx index 91a5c0ca0ee3..e9d8e5992c05 100644 --- a/frontend/src/component/project/Project/ProjectFeatureToggles/ProjectFeatureToggles.tsx +++ b/frontend/src/component/project/Project/ProjectFeatureToggles/ProjectFeatureToggles.tsx @@ -554,6 +554,7 @@ export const ProjectFeatureToggles = ({ onBlur={() => setShowTitle(true)} hasFilters getSearchContext={getSearchContext} + id="projectFeatureToggles" /> } /> @@ -612,6 +613,7 @@ export const ProjectFeatureToggles = ({ onChange={setSearchValue} hasFilters getSearchContext={getSearchContext} + id="projectFeatureToggles" /> } />