[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:
@@ -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>
|
||||
|
||||
@@ -26,3 +26,4 @@ export * from './SearchResultPager';
|
||||
export * from './SearchType';
|
||||
export * from './SidebarSearch';
|
||||
export * from './SidebarSearchModal';
|
||||
export * from './HomePageComponent';
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user