[Search] Standardizes input search components (#8532)

* refactor(plugin-search): customize search bar components

Signed-off-by: Camila Belo <camilaibs@gmail.com>

* refactor(plugin-search): use search bar in search modal

Signed-off-by: Camila Belo <camilaibs@gmail.com>

* refactor(plugin-search): customize home page search bar

Signed-off-by: Camila Belo <camilaibs@gmail.com>

* chore(plugin-search): update api reports

Signed-off-by: Camila Belo <camilaibs@gmail.com>

* chore: add changeset file

Signed-off-by: Camila Belo <camilaibs@gmail.com>

* apply review suggestions

Signed-off-by: Camila Belo <camilaibs@gmail.com>

* increase default debounceTime and remove prop from SearchBar in SearchModal + SearchPage in app

Signed-off-by: Emma Indal <emmai@spotify.com>

* update test using default debounce time

Signed-off-by: Emma Indal <emmai@spotify.com>

* add changeset for create-app

Signed-off-by: Emma Indal <emmai@spotify.com>

* fix exports after rebase

Signed-off-by: Emma Indal <emmai@spotify.com>

Co-authored-by: Emma Indal <emmai@spotify.com>
This commit is contained in:
Camila Belo
2021-12-23 12:05:26 +01:00
committed by GitHub
parent 9d31470853
commit 49a696d720
12 changed files with 215 additions and 158 deletions
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/create-app': patch
---
debounceTime prop is removed from the SearchBar component in the SearchPage as the default is set to 200. The prop is safe to remove and makes it easier to stay up to date with any changes in the future.
+15
View File
@@ -0,0 +1,15 @@
---
'@backstage/plugin-search': patch
---
Standardizes the component used as a search box in the search modal and in the composable home page.
After these changes, all search boxes exported by the search plugin are based on the `<SearchBarBase />` component, and this one is based on the `<InputBase />` component of the Material UI. This means that when you use SearchBarBase or one of its derived components (like `SearchBar` and `HomePageSearchBar`) you can pass all properties accepted by InputBase that have not been replaced by the props type of those components.
For example:
```jsx
<SearchInputBase color="secondary" debouceTime={500} />
```
The `color` property is inherited from `InputBaseProps` type and `debouceTime` defined by `SearchBarBaseProps`.
@@ -37,7 +37,7 @@ const SearchPage = () => {
<Grid container direction="row">
<Grid item xs={12}>
<Paper className={classes.bar}>
<SearchBar debounceTime={100} />
<SearchBar />
</Paper>
</Grid>
<Grid item xs={3}>
+44 -31
View File
@@ -10,6 +10,7 @@ import { AsyncState } from 'react-use/lib/useAsync';
import { BackstagePlugin } from '@backstage/core-plugin-api';
import { IconComponent } from '@backstage/core-plugin-api';
import { IndexableDocument } from '@backstage/search-common';
import { InputBaseProps } from '@material-ui/core';
import { JsonObject } from '@backstage/types';
import { default as React_2 } from 'react';
import { ReactElement } from 'react';
@@ -65,10 +66,14 @@ export type FiltersState = {
//
// @public (undocumented)
export const HomePageSearchBar: ({
placeholder,
}: {
placeholder?: string | undefined;
}) => JSX.Element;
className: defaultClassName,
...props
}: Partial<Omit<SearchBarBaseProps, 'onChange' | 'onSubmit'>>) => JSX.Element;
// @public
export type HomePageSearchBarProps = Partial<
Omit<SearchBarBaseProps, 'onChange' | 'onSubmit'>
>;
// Warning: (ae-missing-release-tag) "SearchPage" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal)
//
@@ -88,34 +93,42 @@ export interface SearchApi {
// @public (undocumented)
export const searchApiRef: ApiRef<SearchApi>;
// Warning: (ae-forgotten-export) The symbol "Props" needs to be exported by the entry point index.d.ts
// Warning: (ae-missing-release-tag) "SearchBar" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal)
//
// @public (undocumented)
export const SearchBar: ({
autoFocus,
className,
// @public
export const SearchBar: ({ onChange, ...props }: SearchBarProps) => JSX.Element;
// @public
export const SearchBarBase: ({
onChange,
onKeyDown,
onSubmit,
debounceTime,
placeholder,
clearButton,
}: Props) => JSX.Element;
fullWidth,
value: defaultValue,
inputProps: defaultInputProps,
endAdornment: defaultEndAdornment,
...props
}: SearchBarBaseProps) => JSX.Element;
// @public
export type SearchBarBaseProps = Omit<InputBaseProps, 'onChange'> & {
debounceTime?: number;
clearButton?: boolean;
onClear?: () => void;
onSubmit?: () => void;
onChange: (value: string) => void;
};
// Warning: (ae-missing-release-tag) "SearchBarNext" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal)
//
// @public @deprecated (undocumented)
export const SearchBarNext: ({
autoFocus,
className,
debounceTime,
placeholder,
clearButton,
}: {
autoFocus?: boolean | undefined;
className?: string | undefined;
debounceTime?: number | undefined;
placeholder?: string | undefined;
clearButton?: boolean | undefined;
}) => JSX.Element;
onChange,
...props
}: Partial<SearchBarBaseProps>) => JSX.Element;
// @public
export type SearchBarProps = Partial<SearchBarBaseProps>;
// Warning: (ae-missing-release-tag) "SearchContextProvider" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal)
//
@@ -131,18 +144,18 @@ export const SearchContextProvider: ({
//
// @public (undocumented)
export const SearchFilter: {
({ component: Element, ...props }: Props_2): JSX.Element;
Checkbox(props: Omit<Props_2, 'component'> & Component): JSX.Element;
Select(props: Omit<Props_2, 'component'> & Component): JSX.Element;
({ component: Element, ...props }: Props): JSX.Element;
Checkbox(props: Omit<Props, 'component'> & Component): JSX.Element;
Select(props: Omit<Props, 'component'> & Component): JSX.Element;
};
// Warning: (ae-missing-release-tag) "SearchFilterNext" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal)
//
// @public @deprecated (undocumented)
export const SearchFilterNext: {
({ component: Element, ...props }: Props_2): JSX.Element;
Checkbox(props: Omit<Props_2, 'component'> & Component): JSX.Element;
Select(props: Omit<Props_2, 'component'> & Component): JSX.Element;
({ component: Element, ...props }: Props): JSX.Element;
Checkbox(props: Omit<Props, 'component'> & Component): JSX.Element;
Select(props: Omit<Props, 'component'> & Component): JSX.Element;
};
// Warning: (ae-missing-release-tag) "SearchModal" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal)
@@ -14,10 +14,10 @@
* limitations under the License.
*/
import React from 'react';
import React, { useCallback, useState } from 'react';
import { makeStyles } from '@material-ui/core/styles';
import { SearchBarBase } from '../SearchBar';
import { SearchBarBase, SearchBarBaseProps } from '../SearchBar';
import { useNavigateToQuery } from '../util';
const useStyles = makeStyles({
@@ -28,20 +28,37 @@ const useStyles = makeStyles({
},
});
type Props = {
placeholder?: string;
};
/**
* Props for {@link HomePageSearchBar}.
*
* @public
*/
export type HomePageSearchBarProps = Partial<
Omit<SearchBarBaseProps, 'onChange' | 'onSubmit'>
>;
export const HomePageSearchBar = ({ placeholder }: Props) => {
const [query, setQuery] = React.useState('');
const handleSearch = useNavigateToQuery();
/**
* The search bar created specifically for the composable home page
*
* @public
*/
export const HomePageSearchBar = ({
className: defaultClassName,
...props
}: HomePageSearchBarProps) => {
const classes = useStyles();
const [query, setQuery] = useState('');
const handleSearch = useNavigateToQuery();
const className = defaultClassName
? `${classes.searchBar} ${defaultClassName}`
: classes.searchBar;
const handleSubmit = () => {
handleSearch({ query });
};
const handleChange = React.useCallback(
const handleChange = useCallback(
value => {
setQuery(value);
},
@@ -50,11 +67,11 @@ export const HomePageSearchBar = ({ placeholder }: Props) => {
return (
<SearchBarBase
className={className}
value={query}
onSubmit={handleSubmit}
onChange={handleChange}
value={query}
className={classes.searchBar}
placeholder={placeholder}
{...props}
/>
);
};
@@ -15,3 +15,4 @@
*/
export { HomePageSearchBar } from './HomePageSearchBar';
export type { HomePageSearchBarProps } from './HomePageSearchBar';
@@ -101,6 +101,9 @@ describe('SearchBar', () => {
});
it('Updates term state when text is entered', async () => {
jest.useFakeTimers();
const defaultDebounceTime = 200;
render(
<ApiProvider apis={apiRegistry}>
<SearchContextProvider initialState={initialState}>
@@ -116,6 +119,10 @@ describe('SearchBar', () => {
userEvent.type(textbox, value);
act(() => {
jest.advanceTimersByTime(defaultDebounceTime);
});
await waitFor(() => {
expect(textbox).toHaveValue(value);
});
@@ -14,128 +14,145 @@
* limitations under the License.
*/
import React, { useEffect, KeyboardEvent, useState } from 'react';
import { configApiRef, useApi } from '@backstage/core-plugin-api';
import React, {
ChangeEvent,
KeyboardEvent,
useState,
useEffect,
useCallback,
} from 'react';
import { useDebounce } from 'react-use';
import { InputBase, InputAdornment, IconButton } from '@material-ui/core';
import { configApiRef, useApi } from '@backstage/core-plugin-api';
import {
InputBase,
InputBaseProps,
InputAdornment,
IconButton,
} from '@material-ui/core';
import SearchIcon from '@material-ui/icons/Search';
import ClearButton from '@material-ui/icons/Clear';
import { useSearch } from '../SearchContext';
type PresenterProps = {
value: string;
onChange: (value: string) => void;
/**
* Props for {@link SearchBarBase}.
*
* @public
*/
export type SearchBarBaseProps = Omit<InputBaseProps, 'onChange'> & {
debounceTime?: number;
clearButton?: boolean;
onClear?: () => void;
onSubmit?: () => void;
className?: string;
placeholder?: string;
autoFocus?: boolean;
clearButton?: boolean;
onChange: (value: string) => void;
};
/**
* All search boxes exported by the search plugin are based on the <SearchBarBase />,
* and this one is based on the <InputBase /> component from Material UI.
* Recommended if you don't use Search Provider or Search Context.
*
* @public
*/
export const SearchBarBase = ({
autoFocus,
value,
onChange,
onKeyDown,
onSubmit,
className,
placeholder: overridePlaceholder,
debounceTime = 200,
clearButton = true,
}: PresenterProps) => {
fullWidth = true,
value: defaultValue,
inputProps: defaultInputProps = {},
endAdornment: defaultEndAdornment,
...props
}: SearchBarBaseProps) => {
const configApi = useApi(configApiRef);
const [value, setValue] = useState<string>(defaultValue as string);
const onKeyDown = React.useCallback(
useEffect(() => {
setValue(prevValue =>
prevValue !== defaultValue ? (defaultValue as string) : prevValue,
);
}, [defaultValue]);
useDebounce(() => onChange(value), debounceTime, [value]);
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();
}
},
[onSubmit],
[onKeyDown, onSubmit],
);
const handleClear = React.useCallback(() => {
const handleClear = useCallback(() => {
onChange('');
}, [onChange]);
const placeholder =
overridePlaceholder ??
`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" disabled>
<SearchIcon />
</IconButton>
</InputAdornment>
);
const endAdornment = (
<InputAdornment position="end">
<IconButton aria-label="Clear" onClick={handleClear}>
<ClearButton />
</IconButton>
</InputAdornment>
);
return (
<InputBase
// decision up to adopter, read https://github.com/jsx-eslint/eslint-plugin-jsx-a11y/blob/master/docs/rules/no-autofocus.md#no-autofocus
// eslint-disable-next-line jsx-a11y/no-autofocus
autoFocus={autoFocus}
data-testid="search-bar-next"
fullWidth
placeholder={placeholder}
value={value}
onChange={e => onChange(e.target.value)}
inputProps={{ 'aria-label': 'Search' }}
startAdornment={
<InputAdornment position="start">
<IconButton aria-label="Query" disabled>
<SearchIcon />
</IconButton>
</InputAdornment>
}
endAdornment={
clearButton && (
<InputAdornment position="end">
<IconButton aria-label="Clear" onClick={handleClear}>
<ClearButton />
</IconButton>
</InputAdornment>
)
}
{...(className && { className })}
{...(onSubmit && { onKeyDown })}
placeholder={placeholder}
startAdornment={startAdornment}
endAdornment={clearButton ? endAdornment : defaultEndAdornment}
inputProps={{ 'aria-label': 'Search', ...defaultInputProps }}
fullWidth={fullWidth}
onChange={handleChange}
onKeyDown={handleKeyDown}
{...props}
/>
);
};
type Props = {
autoFocus?: boolean;
className?: string;
debounceTime?: number;
placeholder?: string;
clearButton?: boolean;
};
/**
* Props for {@link SearchBar}.
*
* @public
*/
export type SearchBarProps = Partial<SearchBarBaseProps>;
export const SearchBar = ({
autoFocus,
className,
debounceTime = 0,
placeholder,
clearButton = true,
}: Props) => {
/**
* Recommended search bar when you use the Search Provider or Search Context.
*
* @public
*/
export const SearchBar = ({ onChange, ...props }: SearchBarProps) => {
const { term, setTerm } = useSearch();
const [value, setValue] = useState<string>(term);
useEffect(() => {
setValue(prevValue => (prevValue !== term ? term : prevValue));
}, [term]);
useDebounce(() => setTerm(value), debounceTime, [value]);
const handleQuery = (newValue: string) => {
setValue(newValue);
const handleChange = (newValue: string) => {
setTerm(newValue);
if (onChange) onChange(newValue);
};
const handleClear = () => setValue('');
return (
<SearchBarBase
// decision up to adopter, read https://github.com/jsx-eslint/eslint-plugin-jsx-a11y/blob/master/docs/rules/no-autofocus.md#no-autofocus
// eslint-disable-next-line jsx-a11y/no-autofocus
autoFocus={autoFocus}
className={className}
value={value}
onChange={handleQuery}
onClear={handleClear}
placeholder={placeholder}
clearButton={clearButton}
/>
);
return <SearchBarBase value={term} onChange={handleChange} {...props} />;
};
@@ -15,3 +15,4 @@
*/
export { SearchBar, SearchBarBase } from './SearchBar';
export type { SearchBarProps, SearchBarBaseProps } from './SearchBar';
@@ -13,7 +13,7 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import React, { useEffect, useState } from 'react';
import React from 'react';
import {
Dialog,
DialogActions,
@@ -26,7 +26,7 @@ import {
} from '@material-ui/core';
import { Launch } from '@material-ui/icons/';
import { makeStyles } from '@material-ui/core/styles';
import { SearchBarBase } from '../SearchBar';
import { SearchBar } from '../SearchBar';
import { DefaultResultListItem } from '../DefaultResultListItem';
import { SearchResult } from '../SearchResult';
import { SearchContextProvider, useSearch } from '../SearchContext';
@@ -35,8 +35,6 @@ import { useRouteRef } from '@backstage/core-plugin-api';
import { Link } from '@backstage/core-components';
import { rootRouteRef } from '../../plugin';
import { useDebounce } from 'react-use';
export interface SearchModalProps {
open?: boolean;
toggleModal: () => void;
@@ -61,24 +59,10 @@ export const Modal = ({ open = true, toggleModal }: SearchModalProps) => {
const getSearchLink = useRouteRef(rootRouteRef);
const classes = useStyles();
const { term, setTerm } = useSearch();
const [value, setValue] = useState<string>(term);
useEffect(() => {
setValue(prevValue => (prevValue !== term ? term : prevValue));
}, [term]);
useDebounce(() => setTerm(value), 500, [value]);
const handleQuery = (newValue: string) => {
setValue(newValue);
};
const handleClear = () => setValue('');
const { term } = useSearch();
const handleResultClick = () => {
toggleModal();
handleClear();
};
const handleKeyPress = () => {
@@ -98,12 +82,7 @@ export const Modal = ({ open = true, toggleModal }: SearchModalProps) => {
>
<DialogTitle>
<Paper className={classes.container}>
<SearchBarBase
className={classes.input}
value={value}
onChange={handleQuery}
onClear={handleClear}
/>
<SearchBar className={classes.input} />
</Paper>
</DialogTitle>
<DialogContent>
@@ -114,10 +93,7 @@ export const Modal = ({ open = true, toggleModal }: SearchModalProps) => {
alignItems="center"
>
<Grid item>
<Link
onClick={toggleModal}
to={`${getSearchLink()}?query=${value}`}
>
<Link onClick={toggleModal} to={`${getSearchLink()}?query=${term}`}>
<span className={classes.viewResultsLink}>View Full Results</span>
<Launch color="primary" />
</Link>
+1
View File
@@ -26,3 +26,4 @@ export * from './SearchResultPager';
export * from './SearchType';
export * from './SidebarSearch';
export * from './SidebarSearchModal';
export * from './HomePageComponent';
+5 -1
View File
@@ -26,6 +26,7 @@ export {
Filters,
FiltersButton,
SearchBar,
SearchBarBase,
SearchContextProvider,
SearchFilter,
SearchFilterNext,
@@ -39,9 +40,12 @@ export {
export type {
SearchModalProps,
SidebarSearchModalProps,
HomePageSearchBarProps,
SidebarSearchProps,
FiltersState,
SearchBarProps,
SearchBarBaseProps,
} from './components';
export type { FiltersState } from './components';
export {
DefaultResultListItem,
HomePageSearchBar,