feat(search-react): create search bar autocomplete

Signed-off-by: Camila Belo <camilaibs@gmail.com>
This commit is contained in:
Camila Belo
2022-08-26 13:27:46 +02:00
parent 412825d9eb
commit c32945d787
6 changed files with 592 additions and 105 deletions
@@ -0,0 +1,233 @@
/*
* Copyright 2022 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import React from 'react';
import { screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import LabelIcon from '@material-ui/icons/Label';
import { configApiRef } from '@backstage/core-plugin-api';
import { ConfigReader } from '@backstage/core-app-api';
import { TestApiProvider, renderWithEffects } from '@backstage/test-utils';
import { searchApiRef } from '../../api';
import {
SearchAutocomplete,
SearchAutocompleteDefaultOption,
} from './SearchAutocomplete';
const title = 'Backstage Test App';
const configApiMock = new ConfigReader({
app: { title },
});
const query = jest.fn().mockResolvedValue({ results: [] });
const searchApiMock = { query };
describe('SearchAutocomplete', () => {
const options = ['hello-world', 'petstore', 'spotify'];
beforeEach(() => {
jest.clearAllMocks();
});
it('Renders without exploding', async () => {
await renderWithEffects(
<TestApiProvider
apis={[
[configApiRef, configApiMock],
[searchApiRef, searchApiMock],
]}
>
<SearchAutocomplete options={options} />
</TestApiProvider>,
);
expect(screen.getByTestId('search-autocomplete')).toBeInTheDocument();
});
it('Show all options by default when focused', async () => {
await renderWithEffects(
<TestApiProvider
apis={[
[configApiRef, configApiMock],
[searchApiRef, searchApiMock],
]}
>
<SearchAutocomplete options={options} />
</TestApiProvider>,
);
expect(screen.queryByText(options[0])).not.toBeInTheDocument();
expect(screen.queryByText(options[1])).not.toBeInTheDocument();
expect(screen.queryByText(options[2])).not.toBeInTheDocument();
await userEvent.click(screen.getByPlaceholderText(`Search in ${title}`));
await waitFor(() => {
expect(screen.getByText(options[0])).toBeInTheDocument();
expect(screen.getByText(options[1])).toBeInTheDocument();
expect(screen.getByText(options[2])).toBeInTheDocument();
});
});
it('Updates context with the initial value', async () => {
await renderWithEffects(
<TestApiProvider
apis={[
[configApiRef, configApiMock],
[searchApiRef, searchApiMock],
]}
>
<SearchAutocomplete value={options[0]} options={options} />
</TestApiProvider>,
);
await waitFor(() => {
expect(query).toBeCalledWith({
filters: {},
pageCursor: undefined,
term: options[0],
types: [],
});
});
});
it('Updates context when value is cleared', async () => {
await renderWithEffects(
<TestApiProvider
apis={[
[configApiRef, configApiMock],
[searchApiRef, searchApiMock],
]}
>
<SearchAutocomplete value={options[0]} options={options} />
</TestApiProvider>,
);
await waitFor(() => {
expect(query).toBeCalledWith({
filters: {},
pageCursor: undefined,
term: options[0],
types: [],
});
});
await userEvent.click(screen.getByLabelText('Clear'));
await waitFor(() => {
expect(query).toBeCalledWith({
filters: {},
pageCursor: undefined,
term: '',
types: [],
});
});
});
it('Updates context when an option is select', async () => {
await renderWithEffects(
<TestApiProvider
apis={[
[configApiRef, configApiMock],
[searchApiRef, searchApiMock],
]}
>
<SearchAutocomplete options={options} />
</TestApiProvider>,
);
await userEvent.click(screen.getByPlaceholderText(`Search in ${title}`));
await userEvent.click(screen.getByText(options[0]));
await waitFor(() => {
expect(query).toBeCalledWith({
filters: {},
pageCursor: undefined,
term: options[0],
types: [],
});
});
});
it('Shows a circular progress when loading options', async () => {
await renderWithEffects(
<TestApiProvider
apis={[
[configApiRef, configApiMock],
[searchApiRef, searchApiMock],
]}
>
<SearchAutocomplete options={options} loading />
</TestApiProvider>,
);
await waitFor(() => {
expect(
screen.getByTestId('search-autocomplete-progressbar'),
).toBeInTheDocument();
});
});
it('Uses the default search autocomplete option component', async () => {
await renderWithEffects(
<TestApiProvider
apis={[
[configApiRef, configApiMock],
[searchApiRef, searchApiMock],
]}
>
<SearchAutocomplete
options={[
{
title: 'hello-world',
text: 'Hello World example for gRPC',
},
{
title: 'petstore',
text: 'The petstore API',
},
{
title: 'spotify',
text: 'The Spotify web API',
},
]}
getOptionLabel={option => option.title}
renderOption={option => (
<SearchAutocompleteDefaultOption
icon={<LabelIcon titleAccess="Option icon" />}
primaryText={option.title}
secondaryText={option.text}
/>
)}
/>
</TestApiProvider>,
);
await userEvent.click(screen.getByPlaceholderText(`Search in ${title}`));
await waitFor(() => {
expect(screen.getAllByTitle('Option icon')).toHaveLength(3);
expect(screen.getByText('hello-world')).toBeInTheDocument();
expect(
screen.getByText('Hello World example for gRPC'),
).toBeInTheDocument();
});
});
});
@@ -0,0 +1,202 @@
/*
* Copyright 2022 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import React, { ChangeEvent, ReactNode, useCallback, useMemo } from 'react';
import {
CircularProgress,
ListItemIcon,
ListItemText,
ListItemTextProps,
} from '@material-ui/core';
import {
Value,
Autocomplete,
AutocompleteProps,
AutocompleteChangeDetails,
AutocompleteChangeReason,
AutocompleteRenderInputParams,
} from '@material-ui/lab';
import { SearchContextProvider, useSearch } from '../../context';
import { SearchBar, SearchBarProps } from '../SearchBar';
/**
* Props for {@link SearchAutocomplete}.
*
* @public
*/
export type SearchAutocompleteProps<
T,
Multiple extends boolean | undefined,
DisableClearable extends boolean | undefined,
FreeSolo extends boolean | undefined,
> = Omit<
AutocompleteProps<T, Multiple, DisableClearable, FreeSolo>,
'renderInput'
> & {
inputDebounceTime?: SearchBarProps['debounceTime'];
renderInput?: (params: AutocompleteRenderInputParams) => JSX.Element;
};
const withContext = (
Component: <
T,
Multiple extends boolean | undefined,
DisableClearable extends boolean | undefined,
FreeSolo extends boolean | undefined,
>(
props: SearchAutocompleteProps<T, Multiple, DisableClearable, FreeSolo>,
) => JSX.Element,
) => {
return <
T,
Multiple extends boolean | undefined,
DisableClearable extends boolean | undefined,
FreeSolo extends boolean | undefined,
>(
props: SearchAutocompleteProps<T, Multiple, DisableClearable, FreeSolo>,
) => (
<SearchContextProvider useParentContext>
<Component {...props} />
</SearchContextProvider>
);
};
/**
* Recommended search autocomplete when you use the Search Provider or Search Context.
*
* @public
*/
export const SearchAutocomplete = withContext(
<
T,
Multiple extends boolean | undefined,
DisableClearable extends boolean | undefined,
FreeSolo extends boolean | undefined,
>({
loading,
value,
onChange = () => {},
options = [],
getOptionLabel = (option: T) => String(option),
renderInput,
inputDebounceTime,
fullWidth = true,
clearOnBlur = false,
...rest
}: SearchAutocompleteProps<T, Multiple, DisableClearable, FreeSolo>) => {
const { setTerm } = useSearch();
const inputValue = useMemo(() => {
return value ? getOptionLabel(value as T) : '';
}, [value, getOptionLabel]);
const handleChange = useCallback(
(
event: ChangeEvent<{}>,
newValue: Value<T, Multiple, DisableClearable, FreeSolo>,
reason: AutocompleteChangeReason,
details?: AutocompleteChangeDetails<T>,
) => {
onChange(event, newValue, reason, details);
setTerm(newValue ? getOptionLabel(newValue as T) : '');
},
[getOptionLabel, setTerm, onChange],
);
const defaultRenderInput = useCallback(
({
InputProps: { ref, endAdornment },
InputLabelProps,
...params
}: AutocompleteRenderInputParams) => (
<SearchBar
{...params}
ref={ref}
clearButton={false}
value={inputValue}
debounceTime={inputDebounceTime}
endAdornment={
loading ? (
<CircularProgress
data-testid="search-autocomplete-progressbar"
color="inherit"
size={20}
/>
) : (
endAdornment
)
}
/>
),
[loading, inputValue, inputDebounceTime],
);
return (
<Autocomplete
data-testid="search-autocomplete"
value={value}
onChange={handleChange}
options={options}
getOptionLabel={getOptionLabel}
renderInput={renderInput ?? defaultRenderInput}
fullWidth={fullWidth}
clearOnBlur={clearOnBlur}
{...rest}
/>
);
},
);
/**
* Props for {@link SearchAutocompleteDefaultOption}.
*
* @public
*/
export type SearchAutocompleteDefaultOptionProps = {
icon?: ReactNode;
primaryText: ListItemTextProps['primary'];
primaryTextTypographyProps?: ListItemTextProps['primaryTypographyProps'];
secondaryText?: ListItemTextProps['secondary'];
secondaryTextTypographyProps?: ListItemTextProps['secondaryTypographyProps'];
disableTextTypography?: ListItemTextProps['disableTypography'];
};
/**
* A default search bar autocomplete component.
*
* @public
*/
export const SearchAutocompleteDefaultOption = ({
icon,
primaryText,
primaryTextTypographyProps,
secondaryText,
secondaryTextTypographyProps,
disableTextTypography,
}: SearchAutocompleteDefaultOptionProps) => (
<>
{icon ? <ListItemIcon>{icon}</ListItemIcon> : null}
<ListItemText
primary={primaryText}
primaryTypographyProps={primaryTextTypographyProps}
secondary={secondaryText}
secondaryTypographyProps={secondaryTextTypographyProps}
disableTypography={disableTextTypography}
/>
</>
);
@@ -0,0 +1,25 @@
/*
* Copyright 2022 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
export {
SearchAutocomplete,
SearchAutocompleteDefaultOption,
} from './SearchAutocomplete';
export type {
SearchAutocompleteProps,
SearchAutocompleteDefaultOptionProps,
} from './SearchAutocomplete';
@@ -20,8 +20,11 @@ import React, {
useState,
useEffect,
useCallback,
forwardRef,
ComponentType,
} from 'react';
import useDebounce from 'react-use/lib/useDebounce';
import {
InputBase,
InputBaseProps,
@@ -37,11 +40,7 @@ import {
useApi,
} from '@backstage/core-plugin-api';
import {
SearchContextProvider,
useSearch,
useSearchContextCheck,
} from '../../context';
import { SearchContextProvider, useSearch } from '../../context';
import { TrackSearch } from '../SearchTracker';
/**
@@ -64,94 +63,97 @@ export type SearchBarBaseProps = Omit<InputBaseProps, 'onChange'> & {
*
* @public
*/
export const SearchBarBase = ({
onChange,
onKeyDown,
onSubmit,
debounceTime = 200,
clearButton = true,
fullWidth = true,
value: defaultValue,
inputProps: defaultInputProps = {},
endAdornment: defaultEndAdornment,
...props
}: SearchBarBaseProps) => {
const configApi = useApi(configApiRef);
const [value, setValue] = useState<string>(defaultValue as string);
const hasSearchContext = useSearchContextCheck();
export const SearchBarBase = forwardRef<unknown, SearchBarBaseProps>(
(
{
onChange,
onKeyDown = () => {},
onClear = () => {},
onSubmit = () => {},
debounceTime = 200,
clearButton = true,
fullWidth = true,
value: defaultValue,
inputProps: defaultInputProps = {},
endAdornment: defaultEndAdornment,
...props
},
ref,
) => {
const configApi = useApi(configApiRef);
const [value, setValue] = useState<string>('');
useEffect(() => {
setValue(prevValue =>
prevValue !== defaultValue ? (defaultValue as string) : prevValue,
useEffect(() => {
setValue(prevValue =>
prevValue !== defaultValue ? String(defaultValue) : prevValue,
);
}, [defaultValue]);
useDebounce(() => onChange(value), debounceTime, [value]);
const handleChange = useCallback(
(e: ChangeEvent<HTMLInputElement>) => {
setValue(e.target.value);
},
[setValue],
);
}, [defaultValue]);
useDebounce(() => onChange(value), debounceTime, [value]);
const handleKeyDown = useCallback(
(e: KeyboardEvent<HTMLInputElement>) => {
if (onKeyDown) onKeyDown(e);
if (onSubmit && e.key === 'Enter') {
onSubmit();
}
},
[onKeyDown, onSubmit],
);
const handleChange = useCallback(
(e: ChangeEvent<HTMLInputElement>) => {
setValue(e.target.value);
},
[setValue],
);
const handleKeyDown = useCallback(
(e: KeyboardEvent<HTMLInputElement>) => {
if (onKeyDown) onKeyDown(e);
if (onSubmit && e.key === 'Enter') {
onSubmit();
const handleClear = useCallback(() => {
onChange('');
if (onClear) {
onClear();
}
},
[onKeyDown, onSubmit],
);
}, [onChange, onClear]);
const handleClear = useCallback(() => {
onChange('');
}, [onChange]);
const placeholder = `Search in ${
configApi.getOptionalString('app.title') || 'Backstage'
}`;
const placeholder = `Search in ${
configApi.getOptionalString('app.title') || 'Backstage'
}`;
const startAdornment = (
<InputAdornment position="start">
<IconButton aria-label="Query" size="small" disabled>
<SearchIcon />
</IconButton>
</InputAdornment>
);
const startAdornment = (
<InputAdornment position="start">
<IconButton aria-label="Query" disabled>
<SearchIcon />
</IconButton>
</InputAdornment>
);
const endAdornment = (
<InputAdornment position="end">
<IconButton aria-label="Clear" size="small" onClick={handleClear}>
<ClearButton />
</IconButton>
</InputAdornment>
);
const endAdornment = (
<InputAdornment position="end">
<IconButton aria-label="Clear" onClick={handleClear}>
<ClearButton />
</IconButton>
</InputAdornment>
);
const searchBar = (
<TrackSearch>
<InputBase
data-testid="search-bar-next"
value={value}
placeholder={placeholder}
startAdornment={startAdornment}
endAdornment={clearButton ? endAdornment : defaultEndAdornment}
inputProps={{ 'aria-label': 'Search', ...defaultInputProps }}
fullWidth={fullWidth}
onChange={handleChange}
onKeyDown={handleKeyDown}
{...props}
/>
</TrackSearch>
);
return hasSearchContext ? (
searchBar
) : (
<SearchContextProvider>{searchBar}</SearchContextProvider>
);
};
return (
<TrackSearch>
<InputBase
data-testid="search-bar-next"
ref={ref}
value={value}
placeholder={placeholder}
startAdornment={startAdornment}
endAdornment={clearButton ? endAdornment : defaultEndAdornment}
inputProps={{ 'aria-label': 'Search', ...defaultInputProps }}
fullWidth={fullWidth}
onChange={handleChange}
onKeyDown={handleKeyDown}
{...props}
/>
</TrackSearch>
);
},
);
/**
* Props for {@link SearchBar}.
@@ -160,30 +162,53 @@ export const SearchBarBase = ({
*/
export type SearchBarProps = Partial<SearchBarBaseProps>;
const withContext = (Component: ComponentType<SearchBarProps>) => {
return forwardRef<unknown, SearchBarProps>((props, ref) => (
<SearchContextProvider useParentContext>
<Component {...props} ref={ref} />
</SearchContextProvider>
));
};
/**
* Recommended search bar when you use the Search Provider or Search Context.
*
* @public
*/
export const SearchBar = ({ onChange, ...props }: SearchBarProps) => {
const { term, setTerm } = useSearch();
export const SearchBar = withContext(
forwardRef<unknown, SearchBarProps>(
({ value: initialValue = '', onChange, ...rest }, ref) => {
const { term, setTerm } = useSearch();
const handleChange = useCallback(
(newValue: string) => {
if (onChange) {
onChange(newValue);
} else {
setTerm(newValue);
}
useEffect(() => {
if (initialValue) {
setTerm(String(initialValue));
}
}, [initialValue, setTerm]);
const handleChange = useCallback(
(newValue: string) => {
if (onChange) {
onChange(newValue);
} else {
setTerm(newValue);
}
},
[onChange, setTerm],
);
return (
<AnalyticsContext
attributes={{ pluginId: 'search', extension: 'SearchBar' }}
>
<SearchBarBase
{...rest}
ref={ref}
value={term}
onChange={handleChange}
/>
</AnalyticsContext>
);
},
[onChange, setTerm],
);
return (
<AnalyticsContext
attributes={{ pluginId: 'search', extension: 'SearchBar' }}
>
<SearchBarBase value={term} onChange={handleChange} {...props} />
</AnalyticsContext>
);
};
),
);
@@ -15,4 +15,5 @@
*/
export { SearchBar, SearchBarBase } from './SearchBar';
export type { SearchBarProps, SearchBarBaseProps } from './SearchBar';
+2 -1
View File
@@ -15,8 +15,9 @@
*/
export * from './HighlightedSearchResultText';
export * from './SearchBar';
export * from './SearchAutocomplete';
export * from './SearchFilter';
export * from './SearchResult';
export * from './SearchResultPager';
export * from './SearchBar';
export * from './DefaultResultListItem';