[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
+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,