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:
parent
af9756e1e1
commit
a0fbad26bf
@ -307,6 +307,7 @@ export const ChangeRequestsTabs = ({
|
|||||||
onChange={setSearchValue}
|
onChange={setSearchValue}
|
||||||
hasFilters
|
hasFilters
|
||||||
getSearchContext={getSearchContext}
|
getSearchContext={getSearchContext}
|
||||||
|
id="changeRequestList"
|
||||||
/>
|
/>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
59
frontend/src/component/common/Search/Search.test.tsx
Normal file
59
frontend/src/component/common/Search/Search.test.tsx
Normal 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
|
||||||
|
});
|
@ -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!}
|
||||||
/>
|
/>
|
||||||
}
|
}
|
||||||
|
@ -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={
|
||||||
|
@ -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>
|
||||||
|
28
frontend/src/component/common/Search/useSavedQuery.ts
Normal file
28
frontend/src/component/common/Search/useSavedQuery.ts
Normal 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);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
|
};
|
@ -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"
|
||||||
/>
|
/>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
Loading…
Reference in New Issue
Block a user