Skip to content

Commit

Permalink
feat: persistent search queries (#4624)
Browse files Browse the repository at this point in the history
  • Loading branch information
kwasniew authored Sep 6, 2023
1 parent af9756e commit a0fbad2
Show file tree
Hide file tree
Showing 7 changed files with 145 additions and 18 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -307,6 +307,7 @@ export const ChangeRequestsTabs = ({
onChange={setSearchValue}
hasFilters
getSearchContext={getSearchContext}
id="changeRequestList"
/>
}
/>
Expand Down
59 changes: 59 additions & 0 deletions frontend/src/component/common/Search/Search.test.tsx
Original file line number Diff line number Diff line change
@@ -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
});
10 changes: 9 additions & 1 deletion frontend/src/component/common/Search/Search.tsx
Original file line number Diff line number Diff line change
@@ -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;
Expand Down Expand Up @@ -66,6 +68,7 @@ const StyledClose = styled(Close)(({ theme }) => ({

export const Search = ({
initialValue = '',
id,
onChange,
onFocus,
onBlur,
Expand All @@ -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(
Expand Down Expand Up @@ -163,6 +169,7 @@ export const Search = ({
/>
</Box>
</StyledSearch>

<ConditionallyRender
condition={Boolean(hasFilters) && showSuggestions}
show={
Expand All @@ -171,6 +178,7 @@ export const Search = ({
onSearchChange(suggestion);
searchInputRef.current?.focus();
}}
savedQuery={savedQuery}
getSearchContext={getSearchContext!}
/>
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 }) => ({
Expand Down Expand Up @@ -49,11 +53,18 @@ export const SearchInstructions: VFC<ISearchInstructionsProps> = ({
{filters.map(filter => (
<StyledFilterHint key={filter.name}>
{filter.header}:{' '}
<StyledCode
onClick={() => onClick(firstFilterOption(filter))}
>
{firstFilterOption(filter)}
</StyledCode>
<ConditionallyRender
condition={filter.options.length > 0}
show={
<StyledCode
onClick={() =>
onClick(firstFilterOption(filter))
}
>
{firstFilterOption(filter)}
</StyledCode>
}
/>
<ConditionallyRender
condition={filter.options.length > 1}
show={
Expand Down
Original file line number Diff line number Diff line change
@@ -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 {
Expand All @@ -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',
Expand All @@ -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,
}));
Expand All @@ -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);
Expand All @@ -60,6 +60,7 @@ const randomIndex = (arr: any[]) => Math.floor(Math.random() * arr.length);
export const SearchSuggestions: VFC<SearchSuggestionsProps> = ({
getSearchContext,
onSuggestion,
savedQuery,
}) => {
const searchContext = getSearchContext();

Expand Down Expand Up @@ -108,6 +109,23 @@ export const SearchSuggestions: VFC<SearchSuggestionsProps> = ({

return (
<StyledPaper className="dropdown-outline">
<ConditionallyRender
condition={Boolean(savedQuery)}
show={
<>
<StyledBox>
<StyledHistory />
<StyledCode
onClick={() => onSuggestion(savedQuery || '')}
>
<span>{savedQuery}</span>
</StyledCode>
</StyledBox>
<StyledDivider />
</>
}
/>

<StyledBox>
<StyledFilterList />
<Box>
Expand Down
28 changes: 28 additions & 0 deletions frontend/src/component/common/Search/useSavedQuery.ts
Original file line number Diff line number Diff line change
@@ -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);
}
},
};
};
Original file line number Diff line number Diff line change
Expand Up @@ -554,6 +554,7 @@ export const ProjectFeatureToggles = ({
onBlur={() => setShowTitle(true)}
hasFilters
getSearchContext={getSearchContext}
id="projectFeatureToggles"
/>
}
/>
Expand Down Expand Up @@ -612,6 +613,7 @@ export const ProjectFeatureToggles = ({
onChange={setSearchValue}
hasFilters
getSearchContext={getSearchContext}
id="projectFeatureToggles"
/>
}
/>
Expand Down

0 comments on commit a0fbad2

Please sign in to comment.