Merge pull request #13374 from backstage/camilal/search-autocomplete
[Search] Create a SearchAutocomplete component
This commit is contained in:
@@ -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 = {
|
||||
|
||||
+122
@@ -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}
|
||||
/>
|
||||
);
|
||||
},
|
||||
);
|
||||
+104
@@ -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>
|
||||
);
|
||||
+61
@@ -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';
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
Reference in New Issue
Block a user