diff --git a/.changeset/search-feet-flash.md b/.changeset/search-feet-flash.md
new file mode 100644
index 0000000000..c7423f0317
--- /dev/null
+++ b/.changeset/search-feet-flash.md
@@ -0,0 +1,59 @@
+---
+'@backstage/plugin-search-react': minor
+---
+
+Provides search autocomplete functionality through a `SearchAutocomplete` component.
+A `SearchAutocompleteDefaultOption` can also be used to render options with icons, primary texts, and secondary texts.
+Example:
+
+```jsx
+import React, { ChangeEvent, useState, useCallback } from 'react';
+import useAsync from 'react-use/lib/useAsync';
+
+import { Grid, Paper } from '@material-ui/core';
+
+import { Page, Content } from '@backstage/core-components';
+import { SearchAutocomplete, SearchAutocompleteDefaultOption} from '@backstage/plugin-search-react';
+
+const OptionsIcon = () =>
+
+const SearchPage = () => {
+ const [inputValue, setInputValue] = useState('');
+
+ const options = useAsync(async () => {
+ // Gets and returns autocomplete options
+ }, [inputValue])
+
+ const useCallback((_event: ChangeEvent<{}>, newInputValue: string) => {
+ setInputValue(newInputValue);
+ }, [setInputValue])
+
+ return (
+
+
+
+
+
+ option.title}
+ renderOption={option => (
+ }
+ primaryText={option.title}
+ secondaryText={option.text}
+ />
+ )}
+ />
+
+
+
+ {'/* Filters and results are omitted */'}
+
+
+ );
+};
+```
diff --git a/.changeset/search-planets-flash.md b/.changeset/search-planets-flash.md
new file mode 100644
index 0000000000..49431128f9
--- /dev/null
+++ b/.changeset/search-planets-flash.md
@@ -0,0 +1,5 @@
+---
+'@backstage/plugin-search': patch
+---
+
+Use the new `inheritParentContextIfAvailable` search context property in `SearchModal` instead of manually checking if a parent context exists, this conditional statement was previously duplicated in more than one component like in `SearchBar` as well and is now only done in ` SearchContextProvider`.
diff --git a/.changeset/search-zebras-tap.md b/.changeset/search-zebras-tap.md
new file mode 100644
index 0000000000..4593478a51
--- /dev/null
+++ b/.changeset/search-zebras-tap.md
@@ -0,0 +1,7 @@
+---
+'@backstage/plugin-search-react': minor
+---
+
+We noticed a repeated check for the existence of a parent context before creating a child search context in more the one component such as Search Modal and Search Bar and to remove code duplication we extract the conditional to the context provider, now you can use it passing an `inheritParentContextIfAvailable` prop to the `SearchContextProvider`.
+
+Note: This added property does not create a local context if there is a parent context and in this case, you cannot use it together with `initialState`, it will result in a type error because the parent context is already initialized.
diff --git a/.changeset/techdocs-feet-dress.md b/.changeset/techdocs-feet-dress.md
new file mode 100644
index 0000000000..d2b2a625af
--- /dev/null
+++ b/.changeset/techdocs-feet-dress.md
@@ -0,0 +1,5 @@
+---
+'@backstage/plugin-techdocs': patch
+---
+
+Use the new `SearchAutocomplete` component in the `TechDocsSearch` component to maintain consistency across search experiences and avoid code duplication.
diff --git a/plugins/search-react/api-report.md b/plugins/search-react/api-report.md
index e4afee45ca..7d5e64757b 100644
--- a/plugins/search-react/api-report.md
+++ b/plugins/search-react/api-report.md
@@ -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;
+// @public
+export const SearchAutocomplete: SearchAutocompleteComponent;
+
+// @public
+export type SearchAutocompleteComponent = (
+ props: SearchAutocompleteProps ,
+) => 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 = Omit<
+ AutocompleteProps ,
+ '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;
+
+// @public
+export const SearchBarBase: ForwardRefExoticComponent;
// @public
export type SearchBarBaseProps = Omit & {
@@ -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 = {
diff --git a/plugins/search-react/src/components/SearchAutocomplete/SearchAutocomplete.stories.tsx b/plugins/search-react/src/components/SearchAutocomplete/SearchAutocomplete.stories.tsx
new file mode 100644
index 0000000000..a43b6b048e
--- /dev/null
+++ b/plugins/search-react/src/components/SearchAutocomplete/SearchAutocomplete.stories.tsx
@@ -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<{}>) => (
+
+
+
+
+
+
+
+
+
+ ),
+ ],
+};
+
+const useStyles = makeStyles(theme => ({
+ root: {
+ padding: theme.spacing(1),
+ },
+}));
+
+export const Default = () => {
+ const classes = useStyles();
+ return (
+
+
+
+ );
+};
+
+export const Outlined = () => {
+ const classes = useStyles();
+ return (
+
+
+
+ );
+};
+
+export const Initialized = () => {
+ const classes = useStyles();
+ const options = ['hello-word', 'petstore', 'spotify'];
+ return (
+
+
+
+ );
+};
+
+export const LoadingOptions = () => {
+ const classes = useStyles();
+ return (
+
+
+
+ );
+};
+
+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 (
+
+ (
+ }
+ primaryText={option.title}
+ secondaryText={option.text}
+ />
+ )}
+ />
+
+ );
+};
diff --git a/plugins/search-react/src/components/SearchAutocomplete/SearchAutocomplete.test.tsx b/plugins/search-react/src/components/SearchAutocomplete/SearchAutocomplete.test.tsx
new file mode 100644
index 0000000000..d9da187541
--- /dev/null
+++ b/plugins/search-react/src/components/SearchAutocomplete/SearchAutocomplete.test.tsx
@@ -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(
+
+
+ ,
+ );
+
+ expect(screen.getByTestId('search-autocomplete')).toBeInTheDocument();
+ });
+
+ it('Show all options by default when focused', async () => {
+ await renderWithEffects(
+
+
+ ,
+ );
+
+ 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(
+
+
+ ,
+ );
+
+ await waitFor(() => {
+ expect(query).toHaveBeenCalledWith({
+ filters: {},
+ pageCursor: undefined,
+ term: options[0],
+ types: [],
+ });
+ });
+ });
+
+ it('Updates context when value is cleared', async () => {
+ await renderWithEffects(
+
+
+ ,
+ );
+
+ 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(
+
+
+ ,
+ );
+
+ 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(
+
+
+ ,
+ );
+
+ await waitFor(() => {
+ expect(
+ screen.getByTestId('search-autocomplete-progressbar'),
+ ).toBeInTheDocument();
+ });
+ });
+
+ it('Uses the default search autocomplete option component', async () => {
+ await renderWithEffects(
+
+ option.title}
+ renderOption={option => (
+ }
+ primaryText={option.title}
+ secondaryText={option.text}
+ />
+ )}
+ />
+ ,
+ );
+
+ 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();
+ });
+ });
+});
diff --git a/plugins/search-react/src/components/SearchAutocomplete/SearchAutocomplete.tsx b/plugins/search-react/src/components/SearchAutocomplete/SearchAutocomplete.tsx
new file mode 100644
index 0000000000..8a5ae7a3ce
--- /dev/null
+++ b/plugins/search-react/src/components/SearchAutocomplete/SearchAutocomplete.tsx
@@ -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 = Omit<
+ AutocompleteProps ,
+ 'renderInput' | 'disableClearable' | 'multiple'
+> & {
+ 'data-testid'?: string;
+ inputPlaceholder?: SearchBarProps['placeholder'];
+ inputDebounceTime?: SearchBarProps['debounceTime'];
+};
+
+/**
+ * Type for {@link SearchAutocomplete}.
+ *
+ * @public
+ */
+export type SearchAutocompleteComponent = (
+ props: SearchAutocompleteProps ,
+) => JSX.Element;
+
+const withContext = (
+ Component: SearchAutocompleteComponent,
+): SearchAutocompleteComponent => {
+ return props => (
+
+
+
+ );
+};
+
+/**
+ * Recommended search autocomplete when you use the Search Provider or Search Context.
+ *
+ * @public
+ */
+export const SearchAutocomplete = withContext(
+ function SearchAutocompleteComponent (
+ props: SearchAutocompleteProps ,
+ ) {
+ 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 ,
+ ) => {
+ setTerm(getInputValue(option));
+ onChange(event, option, reason, details);
+ },
+ [getInputValue, setTerm, onChange],
+ );
+
+ const renderInput = useCallback(
+ ({
+ InputProps: { ref, endAdornment },
+ InputLabelProps,
+ ...params
+ }: AutocompleteRenderInputParams) => (
+
+ ) : (
+ endAdornment
+ )
+ }
+ />
+ ),
+ [loading, inputValue, inputPlaceholder, inputDebounceTime],
+ );
+
+ return (
+
+ );
+ },
+);
diff --git a/plugins/search-react/src/components/SearchAutocomplete/SearchAutocompleteDefaultOption.stories.tsx b/plugins/search-react/src/components/SearchAutocomplete/SearchAutocompleteDefaultOption.stories.tsx
new file mode 100644
index 0000000000..40a3c5cdca
--- /dev/null
+++ b/plugins/search-react/src/components/SearchAutocomplete/SearchAutocompleteDefaultOption.stories.tsx
@@ -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<{}>) => (
+
+
+
+
+
+
+
+
+
+
+
+ ),
+ ],
+};
+
+export const Default = () => (
+
+);
+
+export const Icon = () => (
+ }
+ primaryText="hello-world"
+ />
+);
+
+export const SecondaryText = () => (
+
+);
+
+export const AllCombined = () => (
+ }
+ primaryText="hello-world"
+ secondaryText="Hello World example for gRPC"
+ />
+);
+
+export const CustomTextTypographies = () => (
+ }
+ primaryText="hello-world"
+ primaryTextTypographyProps={{ color: 'primary' }}
+ secondaryText="Hello World example for gRPC"
+ secondaryTextTypographyProps={{ color: 'secondary' }}
+ />
+);
+
+const CustomPrimaryText = ({ children }: PropsWithChildren<{}>) => (
+ {children}
+);
+
+const CustomSecondaryText = ({ children }: PropsWithChildren<{}>) => (
+ {children}
+);
+
+export const CustomTextComponents = () => (
+
+ }
+ primaryText={hello-world }
+ secondaryText={
+ Hello World example for gRPC
+ }
+ disableTextTypography
+ />
+
+);
diff --git a/plugins/search-react/src/components/SearchAutocomplete/SearchAutocompleteDefaultOption.tsx b/plugins/search-react/src/components/SearchAutocomplete/SearchAutocompleteDefaultOption.tsx
new file mode 100644
index 0000000000..b980500ae8
--- /dev/null
+++ b/plugins/search-react/src/components/SearchAutocomplete/SearchAutocompleteDefaultOption.tsx
@@ -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 ? {icon} : null}
+
+ >
+);
diff --git a/plugins/search-react/src/components/SearchAutocomplete/index.ts b/plugins/search-react/src/components/SearchAutocomplete/index.ts
new file mode 100644
index 0000000000..01753f2360
--- /dev/null
+++ b/plugins/search-react/src/components/SearchAutocomplete/index.ts
@@ -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';
diff --git a/plugins/search-react/src/components/SearchBar/SearchBar.tsx b/plugins/search-react/src/components/SearchBar/SearchBar.tsx
index d3d25455ce..9c312718bf 100644
--- a/plugins/search-react/src/components/SearchBar/SearchBar.tsx
+++ b/plugins/search-react/src/components/SearchBar/SearchBar.tsx
@@ -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(Component: ComponentType) {
+ return forwardRef((props, ref) => (
+
+
+
+ ));
+}
+
/**
* Props for {@link SearchBarBase}.
*
@@ -64,95 +72,99 @@ export type SearchBarBaseProps = Omit & {
*
* @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(defaultValue as string);
- const hasSearchContext = useSearchContextCheck();
+export const SearchBarBase: ForwardRefExoticComponent =
+ 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('');
- useDebounce(() => onChange(value), debounceTime, [value]);
+ useEffect(() => {
+ setValue(prevValue =>
+ prevValue !== defaultValue ? String(defaultValue) : prevValue,
+ );
+ }, [defaultValue]);
- const handleChange = useCallback(
- (e: ChangeEvent) => {
- setValue(e.target.value);
- },
- [setValue],
+ useDebounce(() => onChange(value), debounceTime, [value]);
+
+ const handleChange = useCallback(
+ (e: ChangeEvent) => {
+ setValue(e.target.value);
+ },
+ [setValue],
+ );
+
+ const handleKeyDown = useCallback(
+ (e: KeyboardEvent) => {
+ 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 = (
+
+
+
+
+
+ );
+
+ const endAdornment = (
+
+
+
+
+
+ );
+
+ return (
+
+
+
+ );
+ }),
);
- const handleKeyDown = useCallback(
- (e: KeyboardEvent) => {
- 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 = (
-
-
-
-
-
- );
-
- const endAdornment = (
-
-
-
-
-
- );
-
- const searchBar = (
-
-
-
- );
-
- return hasSearchContext ? (
- searchBar
- ) : (
- {searchBar}
- );
-};
-
/**
* Props for {@link SearchBar}.
*
@@ -165,25 +177,40 @@ export type SearchBarProps = Partial;
*
* @public
*/
-export const SearchBar = ({ onChange, ...props }: SearchBarProps) => {
- const { term, setTerm } = useSearch();
+export const SearchBar: ForwardRefExoticComponent = 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 (
-
-
-
- );
-};
+ const handleChange = useCallback(
+ (newValue: string) => {
+ if (onChange) {
+ onChange(newValue);
+ } else {
+ setTerm(newValue);
+ }
+ },
+ [onChange, setTerm],
+ );
+
+ return (
+
+
+
+ );
+ }),
+);
diff --git a/plugins/search-react/src/components/SearchBar/index.tsx b/plugins/search-react/src/components/SearchBar/index.tsx
index 075a0c7dc2..928543916a 100644
--- a/plugins/search-react/src/components/SearchBar/index.tsx
+++ b/plugins/search-react/src/components/SearchBar/index.tsx
@@ -15,4 +15,5 @@
*/
export { SearchBar, SearchBarBase } from './SearchBar';
+
export type { SearchBarProps, SearchBarBaseProps } from './SearchBar';
diff --git a/plugins/search-react/src/components/index.ts b/plugins/search-react/src/components/index.ts
index 263ca4ad59..e254c36dd9 100644
--- a/plugins/search-react/src/components/index.ts
+++ b/plugins/search-react/src/components/index.ts
@@ -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';
diff --git a/plugins/search-react/src/context/SearchContext.tsx b/plugins/search-react/src/context/SearchContext.tsx
index faad50f37a..b4b5f00919 100644
--- a/plugins/search-react/src/context/SearchContext.tsx
+++ b/plugins/search-react/src/context/SearchContext.tsx
@@ -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(
- initialState.pageCursor,
+ initialValue.pageCursor,
);
- const [filters, setFilters] = useState(initialState.filters);
- const [term, setTerm] = useState(initialState.term);
- const [types, setTypes] = useState(initialState.types);
+ const [filters, setFilters] = useState(initialValue.filters);
+ const [term, setTerm] = useState(initialValue.term);
+ const [types, setTypes] = useState(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 (
-
-
+
+
+ {children}
+
);
};
+
+/**
+ * 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}>
+ ) : (
+
+ {children}
+
+ );
+};
diff --git a/plugins/search/src/components/SearchModal/SearchModal.tsx b/plugins/search/src/components/SearchModal/SearchModal.tsx
index dbb8100ec1..9b95a0afaa 100644
--- a/plugins/search/src/components/SearchModal/SearchModal.tsx
+++ b/plugins/search/src/components/SearchModal/SearchModal.tsx
@@ -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 {children} ;
-};
-
/**
* @public
*/
@@ -204,11 +194,11 @@ export const SearchModal = ({
hidden={hidden}
>
{open && (
-
+
{(children && children({ toggleModal })) ?? (
)}
-
+
)}
);
diff --git a/plugins/techdocs/src/search/components/TechDocsSearch.tsx b/plugins/techdocs/src/search/components/TechDocsSearch.tsx
index 647520af8c..aa56229d74 100644
--- a/plugins/techdocs/src/search/components/TechDocsSearch.tsx
+++ b/plugins/techdocs/src/search/components/TechDocsSearch.tsx
@@ -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(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) => {
- 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 (
- ''}
- 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 }) => (
-
- )}
- loading={loading}
- renderInput={params => (
-
-
-
-
-
- ),
- endAdornment: (
-
- {loading ? (
-
- ) : null}
- {params.InputProps.endAdornment}
-
- ),
- }}
- />
- )}
- />
+
+ ''}
+ 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 }) => (
+
+ )}
+ loading={loading}
+ inputDebounceTime={debounceTime}
+ inputPlaceholder={`Search ${entityTitle || entityId.name} docs`}
+ freeSolo={false}
+ />
+
);
};