Merge pull request #13374 from backstage/camilal/search-autocomplete

[Search] Create a SearchAutocomplete component
This commit is contained in:
Camila Belo
2022-09-01 14:14:43 +02:00
committed by GitHub
17 changed files with 1100 additions and 250 deletions
+52 -16
View File
@@ -7,8 +7,11 @@
import { ApiRef } from '@backstage/core-plugin-api';
import { AsyncState } from 'react-use/lib/useAsync';
import { AutocompleteProps } from '@material-ui/lab';
import { ForwardRefExoticComponent } from 'react';
import { InputBaseProps } from '@material-ui/core';
import { JsonObject } from '@backstage/types';
import { ListItemTextProps } from '@material-ui/core';
import { PropsWithChildren } from 'react';
import { default as React_2 } from 'react';
import { ReactElement } from 'react';
@@ -74,6 +77,34 @@ export interface SearchApi {
// @public (undocumented)
export const searchApiRef: ApiRef<SearchApi>;
// @public
export const SearchAutocomplete: SearchAutocompleteComponent;
// @public
export type SearchAutocompleteComponent = <Option>(
props: SearchAutocompleteProps<Option>,
) => JSX.Element;
// @public
export const SearchAutocompleteDefaultOption: ({
icon,
primaryText,
primaryTextTypographyProps,
secondaryText,
secondaryTextTypographyProps,
disableTextTypography,
}: SearchAutocompleteDefaultOptionProps) => JSX.Element;
// @public
export type SearchAutocompleteDefaultOptionProps = {
icon?: ReactNode;
primaryText: ListItemTextProps['primary'];
primaryTextTypographyProps?: ListItemTextProps['primaryTypographyProps'];
secondaryText?: ListItemTextProps['secondary'];
secondaryTextTypographyProps?: ListItemTextProps['secondaryTypographyProps'];
disableTextTypography?: ListItemTextProps['disableTypography'];
};
// @public (undocumented)
export type SearchAutocompleteFilterProps = SearchFilterComponentProps & {
filterSelectedOptions?: boolean;
@@ -82,21 +113,20 @@ export type SearchAutocompleteFilterProps = SearchFilterComponentProps & {
};
// @public
export const SearchBar: ({ onChange, ...props }: SearchBarProps) => JSX.Element;
export type SearchAutocompleteProps<Option> = Omit<
AutocompleteProps<Option, undefined, undefined, boolean>,
'renderInput' | 'disableClearable' | 'multiple'
> & {
'data-testid'?: string;
inputPlaceholder?: SearchBarProps['placeholder'];
inputDebounceTime?: SearchBarProps['debounceTime'];
};
// @public
export const SearchBarBase: ({
onChange,
onKeyDown,
onSubmit,
debounceTime,
clearButton,
fullWidth,
value: defaultValue,
inputProps: defaultInputProps,
endAdornment: defaultEndAdornment,
...props
}: SearchBarBaseProps) => JSX.Element;
export const SearchBar: ForwardRefExoticComponent<SearchBarProps>;
// @public
export const SearchBarBase: ForwardRefExoticComponent<SearchBarBaseProps>;
// @public
export type SearchBarBaseProps = Omit<InputBaseProps, 'onChange'> & {
@@ -116,9 +146,15 @@ export const SearchContextProvider: (
) => JSX.Element;
// @public
export type SearchContextProviderProps = PropsWithChildren<{
initialState?: SearchContextState;
}>;
export type SearchContextProviderProps =
| PropsWithChildren<{
initialState?: SearchContextState;
inheritParentContextIfAvailable?: never;
}>
| PropsWithChildren<{
initialState?: never;
inheritParentContextIfAvailable?: boolean;
}>;
// @public (undocumented)
export type SearchContextState = {
@@ -0,0 +1,122 @@
/*
* 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, { ComponentType } from 'react';
import { Grid, makeStyles, Paper } from '@material-ui/core';
import LabelIcon from '@material-ui/icons/Label';
import { TestApiProvider } from '@backstage/test-utils';
import { searchApiRef, MockSearchApi } from '../../api';
import { SearchContextProvider } from '../../context';
import { SearchAutocomplete } from './SearchAutocomplete';
import { SearchAutocompleteDefaultOption } from './SearchAutocompleteDefaultOption';
export default {
title: 'Plugins/Search/SearchAutocomplete',
component: SearchAutocomplete,
decorators: [
(Story: ComponentType<{}>) => (
<TestApiProvider apis={[[searchApiRef, new MockSearchApi()]]}>
<SearchContextProvider>
<Grid container direction="row">
<Grid item xs={12}>
<Story />
</Grid>
</Grid>
</SearchContextProvider>
</TestApiProvider>
),
],
};
const useStyles = makeStyles(theme => ({
root: {
padding: theme.spacing(1),
},
}));
export const Default = () => {
const classes = useStyles();
return (
<Paper className={classes.root}>
<SearchAutocomplete options={['hello-word', 'petstore', 'spotify']} />
</Paper>
);
};
export const Outlined = () => {
const classes = useStyles();
return (
<Paper className={classes.root} variant="outlined">
<SearchAutocomplete options={['hello-word', 'petstore', 'spotify']} />
</Paper>
);
};
export const Initialized = () => {
const classes = useStyles();
const options = ['hello-word', 'petstore', 'spotify'];
return (
<Paper className={classes.root}>
<SearchAutocomplete options={options} value={options[0]} />
</Paper>
);
};
export const LoadingOptions = () => {
const classes = useStyles();
return (
<Paper className={classes.root}>
<SearchAutocomplete options={[]} loading />
</Paper>
);
};
export const RenderingCustomOptions = () => {
const classes = useStyles();
const options = [
{
title: 'hello-world',
text: 'Hello World example for gRPC',
},
{
title: 'petstore',
text: 'The petstore API',
},
{
title: 'spotify',
text: 'The Spotify web API',
},
];
return (
<Paper className={classes.root}>
<SearchAutocomplete
options={options}
renderOption={option => (
<SearchAutocompleteDefaultOption
icon={<LabelIcon titleAccess="Option icon" />}
primaryText={option.title}
secondaryText={option.text}
/>
)}
/>
</Paper>
);
};
@@ -0,0 +1,231 @@
/*
* 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 } from './SearchAutocomplete';
import { SearchAutocompleteDefaultOption } from './SearchAutocompleteDefaultOption';
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).toHaveBeenCalledWith({
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).toHaveBeenCalledWith({
filters: {},
pageCursor: undefined,
term: options[0],
types: [],
});
});
await userEvent.click(screen.getByLabelText('Clear'));
await waitFor(() => {
expect(query).toHaveBeenCalledWith({
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).toHaveBeenCalledWith({
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,161 @@
/*
* 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, useCallback, useMemo } from 'react';
import { CircularProgress } from '@material-ui/core';
import {
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<Option> = Omit<
AutocompleteProps<Option, undefined, undefined, boolean>,
'renderInput' | 'disableClearable' | 'multiple'
> & {
'data-testid'?: string;
inputPlaceholder?: SearchBarProps['placeholder'];
inputDebounceTime?: SearchBarProps['debounceTime'];
};
/**
* Type for {@link SearchAutocomplete}.
*
* @public
*/
export type SearchAutocompleteComponent = <Option>(
props: SearchAutocompleteProps<Option>,
) => JSX.Element;
const withContext = (
Component: SearchAutocompleteComponent,
): SearchAutocompleteComponent => {
return props => (
<SearchContextProvider inheritParentContextIfAvailable>
<Component {...props} />
</SearchContextProvider>
);
};
/**
* Recommended search autocomplete when you use the Search Provider or Search Context.
*
* @public
*/
export const SearchAutocomplete = withContext(
function SearchAutocompleteComponent<Option>(
props: SearchAutocompleteProps<Option>,
) {
const {
loading,
value,
onChange = () => {},
options = [],
getOptionLabel = (option: Option) => String(option),
inputPlaceholder,
inputDebounceTime,
freeSolo = true,
fullWidth = true,
clearOnBlur = false,
'data-testid': dataTestId = 'search-autocomplete',
...rest
} = props;
const { setTerm } = useSearch();
const getInputValue = useCallback(
(option?: null | string | Option) => {
if (!option) return '';
if (typeof option === 'string') return option;
return getOptionLabel(option);
},
[getOptionLabel],
);
const inputValue = useMemo(
() => getInputValue(value),
[value, getInputValue],
);
const handleChange = useCallback(
(
event: ChangeEvent<{}>,
option: null | string | Option,
reason: AutocompleteChangeReason,
details?: AutocompleteChangeDetails<Option>,
) => {
setTerm(getInputValue(option));
onChange(event, option, reason, details);
},
[getInputValue, setTerm, onChange],
);
const renderInput = useCallback(
({
InputProps: { ref, endAdornment },
InputLabelProps,
...params
}: AutocompleteRenderInputParams) => (
<SearchBar
{...params}
ref={ref}
clearButton={false}
value={inputValue}
placeholder={inputPlaceholder}
debounceTime={inputDebounceTime}
endAdornment={
loading ? (
<CircularProgress
data-testid="search-autocomplete-progressbar"
color="inherit"
size={20}
/>
) : (
endAdornment
)
}
/>
),
[loading, inputValue, inputPlaceholder, inputDebounceTime],
);
return (
<Autocomplete
{...rest}
data-testid={dataTestId}
value={value}
onChange={handleChange}
options={options}
getOptionLabel={getOptionLabel}
renderInput={renderInput}
freeSolo={freeSolo}
fullWidth={fullWidth}
clearOnBlur={clearOnBlur}
/>
);
},
);
@@ -0,0 +1,104 @@
/*
* 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, { ComponentType, PropsWithChildren } from 'react';
import { Grid, ListItem } from '@material-ui/core';
import LabelIcon from '@material-ui/icons/Label';
import { TestApiProvider } from '@backstage/test-utils';
import { searchApiRef, MockSearchApi } from '../../api';
import { SearchContextProvider } from '../../context';
import { SearchAutocompleteDefaultOption } from './SearchAutocompleteDefaultOption';
export default {
title: 'Plugins/Search/SearchAutocompleteDefaultOption',
component: SearchAutocompleteDefaultOption,
decorators: [
(Story: ComponentType<{}>) => (
<TestApiProvider apis={[[searchApiRef, new MockSearchApi()]]}>
<SearchContextProvider>
<Grid container direction="row">
<Grid item xs={12}>
<ListItem>
<Story />
</ListItem>
</Grid>
</Grid>
</SearchContextProvider>
</TestApiProvider>
),
],
};
export const Default = () => (
<SearchAutocompleteDefaultOption primaryText="hello-world" />
);
export const Icon = () => (
<SearchAutocompleteDefaultOption
icon={<LabelIcon />}
primaryText="hello-world"
/>
);
export const SecondaryText = () => (
<SearchAutocompleteDefaultOption
primaryText="hello-world"
secondaryText="Hello World example for gRPC"
/>
);
export const AllCombined = () => (
<SearchAutocompleteDefaultOption
icon={<LabelIcon />}
primaryText="hello-world"
secondaryText="Hello World example for gRPC"
/>
);
export const CustomTextTypographies = () => (
<SearchAutocompleteDefaultOption
icon={<LabelIcon />}
primaryText="hello-world"
primaryTextTypographyProps={{ color: 'primary' }}
secondaryText="Hello World example for gRPC"
secondaryTextTypographyProps={{ color: 'secondary' }}
/>
);
const CustomPrimaryText = ({ children }: PropsWithChildren<{}>) => (
<dt>{children}</dt>
);
const CustomSecondaryText = ({ children }: PropsWithChildren<{}>) => (
<dd>{children}</dd>
);
export const CustomTextComponents = () => (
<dl>
<SearchAutocompleteDefaultOption
icon={<LabelIcon />}
primaryText={<CustomPrimaryText>hello-world</CustomPrimaryText>}
secondaryText={
<CustomSecondaryText>Hello World example for gRPC</CustomSecondaryText>
}
disableTextTypography
/>
</dl>
);
@@ -0,0 +1,61 @@
/*
* 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, { ReactNode } from 'react';
import {
ListItemIcon,
ListItemText,
ListItemTextProps,
} from '@material-ui/core';
/**
* 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 autocomplete option 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,26 @@
/*
* 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 } from './SearchAutocomplete';
export { SearchAutocompleteDefaultOption } from './SearchAutocompleteDefaultOption';
export type {
SearchAutocompleteProps,
SearchAutocompleteComponent,
} from './SearchAutocomplete';
export type { SearchAutocompleteDefaultOptionProps } from './SearchAutocompleteDefaultOption';
@@ -20,8 +20,12 @@ import React, {
useState,
useEffect,
useCallback,
forwardRef,
ComponentType,
ForwardRefExoticComponent,
} from 'react';
import useDebounce from 'react-use/lib/useDebounce';
import {
InputBase,
InputBaseProps,
@@ -37,13 +41,17 @@ import {
useApi,
} from '@backstage/core-plugin-api';
import {
SearchContextProvider,
useSearch,
useSearchContextCheck,
} from '../../context';
import { SearchContextProvider, useSearch } from '../../context';
import { TrackSearch } from '../SearchTracker';
function withContext<T>(Component: ComponentType<T>) {
return forwardRef<unknown, T>((props, ref) => (
<SearchContextProvider inheritParentContextIfAvailable>
<Component {...props} ref={ref} />
</SearchContextProvider>
));
}
/**
* Props for {@link SearchBarBase}.
*
@@ -64,95 +72,99 @@ 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: ForwardRefExoticComponent<SearchBarBaseProps> =
withContext(
forwardRef((props, ref) => {
const {
onChange,
onKeyDown = () => {},
onClear = () => {},
onSubmit = () => {},
debounceTime = 200,
clearButton = true,
fullWidth = true,
value: defaultValue,
placeholder: defaultPlaceholder,
inputProps: defaultInputProps = {},
endAdornment: defaultEndAdornment,
...rest
} = props;
useEffect(() => {
setValue(prevValue =>
prevValue !== defaultValue ? (defaultValue as string) : prevValue,
);
}, [defaultValue]);
const configApi = useApi(configApiRef);
const [value, setValue] = useState<string>('');
useDebounce(() => onChange(value), debounceTime, [value]);
useEffect(() => {
setValue(prevValue =>
prevValue !== defaultValue ? String(defaultValue) : prevValue,
);
}, [defaultValue]);
const handleChange = useCallback(
(e: ChangeEvent<HTMLInputElement>) => {
setValue(e.target.value);
},
[setValue],
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();
}
},
[onKeyDown, onSubmit],
);
const handleClear = useCallback(() => {
onChange('');
if (onClear) {
onClear();
}
}, [onChange, onClear]);
const placeholder =
defaultPlaceholder ??
`Search in ${configApi.getOptionalString('app.title') || 'Backstage'}`;
const startAdornment = (
<InputAdornment position="start">
<IconButton aria-label="Query" size="small" disabled>
<SearchIcon />
</IconButton>
</InputAdornment>
);
const endAdornment = (
<InputAdornment position="end">
<IconButton aria-label="Clear" size="small" onClick={handleClear}>
<ClearButton />
</IconButton>
</InputAdornment>
);
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}
{...rest}
/>
</TrackSearch>
);
}),
);
const handleKeyDown = useCallback(
(e: KeyboardEvent<HTMLInputElement>) => {
if (onKeyDown) onKeyDown(e);
if (onSubmit && e.key === 'Enter') {
onSubmit();
}
},
[onKeyDown, onSubmit],
);
const handleClear = useCallback(() => {
onChange('');
}, [onChange]);
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>
);
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>
);
};
/**
* Props for {@link SearchBar}.
*
@@ -165,25 +177,40 @@ export type SearchBarProps = Partial<SearchBarBaseProps>;
*
* @public
*/
export const SearchBar = ({ onChange, ...props }: SearchBarProps) => {
const { term, setTerm } = useSearch();
export const SearchBar: ForwardRefExoticComponent<SearchBarProps> = withContext(
forwardRef((props, ref) => {
const { value: initialValue = '', onChange, ...rest } = props;
const handleChange = useCallback(
(newValue: string) => {
if (onChange) {
onChange(newValue);
} else {
setTerm(newValue);
const { term, setTerm } = useSearch();
useEffect(() => {
if (initialValue) {
setTerm(String(initialValue));
}
},
[onChange, setTerm],
);
}, [initialValue, setTerm]);
return (
<AnalyticsContext
attributes={{ pluginId: 'search', extension: 'SearchBar' }}
>
<SearchBarBase value={term} onChange={handleChange} {...props} />
</AnalyticsContext>
);
};
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>
);
}),
);
@@ -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';
@@ -100,29 +100,16 @@ const searchInitialState: SearchContextState = {
types: [],
};
/**
* Props for {@link SearchContextProvider}
*
* @public
*/
export type SearchContextProviderProps = PropsWithChildren<{
initialState?: SearchContextState;
}>;
/**
* @public
*
* Search context provider which gives you access to shared state between search components
*/
export const SearchContextProvider = (props: SearchContextProviderProps) => {
const { initialState = searchInitialState, children } = props;
const useSearchContextValue = (
initialValue: SearchContextState = searchInitialState,
) => {
const searchApi = useApi(searchApiRef);
const [pageCursor, setPageCursor] = useState<string | undefined>(
initialState.pageCursor,
initialValue.pageCursor,
);
const [filters, setFilters] = useState<JsonObject>(initialState.filters);
const [term, setTerm] = useState<string>(initialState.term);
const [types, setTypes] = useState<string[]>(initialState.types);
const [filters, setFilters] = useState<JsonObject>(initialValue.filters);
const [term, setTerm] = useState<string>(initialValue.term);
const [types, setTypes] = useState<string[]>(initialValue.types);
const prevTerm = usePrevious(term);
@@ -170,11 +157,69 @@ export const SearchContextProvider = (props: SearchContextProviderProps) => {
fetchPreviousPage: hasPreviousPage ? fetchPreviousPage : undefined,
};
const versionedValue = createVersionedValueMap({ 1: value });
return value;
};
export type LocalSearchContextProps = PropsWithChildren<{
initialState?: SearchContextState;
}>;
const LocalSearchContext = (props: SearchContextProviderProps) => {
const { initialState, children } = props;
const value = useSearchContextValue(initialState);
return (
<AnalyticsContext attributes={{ searchTypes: types.sort().join(',') }}>
<SearchContext.Provider value={versionedValue} children={children} />
<AnalyticsContext
attributes={{ searchTypes: value.types.sort().join(',') }}
>
<SearchContext.Provider value={createVersionedValueMap({ 1: value })}>
{children}
</SearchContext.Provider>
</AnalyticsContext>
);
};
/**
* Props for {@link SearchContextProvider}
*
* @public
*/
export type SearchContextProviderProps =
| PropsWithChildren<{
/**
* State initialized by a local context.
*/
initialState?: SearchContextState;
/**
* Do not create an inheritance from the parent, as a new initial state must be defined in a local context.
*/
inheritParentContextIfAvailable?: never;
}>
| PropsWithChildren<{
/**
* Does not accept initial state since it is already initialized by parent context.
*/
initialState?: never;
/**
* If true, don't create a child context if there is a parent one already defined.
* @remarks Defaults to false.
*/
inheritParentContextIfAvailable?: boolean;
}>;
/**
* @public
* Search context provider which gives you access to shared state between search components
*/
export const SearchContextProvider = (props: SearchContextProviderProps) => {
const { initialState, inheritParentContextIfAvailable, children } = props;
const hasParentContext = useSearchContextCheck();
return hasParentContext && inheritParentContextIfAvailable ? (
<>{children}</>
) : (
<LocalSearchContext initialState={initialState}>
{children}
</LocalSearchContext>
);
};
@@ -14,7 +14,7 @@
* limitations under the License.
*/
import React, { PropsWithChildren } from 'react';
import React from 'react';
import {
Dialog,
DialogActions,
@@ -35,7 +35,6 @@ import {
SearchResult,
SearchResultPager,
useSearch,
useSearchContextCheck,
} from '@backstage/plugin-search-react';
import { useRouteRef } from '@backstage/core-plugin-api';
import { Link, useContent } from '@backstage/core-components';
@@ -171,15 +170,6 @@ export const Modal = ({ toggleModal }: SearchModalProps) => {
);
};
const Context = ({ children }: PropsWithChildren<{}>) => {
// Checks if there is a parent context already defined and, if not, creates a new local context.
const hasParentContext = useSearchContextCheck();
if (hasParentContext) {
return <>{children}</>;
}
return <SearchContextProvider>{children}</SearchContextProvider>;
};
/**
* @public
*/
@@ -204,11 +194,11 @@ export const SearchModal = ({
hidden={hidden}
>
{open && (
<Context>
<SearchContextProvider inheritParentContextIfAvailable>
{(children && children({ toggleModal })) ?? (
<Modal toggleModal={toggleModal} />
)}
</Context>
</SearchContextProvider>
)}
</Dialog>
);
@@ -15,29 +15,25 @@
*/
import { CompoundEntityRef } from '@backstage/catalog-model';
import { ResultHighlight } from '@backstage/plugin-search-common';
import {
SearchAutocomplete,
SearchContextProvider,
useSearch,
} from '@backstage/plugin-search-react';
import {
makeStyles,
CircularProgress,
IconButton,
InputAdornment,
TextField,
} from '@material-ui/core';
import SearchIcon from '@material-ui/icons/Search';
import Autocomplete from '@material-ui/lab/Autocomplete';
import React, { ChangeEvent, useEffect, useState } from 'react';
import { makeStyles, Paper } from '@material-ui/core';
import React, { useEffect, useState } from 'react';
import { useNavigate } from 'react-router';
import useDebounce from 'react-use/lib/useDebounce';
import { TechDocsSearchResultListItem } from './TechDocsSearchResultListItem';
const useStyles = makeStyles({
const useStyles = makeStyles(theme => ({
root: {
width: '100%',
},
});
bar: {
padding: theme.spacing(1),
},
}));
/**
* Props for {@link TechDocsSearch}
@@ -62,6 +58,13 @@ type TechDocsDoc = {
type TechDocsSearchResult = {
type: string;
document: TechDocsDoc;
highlight?: ResultHighlight;
};
const isTechDocsSearchResult = (
option: any,
): option is TechDocsSearchResult => {
return option?.document;
};
const TechDocsSearchBar = (props: TechDocsSearchProps) => {
@@ -69,8 +72,6 @@ const TechDocsSearchBar = (props: TechDocsSearchProps) => {
const [open, setOpen] = useState(false);
const navigate = useNavigate();
const {
term,
setTerm,
setFilters,
result: { loading, value: searchVal },
} = useSearch();
@@ -91,10 +92,6 @@ const TechDocsSearchBar = (props: TechDocsSearchProps) => {
};
}, [loading, searchVal]);
const [value, setValue] = useState<string>(term);
useDebounce(() => setTerm(value), debounceTime, [value]);
// Update the filter context when the entityId changes, e.g. when the search
// bar continues to be rendered, navigating between different TechDocs sites.
const { kind, name, namespace } = entityId;
@@ -109,82 +106,54 @@ const TechDocsSearchBar = (props: TechDocsSearchProps) => {
});
}, [kind, namespace, name, setFilters]);
const handleQuery = (e: ChangeEvent<HTMLInputElement>) => {
if (!open) {
setOpen(true);
}
setValue(e.target.value);
};
const handleSelection = (_: any, selection: TechDocsSearchResult | null) => {
if (selection?.document) {
const handleSelection = (
_: any,
selection: TechDocsSearchResult | string | null,
) => {
if (isTechDocsSearchResult(selection)) {
const { location } = selection.document;
navigate(location);
}
};
return (
<Autocomplete
classes={{ root: classes.root }}
data-testid="techdocs-search-bar"
size="small"
open={open}
getOptionLabel={() => ''}
filterOptions={x => {
return x; // This is needed to get renderOption to be called after options change. Bug in material-ui?
}}
onClose={() => {
setOpen(false);
}}
onFocus={() => {
setOpen(true);
}}
onChange={handleSelection}
blurOnSelect
noOptionsText="No results found"
value={null}
options={options}
renderOption={({ document, highlight }) => (
<TechDocsSearchResultListItem
result={document}
lineClamp={3}
asListItem={false}
asLink={false}
title={document.title}
highlight={highlight}
/>
)}
loading={loading}
renderInput={params => (
<TextField
{...params}
data-testid="techdocs-search-bar-input"
variant="outlined"
fullWidth
placeholder={`Search ${entityTitle || entityId.name} docs`}
value={value}
onChange={handleQuery}
InputProps={{
...params.InputProps,
startAdornment: (
<InputAdornment position="start">
<IconButton aria-label="Query" disabled>
<SearchIcon />
</IconButton>
</InputAdornment>
),
endAdornment: (
<React.Fragment>
{loading ? (
<CircularProgress color="inherit" size={20} />
) : null}
{params.InputProps.endAdornment}
</React.Fragment>
),
}}
/>
)}
/>
<Paper className={classes.bar} variant="outlined">
<SearchAutocomplete
classes={{ root: classes.root }}
data-testid="techdocs-search-bar"
size="small"
open={open}
getOptionLabel={() => ''}
filterOptions={x => {
return x; // This is needed to get renderOption to be called after options change. Bug in material-ui?
}}
onClose={() => {
setOpen(false);
}}
onFocus={() => {
setOpen(true);
}}
onChange={handleSelection}
blurOnSelect
noOptionsText="No results found"
value={null}
options={options}
renderOption={({ document, highlight }) => (
<TechDocsSearchResultListItem
result={document}
lineClamp={3}
asListItem={false}
asLink={false}
title={document.title}
highlight={highlight}
/>
)}
loading={loading}
inputDebounceTime={debounceTime}
inputPlaceholder={`Search ${entityTitle || entityId.name} docs`}
freeSolo={false}
/>
</Paper>
);
};