1
0
mirror of https://github.com/Unleash/unleash.git synced 2025-10-13 11:17:26 +02:00
unleash.unleash/frontend/src/component/common/Search/Search.tsx
Jaanus Sellin 57c1a6edd5
feat: track interaction with search (#7526)
Now we will start tracking how users interact with search box. Whether
they open it manually or use CTRL K
2024-07-03 12:27:20 +03:00

268 lines
8.5 KiB
TypeScript

import type React from 'react';
import { useEffect, useRef, useState } from 'react';
import { useAsyncDebounce } from 'react-table';
import {
Box,
IconButton,
InputBase,
Paper,
styled,
Tooltip,
} from '@mui/material';
import Close from '@mui/icons-material/Close';
import SearchIcon from '@mui/icons-material/Search';
import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender';
import { SearchSuggestions } from './SearchSuggestions/SearchSuggestions';
import type { IGetSearchContextOutput } from 'hooks/useSearch';
import { useKeyboardShortcut } from 'hooks/useKeyboardShortcut';
import { SEARCH_INPUT } from 'utils/testIds';
import { useOnClickOutside } from 'hooks/useOnClickOutside';
import { useSavedQuery } from './useSavedQuery';
import { useOnBlur } from 'hooks/useOnBlur';
import { SearchHistory } from './SearchSuggestions/SearchHistory';
import { usePlausibleTracker } from 'hooks/usePlausibleTracker';
interface ISearchProps {
id?: string;
initialValue?: string;
onChange: (value: string) => void;
onFocus?: () => void;
onBlur?: () => void;
className?: string;
placeholder?: string;
hasFilters?: boolean;
disabled?: boolean;
getSearchContext?: () => IGetSearchContextOutput;
containerStyles?: React.CSSProperties;
debounceTime?: number;
expandable?: boolean;
}
export const SearchPaper = styled(Paper)(({ theme }) => ({
position: 'absolute',
width: '100%',
left: 0,
top: '20px',
zIndex: 2,
padding: theme.spacing(4, 1.5, 1.5),
borderBottomLeftRadius: theme.spacing(1),
borderBottomRightRadius: theme.spacing(1),
boxShadow: '0px 8px 20px rgba(33, 33, 33, 0.15)',
fontSize: theme.fontSizes.smallBody,
color: theme.palette.text.secondary,
wordBreak: 'break-word',
}));
const StyledContainer = styled('div', {
shouldForwardProp: (prop) => prop !== 'active',
})<{
active: boolean | undefined;
}>(({ theme, active }) => ({
display: 'flex',
flexGrow: 1,
alignItems: 'center',
position: 'relative',
backgroundColor: theme.palette.background.paper,
maxWidth: active ? '100%' : '400px',
[theme.breakpoints.down('md')]: {
marginTop: theme.spacing(1),
maxWidth: '100%',
},
}));
const StyledSearch = styled('div')(({ theme }) => ({
display: 'flex',
alignItems: 'center',
backgroundColor: theme.palette.background.elevation1,
border: `1px solid ${theme.palette.neutral.border}`,
borderRadius: theme.shape.borderRadiusExtraLarge,
padding: '3px 5px 3px 12px',
width: '100%',
zIndex: 3,
'&:focus-within': {
borderColor: theme.palette.primary.main,
boxShadow: theme.boxShadows.main,
},
}));
const StyledInputBase = styled(InputBase)(({ theme }) => ({
width: '100%',
backgroundColor: theme.palette.background.elevation1,
}));
const StyledClose = styled(Close)(({ theme }) => ({
color: theme.palette.neutral.main,
fontSize: theme.typography.body1.fontSize,
}));
export const Search = ({
initialValue = '',
id,
onChange,
onFocus,
onBlur,
className,
placeholder: customPlaceholder,
hasFilters,
disabled,
getSearchContext,
containerStyles,
expandable = false,
debounceTime = 200,
...rest
}: ISearchProps) => {
const { trackEvent } = usePlausibleTracker();
const searchInputRef = useRef<HTMLInputElement>(null);
const searchContainerRef = useRef<HTMLInputElement>(null);
const [showSuggestions, setShowSuggestions] = useState(false);
const [usedHotkey, setUsedHotkey] = useState(false);
const hideSuggestions = () => {
setShowSuggestions(false);
onBlur?.();
};
const { savedQuery, setSavedQuery } = useSavedQuery(id);
const [value, setValue] = useState<string>(initialValue);
const debouncedOnChange = useAsyncDebounce(onChange, debounceTime);
const onSearchChange = (value: string) => {
debouncedOnChange(value);
setValue(value);
setSavedQuery(value);
};
const hotkey = useKeyboardShortcut(
{
modifiers: ['ctrl'],
key: 'k',
preventDefault: true,
},
() => {
setUsedHotkey(true);
if (document.activeElement === searchInputRef.current) {
searchInputRef.current?.blur();
} else {
searchInputRef.current?.focus();
}
},
);
useEffect(() => {
if (!showSuggestions) {
return;
}
if (usedHotkey) {
trackEvent('search-opened', {
props: {
eventType: 'hotkey',
},
});
} else {
trackEvent('search-opened', {
props: {
eventType: 'manual',
},
});
}
setUsedHotkey(false);
}, [showSuggestions]);
useKeyboardShortcut({ key: 'Escape' }, () => {
if (searchContainerRef.current?.contains(document.activeElement)) {
searchInputRef.current?.blur();
}
});
const placeholder = `${customPlaceholder ?? 'Search'} (${hotkey})`;
useOnClickOutside([searchContainerRef], hideSuggestions);
useOnBlur(searchContainerRef, hideSuggestions);
useEffect(() => {
setValue(initialValue);
}, [initialValue]);
const historyEnabled = showSuggestions && id;
return (
<StyledContainer
ref={searchContainerRef}
style={containerStyles}
active={expandable && showSuggestions}
{...rest}
>
<StyledSearch className={className}>
<SearchIcon
sx={{
mr: 1,
color: (theme) => theme.palette.action.disabled,
}}
/>
<StyledInputBase
inputRef={searchInputRef}
placeholder={placeholder}
inputProps={{
'aria-label': placeholder,
'data-testid': SEARCH_INPUT,
}}
value={value}
onChange={(e) => onSearchChange(e.target.value)}
onFocus={() => {
setShowSuggestions(true);
onFocus?.();
}}
disabled={disabled}
/>
<Box sx={{ width: (theme) => theme.spacing(4) }}>
<ConditionallyRender
condition={Boolean(value)}
show={
<Tooltip title='Clear search query' arrow>
<IconButton
size='small'
onClick={(e) => {
e.stopPropagation(); // prevent outside click from the lazily added element
onSearchChange('');
searchInputRef.current?.focus();
}}
sx={{
padding: (theme) => theme.spacing(1),
}}
>
<StyledClose />
</IconButton>
</Tooltip>
}
/>
</Box>
</StyledSearch>
<ConditionallyRender
condition={
Boolean(hasFilters && getSearchContext) && showSuggestions
}
show={
<SearchSuggestions
onSuggestion={(suggestion) => {
onSearchChange(suggestion);
searchInputRef.current?.focus();
}}
savedQuery={savedQuery}
getSearchContext={getSearchContext!}
/>
}
elseShow={
historyEnabled && (
<SearchPaper className='dropdown-outline'>
<SearchHistory
onSuggestion={onSearchChange}
savedQuery={savedQuery}
/>
</SearchPaper>
)
}
/>
</StyledContainer>
);
};