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..4502a61d6e --- /dev/null +++ b/plugins/search-react/src/components/SearchAutocomplete/SearchAutocomplete.test.tsx @@ -0,0 +1,233 @@ +/* + * 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, + SearchAutocompleteDefaultOption, +} from './SearchAutocomplete'; + +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).toBeCalledWith({ + filters: {}, + pageCursor: undefined, + term: options[0], + types: [], + }); + }); + }); + + it('Updates context when value is cleared', async () => { + await renderWithEffects( + + + , + ); + + await waitFor(() => { + expect(query).toBeCalledWith({ + filters: {}, + pageCursor: undefined, + term: options[0], + types: [], + }); + }); + + await userEvent.click(screen.getByLabelText('Clear')); + + await waitFor(() => { + expect(query).toBeCalledWith({ + 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).toBeCalledWith({ + 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..1068fefa50 --- /dev/null +++ b/plugins/search-react/src/components/SearchAutocomplete/SearchAutocomplete.tsx @@ -0,0 +1,202 @@ +/* + * 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, ReactNode, useCallback, useMemo } from 'react'; + +import { + CircularProgress, + ListItemIcon, + ListItemText, + ListItemTextProps, +} from '@material-ui/core'; +import { + Value, + 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< + T, + Multiple extends boolean | undefined, + DisableClearable extends boolean | undefined, + FreeSolo extends boolean | undefined, +> = Omit< + AutocompleteProps, + 'renderInput' +> & { + inputDebounceTime?: SearchBarProps['debounceTime']; + renderInput?: (params: AutocompleteRenderInputParams) => JSX.Element; +}; + +const withContext = ( + Component: < + T, + Multiple extends boolean | undefined, + DisableClearable extends boolean | undefined, + FreeSolo extends boolean | undefined, + >( + props: SearchAutocompleteProps, + ) => JSX.Element, +) => { + return < + T, + Multiple extends boolean | undefined, + DisableClearable extends boolean | undefined, + FreeSolo extends boolean | undefined, + >( + props: SearchAutocompleteProps, + ) => ( + + + + ); +}; + +/** + * Recommended search autocomplete when you use the Search Provider or Search Context. + * + * @public + */ +export const SearchAutocomplete = withContext( + < + T, + Multiple extends boolean | undefined, + DisableClearable extends boolean | undefined, + FreeSolo extends boolean | undefined, + >({ + loading, + value, + onChange = () => {}, + options = [], + getOptionLabel = (option: T) => String(option), + renderInput, + inputDebounceTime, + fullWidth = true, + clearOnBlur = false, + ...rest + }: SearchAutocompleteProps) => { + const { setTerm } = useSearch(); + + const inputValue = useMemo(() => { + return value ? getOptionLabel(value as T) : ''; + }, [value, getOptionLabel]); + + const handleChange = useCallback( + ( + event: ChangeEvent<{}>, + newValue: Value, + reason: AutocompleteChangeReason, + details?: AutocompleteChangeDetails, + ) => { + onChange(event, newValue, reason, details); + setTerm(newValue ? getOptionLabel(newValue as T) : ''); + }, + [getOptionLabel, setTerm, onChange], + ); + + const defaultRenderInput = useCallback( + ({ + InputProps: { ref, endAdornment }, + InputLabelProps, + ...params + }: AutocompleteRenderInputParams) => ( + + ) : ( + endAdornment + ) + } + /> + ), + [loading, inputValue, inputDebounceTime], + ); + + return ( + + ); + }, +); + +/** + * 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 bar autocomplete 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..c5534cef33 --- /dev/null +++ b/plugins/search-react/src/components/SearchAutocomplete/index.ts @@ -0,0 +1,25 @@ +/* + * 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, + SearchAutocompleteDefaultOption, +} from './SearchAutocomplete'; + +export type { + SearchAutocompleteProps, + SearchAutocompleteDefaultOptionProps, +} from './SearchAutocomplete'; diff --git a/plugins/search-react/src/components/SearchBar/SearchBar.tsx b/plugins/search-react/src/components/SearchBar/SearchBar.tsx index d3d25455ce..9e91f3f965 100644 --- a/plugins/search-react/src/components/SearchBar/SearchBar.tsx +++ b/plugins/search-react/src/components/SearchBar/SearchBar.tsx @@ -20,8 +20,11 @@ import React, { useState, useEffect, useCallback, + forwardRef, + ComponentType, } from 'react'; import useDebounce from 'react-use/lib/useDebounce'; + import { InputBase, InputBaseProps, @@ -37,11 +40,7 @@ import { useApi, } from '@backstage/core-plugin-api'; -import { - SearchContextProvider, - useSearch, - useSearchContextCheck, -} from '../../context'; +import { SearchContextProvider, useSearch } from '../../context'; import { TrackSearch } from '../SearchTracker'; /** @@ -64,94 +63,97 @@ 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 = forwardRef( + ( + { + onChange, + onKeyDown = () => {}, + onClear = () => {}, + onSubmit = () => {}, + debounceTime = 200, + clearButton = true, + fullWidth = true, + value: defaultValue, + inputProps: defaultInputProps = {}, + endAdornment: defaultEndAdornment, + ...props + }, + ref, + ) => { + const configApi = useApi(configApiRef); + const [value, setValue] = useState(''); - useEffect(() => { - setValue(prevValue => - prevValue !== defaultValue ? (defaultValue as string) : prevValue, + useEffect(() => { + setValue(prevValue => + prevValue !== defaultValue ? String(defaultValue) : prevValue, + ); + }, [defaultValue]); + + useDebounce(() => onChange(value), debounceTime, [value]); + + const handleChange = useCallback( + (e: ChangeEvent) => { + setValue(e.target.value); + }, + [setValue], ); - }, [defaultValue]); - useDebounce(() => onChange(value), debounceTime, [value]); + const handleKeyDown = useCallback( + (e: KeyboardEvent) => { + if (onKeyDown) onKeyDown(e); + if (onSubmit && e.key === 'Enter') { + onSubmit(); + } + }, + [onKeyDown, onSubmit], + ); - 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(); + const handleClear = useCallback(() => { + onChange(''); + if (onClear) { + onClear(); } - }, - [onKeyDown, onSubmit], - ); + }, [onChange, onClear]); - const handleClear = useCallback(() => { - onChange(''); - }, [onChange]); + const placeholder = `Search in ${ + configApi.getOptionalString('app.title') || 'Backstage' + }`; - const placeholder = `Search in ${ - configApi.getOptionalString('app.title') || 'Backstage' - }`; + const startAdornment = ( + + + + + + ); - const startAdornment = ( - - - - - - ); + const endAdornment = ( + + + + + + ); - const endAdornment = ( - - - - - - ); - - const searchBar = ( - - - - ); - - return hasSearchContext ? ( - searchBar - ) : ( - {searchBar} - ); -}; + return ( + + + + ); + }, +); /** * Props for {@link SearchBar}. @@ -160,30 +162,53 @@ export const SearchBarBase = ({ */ export type SearchBarProps = Partial; +const withContext = (Component: ComponentType) => { + return forwardRef((props, ref) => ( + + + + )); +}; + /** * Recommended search bar when you use the Search Provider or Search Context. * * @public */ -export const SearchBar = ({ onChange, ...props }: SearchBarProps) => { - const { term, setTerm } = useSearch(); +export const SearchBar = withContext( + forwardRef( + ({ value: initialValue = '', onChange, ...rest }, ref) => { + const { term, setTerm } = useSearch(); - const handleChange = useCallback( - (newValue: string) => { - if (onChange) { - onChange(newValue); - } else { - setTerm(newValue); - } + useEffect(() => { + if (initialValue) { + setTerm(String(initialValue)); + } + }, [initialValue, setTerm]); + + const handleChange = useCallback( + (newValue: string) => { + if (onChange) { + onChange(newValue); + } else { + setTerm(newValue); + } + }, + [onChange, setTerm], + ); + + return ( + + + + ); }, - [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';