1
0
mirror of https://github.com/Unleash/unleash.git synced 2025-02-23 00:22:19 +01:00

feat: persistent search queries (#4624)

This commit is contained in:
Mateusz Kwasniewski 2023-09-06 15:46:10 +02:00 committed by GitHub
parent af9756e1e1
commit a0fbad26bf
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
7 changed files with 145 additions and 18 deletions

View File

@ -307,6 +307,7 @@ export const ChangeRequestsTabs = ({
onChange={setSearchValue} onChange={setSearchValue}
hasFilters hasFilters
getSearchContext={getSearchContext} getSearchContext={getSearchContext}
id="changeRequestList"
/> />
} }
/> />

View File

@ -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 = (
<UIProviderContainer>
<Search
hasFilters
onChange={() => {}}
id="localStorageId"
getSearchContext={() => ({
data: [],
columns: [],
searchValue: '',
})}
/>
</UIProviderContainer>
);
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
});

View File

@ -1,15 +1,17 @@
import React, { useRef, useState } from 'react'; import React, { useRef, useState } from 'react';
import { useAsyncDebounce } from 'react-table'; import { useAsyncDebounce } from 'react-table';
import { Box, IconButton, InputBase, styled, Tooltip } from '@mui/material'; 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 { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender';
import { SearchSuggestions } from './SearchSuggestions/SearchSuggestions'; import { SearchSuggestions } from './SearchSuggestions/SearchSuggestions';
import { IGetSearchContextOutput } from 'hooks/useSearch'; import { IGetSearchContextOutput } from 'hooks/useSearch';
import { useKeyboardShortcut } from 'hooks/useKeyboardShortcut'; import { useKeyboardShortcut } from 'hooks/useKeyboardShortcut';
import { SEARCH_INPUT } from 'utils/testIds'; import { SEARCH_INPUT } from 'utils/testIds';
import { useOnClickOutside } from 'hooks/useOnClickOutside'; import { useOnClickOutside } from 'hooks/useOnClickOutside';
import { useSavedQuery } from './useSavedQuery';
interface ISearchProps { interface ISearchProps {
id?: string;
initialValue?: string; initialValue?: string;
onChange: (value: string) => void; onChange: (value: string) => void;
onFocus?: () => void; onFocus?: () => void;
@ -66,6 +68,7 @@ const StyledClose = styled(Close)(({ theme }) => ({
export const Search = ({ export const Search = ({
initialValue = '', initialValue = '',
id,
onChange, onChange,
onFocus, onFocus,
onBlur, onBlur,
@ -86,12 +89,15 @@ export const Search = ({
onBlur?.(); onBlur?.();
}; };
const { savedQuery, setSavedQuery } = useSavedQuery(id);
const [value, setValue] = useState(initialValue); const [value, setValue] = useState(initialValue);
const debouncedOnChange = useAsyncDebounce(onChange, debounceTime); const debouncedOnChange = useAsyncDebounce(onChange, debounceTime);
const onSearchChange = (value: string) => { const onSearchChange = (value: string) => {
debouncedOnChange(value); debouncedOnChange(value);
setValue(value); setValue(value);
setSavedQuery(value);
}; };
const hotkey = useKeyboardShortcut( const hotkey = useKeyboardShortcut(
@ -163,6 +169,7 @@ export const Search = ({
/> />
</Box> </Box>
</StyledSearch> </StyledSearch>
<ConditionallyRender <ConditionallyRender
condition={Boolean(hasFilters) && showSuggestions} condition={Boolean(hasFilters) && showSuggestions}
show={ show={
@ -171,6 +178,7 @@ export const Search = ({
onSearchChange(suggestion); onSearchChange(suggestion);
searchInputRef.current?.focus(); searchInputRef.current?.focus();
}} }}
savedQuery={savedQuery}
getSearchContext={getSearchContext!} getSearchContext={getSearchContext!}
/> />
} }

View File

@ -7,12 +7,16 @@ const StyledHeader = styled('span')(({ theme }) => ({
color: theme.palette.text.primary, color: theme.palette.text.primary,
})); }));
const StyledCode = styled('span')(({ theme }) => ({ export const StyledCode = styled('span')(({ theme }) => ({
backgroundColor: theme.palette.background.elevation2, backgroundColor: theme.palette.background.elevation2,
color: theme.palette.text.primary, color: theme.palette.text.primary,
padding: theme.spacing(0.2, 1), padding: theme.spacing(0.2, 1),
borderRadius: theme.spacing(0.5), borderRadius: theme.spacing(0.5),
cursor: 'pointer', cursor: 'pointer',
'&:hover': {
transition: 'background-color 0.2s ease-in-out',
backgroundColor: theme.palette.seen.primary,
},
})); }));
const StyledFilterHint = styled('p')(({ theme }) => ({ const StyledFilterHint = styled('p')(({ theme }) => ({
@ -49,11 +53,18 @@ export const SearchInstructions: VFC<ISearchInstructionsProps> = ({
{filters.map(filter => ( {filters.map(filter => (
<StyledFilterHint key={filter.name}> <StyledFilterHint key={filter.name}>
{filter.header}:{' '} {filter.header}:{' '}
<StyledCode <ConditionallyRender
onClick={() => onClick(firstFilterOption(filter))} condition={filter.options.length > 0}
> show={
{firstFilterOption(filter)} <StyledCode
</StyledCode> onClick={() =>
onClick(firstFilterOption(filter))
}
>
{firstFilterOption(filter)}
</StyledCode>
}
/>
<ConditionallyRender <ConditionallyRender
condition={filter.options.length > 1} condition={filter.options.length > 1}
show={ show={

View File

@ -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 { Box, Divider, Paper, styled } from '@mui/material';
import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender'; import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender';
import { import {
@ -7,9 +7,12 @@ import {
getFilterValues, getFilterValues,
IGetSearchContextOutput, IGetSearchContextOutput,
} from 'hooks/useSearch'; } from 'hooks/useSearch';
import { useMemo, VFC } from 'react'; import { VFC } from 'react';
import { SearchDescription } from './SearchDescription/SearchDescription'; import { SearchDescription } from './SearchDescription/SearchDescription';
import { SearchInstructions } from './SearchInstructions/SearchInstructions'; import {
SearchInstructions,
StyledCode,
} from './SearchInstructions/SearchInstructions';
const StyledPaper = styled(Paper)(({ theme }) => ({ const StyledPaper = styled(Paper)(({ theme }) => ({
position: 'absolute', position: 'absolute',
@ -31,6 +34,10 @@ const StyledBox = styled(Box)(({ theme }) => ({
gap: theme.spacing(2), gap: theme.spacing(2),
})); }));
const StyledHistory = styled(History)(({ theme }) => ({
color: theme.palette.text.secondary,
}));
const StyledFilterList = styled(FilterList)(({ theme }) => ({ const StyledFilterList = styled(FilterList)(({ theme }) => ({
color: theme.palette.text.secondary, color: theme.palette.text.secondary,
})); }));
@ -40,17 +47,10 @@ const StyledDivider = styled(Divider)(({ theme }) => ({
margin: theme.spacing(1.5, 0), 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 { interface SearchSuggestionsProps {
getSearchContext: () => IGetSearchContextOutput; getSearchContext: () => IGetSearchContextOutput;
onSuggestion: (suggestion: string) => void; onSuggestion: (suggestion: string) => void;
savedQuery?: string;
} }
const quote = (item: string) => (item.includes(' ') ? `"${item}"` : item); 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<SearchSuggestionsProps> = ({ export const SearchSuggestions: VFC<SearchSuggestionsProps> = ({
getSearchContext, getSearchContext,
onSuggestion, onSuggestion,
savedQuery,
}) => { }) => {
const searchContext = getSearchContext(); const searchContext = getSearchContext();
@ -108,6 +109,23 @@ export const SearchSuggestions: VFC<SearchSuggestionsProps> = ({
return ( return (
<StyledPaper className="dropdown-outline"> <StyledPaper className="dropdown-outline">
<ConditionallyRender
condition={Boolean(savedQuery)}
show={
<>
<StyledBox>
<StyledHistory />
<StyledCode
onClick={() => onSuggestion(savedQuery || '')}
>
<span>{savedQuery}</span>
</StyledCode>
</StyledBox>
<StyledDivider />
</>
}
/>
<StyledBox> <StyledBox>
<StyledFilterList /> <StyledFilterList />
<Box> <Box>

View File

@ -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);
}
},
};
};

View File

@ -554,6 +554,7 @@ export const ProjectFeatureToggles = ({
onBlur={() => setShowTitle(true)} onBlur={() => setShowTitle(true)}
hasFilters hasFilters
getSearchContext={getSearchContext} getSearchContext={getSearchContext}
id="projectFeatureToggles"
/> />
} }
/> />
@ -612,6 +613,7 @@ export const ProjectFeatureToggles = ({
onChange={setSearchValue} onChange={setSearchValue}
hasFilters hasFilters
getSearchContext={getSearchContext} getSearchContext={getSearchContext}
id="projectFeatureToggles"
/> />
} }
/> />