From 412825d9ebfcf06eb1026992d178924f6cc954ef Mon Sep 17 00:00:00 2001 From: Camila Belo Date: Fri, 26 Aug 2022 12:42:38 +0200 Subject: [PATCH 1/5] feat(search-react): add use parent context prop Signed-off-by: Camila Belo --- .../src/context/SearchContext.tsx | 66 ++++++++++++------- .../SearchModal/SearchModal.test.tsx | 4 +- .../components/SearchModal/SearchModal.tsx | 16 +---- 3 files changed, 49 insertions(+), 37 deletions(-) diff --git a/plugins/search-react/src/context/SearchContext.tsx b/plugins/search-react/src/context/SearchContext.tsx index faad50f37a..41af7635c9 100644 --- a/plugins/search-react/src/context/SearchContext.tsx +++ b/plugins/search-react/src/context/SearchContext.tsx @@ -101,28 +101,20 @@ const searchInitialState: SearchContextState = { }; /** - * Props for {@link SearchContextProvider} - * - * @public + * Creates a new local search context. + * @remarks Use it for isolating this context from parent search contexts. + * @internal */ -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 +162,41 @@ export const SearchContextProvider = (props: SearchContextProviderProps) => { fetchPreviousPage: hasPreviousPage ? fetchPreviousPage : undefined, }; - const versionedValue = createVersionedValueMap({ 1: value }); + return value; +}; - return ( - - +/** + * Props for {@link SearchContextProvider} + * + * @public + */ +export type SearchContextProviderProps = PropsWithChildren<{ + initialState?: SearchContextState; + /** + * If true, don't create a child context if there is a parent one already defined. + * @remarks Default to false. + */ + useParentContext?: boolean; +}>; + +/** + * @public + * Search context provider which gives you access to shared state between search components + */ +export const SearchContextProvider = (props: SearchContextProviderProps) => { + const { initialState, useParentContext, children } = props; + const hasParentContext = useSearchContextCheck(); + const value = useSearchContextValue(initialState); + + return useParentContext && hasParentContext ? ( + <>{children} + ) : ( + + + {children} + ); }; diff --git a/plugins/search/src/components/SearchModal/SearchModal.test.tsx b/plugins/search/src/components/SearchModal/SearchModal.test.tsx index 3ef9291585..2a6be75efb 100644 --- a/plugins/search/src/components/SearchModal/SearchModal.test.tsx +++ b/plugins/search/src/components/SearchModal/SearchModal.test.tsx @@ -55,7 +55,7 @@ describe('SearchModal', () => { ); expect(screen.getByRole('dialog')).toBeInTheDocument(); - expect(query).toHaveBeenCalledTimes(1); + expect(query).toHaveBeenCalledTimes(2); }); it('Should use parent search context if defined', async () => { @@ -133,7 +133,7 @@ describe('SearchModal', () => { }, ); - expect(query).toHaveBeenCalledTimes(1); + expect(query).toHaveBeenCalledTimes(2); await userEvent.keyboard('{Escape}'); expect(toggleModal).toHaveBeenCalledTimes(1); }); diff --git a/plugins/search/src/components/SearchModal/SearchModal.tsx b/plugins/search/src/components/SearchModal/SearchModal.tsx index dbb8100ec1..b7f2162bac 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 })) ?? ( )} - + )} ); From c32945d7874ad637fb39ab5477ffc3baa0ddc843 Mon Sep 17 00:00:00 2001 From: Camila Belo Date: Fri, 26 Aug 2022 13:27:46 +0200 Subject: [PATCH 2/5] feat(search-react): create search bar autocomplete Signed-off-by: Camila Belo --- .../SearchAutocomplete.test.tsx | 233 ++++++++++++++++++ .../SearchAutocomplete/SearchAutocomplete.tsx | 202 +++++++++++++++ .../components/SearchAutocomplete/index.ts | 25 ++ .../src/components/SearchBar/SearchBar.tsx | 233 ++++++++++-------- .../src/components/SearchBar/index.tsx | 1 + plugins/search-react/src/components/index.ts | 3 +- 6 files changed, 592 insertions(+), 105 deletions(-) create mode 100644 plugins/search-react/src/components/SearchAutocomplete/SearchAutocomplete.test.tsx create mode 100644 plugins/search-react/src/components/SearchAutocomplete/SearchAutocomplete.tsx create mode 100644 plugins/search-react/src/components/SearchAutocomplete/index.ts 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'; From 43e375eecab4a99fd40566a4509b4e72250a1d68 Mon Sep 17 00:00:00 2001 From: Camila Belo Date: Mon, 29 Aug 2022 10:45:36 +0200 Subject: [PATCH 3/5] chore: update api reports Signed-off-by: Camila Belo --- plugins/search-react/api-report.md | 635 ++++++++++++++++++++++++++++- 1 file changed, 622 insertions(+), 13 deletions(-) diff --git a/plugins/search-react/api-report.md b/plugins/search-react/api-report.md index e4afee45ca..fcb0378ab0 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 { AutocompleteRenderInputParams } from '@material-ui/lab'; 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,36 @@ export interface SearchApi { // @public (undocumented) export const searchApiRef: ApiRef; +// @public +export const SearchAutocomplete: < + T, + Multiple extends boolean | undefined, + DisableClearable extends boolean | undefined, + FreeSolo extends boolean | undefined, +>( + 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 +115,596 @@ export type SearchAutocompleteFilterProps = SearchFilterComponentProps & { }; // @public -export const SearchBar: ({ onChange, ...props }: SearchBarProps) => JSX.Element; +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; +}; // @public -export const SearchBarBase: ({ - onChange, - onKeyDown, - onSubmit, - debounceTime, - clearButton, - fullWidth, - value: defaultValue, - inputProps: defaultInputProps, - endAdornment: defaultEndAdornment, - ...props -}: SearchBarBaseProps) => JSX.Element; +export const SearchBar: React_2.ForwardRefExoticComponent< + Pick< + Partial, + | 'required' + | 'type' + | 'error' + | 'id' + | 'name' + | 'color' + | 'margin' + | 'translate' + | 'value' + | 'hidden' + | 'dir' + | 'slot' + | 'style' + | 'title' + | 'accessKey' + | 'draggable' + | 'lang' + | 'className' + | 'prefix' + | 'contentEditable' + | 'inputMode' + | 'tabIndex' + | 'disabled' + | 'autoComplete' + | 'autoFocus' + | 'defaultChecked' + | 'defaultValue' + | 'suppressContentEditableWarning' + | 'suppressHydrationWarning' + | 'contextMenu' + | 'placeholder' + | 'spellCheck' + | 'radioGroup' + | 'role' + | 'about' + | 'datatype' + | 'inlist' + | 'property' + | 'resource' + | 'typeof' + | 'vocab' + | 'autoCapitalize' + | 'autoCorrect' + | 'autoSave' + | 'itemProp' + | 'itemScope' + | 'itemType' + | 'itemID' + | 'itemRef' + | 'results' + | 'security' + | 'unselectable' + | 'is' + | 'aria-activedescendant' + | 'aria-atomic' + | 'aria-autocomplete' + | 'aria-busy' + | 'aria-checked' + | 'aria-colcount' + | 'aria-colindex' + | 'aria-colspan' + | 'aria-controls' + | 'aria-current' + | 'aria-describedby' + | 'aria-details' + | 'aria-disabled' + | 'aria-dropeffect' + | 'aria-errormessage' + | 'aria-expanded' + | 'aria-flowto' + | 'aria-grabbed' + | 'aria-haspopup' + | 'aria-hidden' + | 'aria-invalid' + | 'aria-keyshortcuts' + | 'aria-label' + | 'aria-labelledby' + | 'aria-level' + | 'aria-live' + | 'aria-modal' + | 'aria-multiline' + | 'aria-multiselectable' + | 'aria-orientation' + | 'aria-owns' + | 'aria-placeholder' + | 'aria-posinset' + | 'aria-pressed' + | 'aria-readonly' + | 'aria-relevant' + | 'aria-required' + | 'aria-roledescription' + | 'aria-rowcount' + | 'aria-rowindex' + | 'aria-rowspan' + | 'aria-selected' + | 'aria-setsize' + | 'aria-sort' + | 'aria-valuemax' + | 'aria-valuemin' + | 'aria-valuenow' + | 'aria-valuetext' + | 'readOnly' + | 'rows' + | 'dangerouslySetInnerHTML' + | 'onCopy' + | 'onCopyCapture' + | 'onCut' + | 'onCutCapture' + | 'onPaste' + | 'onPasteCapture' + | 'onCompositionEnd' + | 'onCompositionEndCapture' + | 'onCompositionStart' + | 'onCompositionStartCapture' + | 'onCompositionUpdate' + | 'onCompositionUpdateCapture' + | 'onFocus' + | 'onFocusCapture' + | 'onBlur' + | 'onBlurCapture' + | 'onChange' + | 'onChangeCapture' + | 'onBeforeInput' + | 'onBeforeInputCapture' + | 'onInput' + | 'onInputCapture' + | 'onReset' + | 'onResetCapture' + | 'onSubmit' + | 'onSubmitCapture' + | 'onInvalid' + | 'onInvalidCapture' + | 'onLoad' + | 'onLoadCapture' + | 'onError' + | 'onErrorCapture' + | 'onKeyDown' + | 'onKeyDownCapture' + | 'onKeyPress' + | 'onKeyPressCapture' + | 'onKeyUp' + | 'onKeyUpCapture' + | 'onAbort' + | 'onAbortCapture' + | 'onCanPlay' + | 'onCanPlayCapture' + | 'onCanPlayThrough' + | 'onCanPlayThroughCapture' + | 'onDurationChange' + | 'onDurationChangeCapture' + | 'onEmptied' + | 'onEmptiedCapture' + | 'onEncrypted' + | 'onEncryptedCapture' + | 'onEnded' + | 'onEndedCapture' + | 'onLoadedData' + | 'onLoadedDataCapture' + | 'onLoadedMetadata' + | 'onLoadedMetadataCapture' + | 'onLoadStart' + | 'onLoadStartCapture' + | 'onPause' + | 'onPauseCapture' + | 'onPlay' + | 'onPlayCapture' + | 'onPlaying' + | 'onPlayingCapture' + | 'onProgress' + | 'onProgressCapture' + | 'onRateChange' + | 'onRateChangeCapture' + | 'onSeeked' + | 'onSeekedCapture' + | 'onSeeking' + | 'onSeekingCapture' + | 'onStalled' + | 'onStalledCapture' + | 'onSuspend' + | 'onSuspendCapture' + | 'onTimeUpdate' + | 'onTimeUpdateCapture' + | 'onVolumeChange' + | 'onVolumeChangeCapture' + | 'onWaiting' + | 'onWaitingCapture' + | 'onAuxClick' + | 'onAuxClickCapture' + | 'onClick' + | 'onClickCapture' + | 'onContextMenu' + | 'onContextMenuCapture' + | 'onDoubleClick' + | 'onDoubleClickCapture' + | 'onDrag' + | 'onDragCapture' + | 'onDragEnd' + | 'onDragEndCapture' + | 'onDragEnter' + | 'onDragEnterCapture' + | 'onDragExit' + | 'onDragExitCapture' + | 'onDragLeave' + | 'onDragLeaveCapture' + | 'onDragOver' + | 'onDragOverCapture' + | 'onDragStart' + | 'onDragStartCapture' + | 'onDrop' + | 'onDropCapture' + | 'onMouseDown' + | 'onMouseDownCapture' + | 'onMouseEnter' + | 'onMouseLeave' + | 'onMouseMove' + | 'onMouseMoveCapture' + | 'onMouseOut' + | 'onMouseOutCapture' + | 'onMouseOver' + | 'onMouseOverCapture' + | 'onMouseUp' + | 'onMouseUpCapture' + | 'onSelect' + | 'onSelectCapture' + | 'onTouchCancel' + | 'onTouchCancelCapture' + | 'onTouchEnd' + | 'onTouchEndCapture' + | 'onTouchMove' + | 'onTouchMoveCapture' + | 'onTouchStart' + | 'onTouchStartCapture' + | 'onPointerDown' + | 'onPointerDownCapture' + | 'onPointerMove' + | 'onPointerMoveCapture' + | 'onPointerUp' + | 'onPointerUpCapture' + | 'onPointerCancel' + | 'onPointerCancelCapture' + | 'onPointerEnter' + | 'onPointerEnterCapture' + | 'onPointerLeave' + | 'onPointerLeaveCapture' + | 'onPointerOver' + | 'onPointerOverCapture' + | 'onPointerOut' + | 'onPointerOutCapture' + | 'onGotPointerCapture' + | 'onGotPointerCaptureCapture' + | 'onLostPointerCapture' + | 'onLostPointerCaptureCapture' + | 'onScroll' + | 'onScrollCapture' + | 'onWheel' + | 'onWheelCapture' + | 'onAnimationStart' + | 'onAnimationStartCapture' + | 'onAnimationEnd' + | 'onAnimationEndCapture' + | 'onAnimationIteration' + | 'onAnimationIterationCapture' + | 'onTransitionEnd' + | 'onTransitionEndCapture' + | 'classes' + | 'innerRef' + | 'fullWidth' + | 'inputProps' + | 'inputRef' + | 'multiline' + | 'endAdornment' + | 'inputComponent' + | 'renderSuffix' + | 'rowsMax' + | 'rowsMin' + | 'maxRows' + | 'minRows' + | 'startAdornment' + | 'onClear' + | 'debounceTime' + | 'clearButton' + > & + React_2.RefAttributes +>; + +// @public +export const SearchBarBase: React_2.ForwardRefExoticComponent< + Pick< + SearchBarBaseProps, + | 'required' + | 'type' + | 'error' + | 'id' + | 'name' + | 'color' + | 'margin' + | 'translate' + | 'value' + | 'hidden' + | 'dir' + | 'slot' + | 'style' + | 'title' + | 'accessKey' + | 'draggable' + | 'lang' + | 'className' + | 'prefix' + | 'contentEditable' + | 'inputMode' + | 'tabIndex' + | 'disabled' + | 'autoComplete' + | 'autoFocus' + | 'defaultChecked' + | 'defaultValue' + | 'suppressContentEditableWarning' + | 'suppressHydrationWarning' + | 'contextMenu' + | 'placeholder' + | 'spellCheck' + | 'radioGroup' + | 'role' + | 'about' + | 'datatype' + | 'inlist' + | 'property' + | 'resource' + | 'typeof' + | 'vocab' + | 'autoCapitalize' + | 'autoCorrect' + | 'autoSave' + | 'itemProp' + | 'itemScope' + | 'itemType' + | 'itemID' + | 'itemRef' + | 'results' + | 'security' + | 'unselectable' + | 'is' + | 'aria-activedescendant' + | 'aria-atomic' + | 'aria-autocomplete' + | 'aria-busy' + | 'aria-checked' + | 'aria-colcount' + | 'aria-colindex' + | 'aria-colspan' + | 'aria-controls' + | 'aria-current' + | 'aria-describedby' + | 'aria-details' + | 'aria-disabled' + | 'aria-dropeffect' + | 'aria-errormessage' + | 'aria-expanded' + | 'aria-flowto' + | 'aria-grabbed' + | 'aria-haspopup' + | 'aria-hidden' + | 'aria-invalid' + | 'aria-keyshortcuts' + | 'aria-label' + | 'aria-labelledby' + | 'aria-level' + | 'aria-live' + | 'aria-modal' + | 'aria-multiline' + | 'aria-multiselectable' + | 'aria-orientation' + | 'aria-owns' + | 'aria-placeholder' + | 'aria-posinset' + | 'aria-pressed' + | 'aria-readonly' + | 'aria-relevant' + | 'aria-required' + | 'aria-roledescription' + | 'aria-rowcount' + | 'aria-rowindex' + | 'aria-rowspan' + | 'aria-selected' + | 'aria-setsize' + | 'aria-sort' + | 'aria-valuemax' + | 'aria-valuemin' + | 'aria-valuenow' + | 'aria-valuetext' + | 'readOnly' + | 'rows' + | 'dangerouslySetInnerHTML' + | 'onCopy' + | 'onCopyCapture' + | 'onCut' + | 'onCutCapture' + | 'onPaste' + | 'onPasteCapture' + | 'onCompositionEnd' + | 'onCompositionEndCapture' + | 'onCompositionStart' + | 'onCompositionStartCapture' + | 'onCompositionUpdate' + | 'onCompositionUpdateCapture' + | 'onFocus' + | 'onFocusCapture' + | 'onBlur' + | 'onBlurCapture' + | 'onChange' + | 'onChangeCapture' + | 'onBeforeInput' + | 'onBeforeInputCapture' + | 'onInput' + | 'onInputCapture' + | 'onReset' + | 'onResetCapture' + | 'onSubmit' + | 'onSubmitCapture' + | 'onInvalid' + | 'onInvalidCapture' + | 'onLoad' + | 'onLoadCapture' + | 'onError' + | 'onErrorCapture' + | 'onKeyDown' + | 'onKeyDownCapture' + | 'onKeyPress' + | 'onKeyPressCapture' + | 'onKeyUp' + | 'onKeyUpCapture' + | 'onAbort' + | 'onAbortCapture' + | 'onCanPlay' + | 'onCanPlayCapture' + | 'onCanPlayThrough' + | 'onCanPlayThroughCapture' + | 'onDurationChange' + | 'onDurationChangeCapture' + | 'onEmptied' + | 'onEmptiedCapture' + | 'onEncrypted' + | 'onEncryptedCapture' + | 'onEnded' + | 'onEndedCapture' + | 'onLoadedData' + | 'onLoadedDataCapture' + | 'onLoadedMetadata' + | 'onLoadedMetadataCapture' + | 'onLoadStart' + | 'onLoadStartCapture' + | 'onPause' + | 'onPauseCapture' + | 'onPlay' + | 'onPlayCapture' + | 'onPlaying' + | 'onPlayingCapture' + | 'onProgress' + | 'onProgressCapture' + | 'onRateChange' + | 'onRateChangeCapture' + | 'onSeeked' + | 'onSeekedCapture' + | 'onSeeking' + | 'onSeekingCapture' + | 'onStalled' + | 'onStalledCapture' + | 'onSuspend' + | 'onSuspendCapture' + | 'onTimeUpdate' + | 'onTimeUpdateCapture' + | 'onVolumeChange' + | 'onVolumeChangeCapture' + | 'onWaiting' + | 'onWaitingCapture' + | 'onAuxClick' + | 'onAuxClickCapture' + | 'onClick' + | 'onClickCapture' + | 'onContextMenu' + | 'onContextMenuCapture' + | 'onDoubleClick' + | 'onDoubleClickCapture' + | 'onDrag' + | 'onDragCapture' + | 'onDragEnd' + | 'onDragEndCapture' + | 'onDragEnter' + | 'onDragEnterCapture' + | 'onDragExit' + | 'onDragExitCapture' + | 'onDragLeave' + | 'onDragLeaveCapture' + | 'onDragOver' + | 'onDragOverCapture' + | 'onDragStart' + | 'onDragStartCapture' + | 'onDrop' + | 'onDropCapture' + | 'onMouseDown' + | 'onMouseDownCapture' + | 'onMouseEnter' + | 'onMouseLeave' + | 'onMouseMove' + | 'onMouseMoveCapture' + | 'onMouseOut' + | 'onMouseOutCapture' + | 'onMouseOver' + | 'onMouseOverCapture' + | 'onMouseUp' + | 'onMouseUpCapture' + | 'onSelect' + | 'onSelectCapture' + | 'onTouchCancel' + | 'onTouchCancelCapture' + | 'onTouchEnd' + | 'onTouchEndCapture' + | 'onTouchMove' + | 'onTouchMoveCapture' + | 'onTouchStart' + | 'onTouchStartCapture' + | 'onPointerDown' + | 'onPointerDownCapture' + | 'onPointerMove' + | 'onPointerMoveCapture' + | 'onPointerUp' + | 'onPointerUpCapture' + | 'onPointerCancel' + | 'onPointerCancelCapture' + | 'onPointerEnter' + | 'onPointerEnterCapture' + | 'onPointerLeave' + | 'onPointerLeaveCapture' + | 'onPointerOver' + | 'onPointerOverCapture' + | 'onPointerOut' + | 'onPointerOutCapture' + | 'onGotPointerCapture' + | 'onGotPointerCaptureCapture' + | 'onLostPointerCapture' + | 'onLostPointerCaptureCapture' + | 'onScroll' + | 'onScrollCapture' + | 'onWheel' + | 'onWheelCapture' + | 'onAnimationStart' + | 'onAnimationStartCapture' + | 'onAnimationEnd' + | 'onAnimationEndCapture' + | 'onAnimationIteration' + | 'onAnimationIterationCapture' + | 'onTransitionEnd' + | 'onTransitionEndCapture' + | 'classes' + | 'innerRef' + | 'fullWidth' + | 'inputProps' + | 'inputRef' + | 'multiline' + | 'endAdornment' + | 'inputComponent' + | 'renderSuffix' + | 'rowsMax' + | 'rowsMin' + | 'maxRows' + | 'minRows' + | 'startAdornment' + | 'onClear' + | 'debounceTime' + | 'clearButton' + > & + React_2.RefAttributes +>; // @public export type SearchBarBaseProps = Omit & { @@ -118,6 +726,7 @@ export const SearchContextProvider: ( // @public export type SearchContextProviderProps = PropsWithChildren<{ initialState?: SearchContextState; + useParentContext?: boolean; }>; // @public (undocumented) From 18f60427f22c80e53133c8927edf606f2aa410bc Mon Sep 17 00:00:00 2001 From: Camila Belo Date: Mon, 29 Aug 2022 11:03:53 +0200 Subject: [PATCH 4/5] chore: add changeset files Signed-off-by: Camila Belo --- .changeset/search-feet-flash.md | 21 +++++++++++++++++++++ .changeset/search-planets-flash.md | 5 +++++ 2 files changed, 26 insertions(+) create mode 100644 .changeset/search-feet-flash.md create mode 100644 .changeset/search-planets-flash.md diff --git a/.changeset/search-feet-flash.md b/.changeset/search-feet-flash.md new file mode 100644 index 0000000000..fb4a27975d --- /dev/null +++ b/.changeset/search-feet-flash.md @@ -0,0 +1,21 @@ +--- +'@backstage/plugin-search-react': patch +--- + +Add the term autocomplete functionality to the search bar with a `SearchAutocomplete` component. Additionally, we provide a `SearchAutocompleteDefaultOption` to render options with an icon, a primary text and a secondary text. +Example: + +```jsx +// import { SearchAutocomplete, SearchAutocompleteDefaultOption} from '@backstage/plugin-search-react'; + option.title} + renderOption={option => ( + } + primaryText={option.title} + secondaryText={option.text} + /> + )} +/> +``` diff --git a/.changeset/search-planets-flash.md b/.changeset/search-planets-flash.md new file mode 100644 index 0000000000..cc81b8c209 --- /dev/null +++ b/.changeset/search-planets-flash.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-search': patch +--- + +Add `userParentContext` prop to the `SearchContextProvider`, this added property does not create a local context and consumes the parent if it already exists. From ca8d5a6eae130d0dce7bfb53c49ea6d76ec062c8 Mon Sep 17 00:00:00 2001 From: Camila Belo Date: Mon, 29 Aug 2022 15:07:18 +0200 Subject: [PATCH 5/5] refactor: apply review suggestions Signed-off-by: Camila Belo --- .changeset/search-feet-flash.md | 66 +- .changeset/search-planets-flash.md | 2 +- .changeset/search-zebras-tap.md | 7 + .changeset/techdocs-feet-dress.md | 5 + plugins/search-react/api-report.md | 617 +----------------- .../SearchAutocomplete.stories.tsx | 122 ++++ .../SearchAutocomplete.test.tsx | 14 +- .../SearchAutocomplete/SearchAutocomplete.tsx | 167 ++--- ...earchAutocompleteDefaultOption.stories.tsx | 104 +++ .../SearchAutocompleteDefaultOption.tsx | 61 ++ .../components/SearchAutocomplete/index.ts | 11 +- .../src/components/SearchBar/SearchBar.tsx | 248 +++---- .../src/context/SearchContext.tsx | 75 ++- .../SearchModal/SearchModal.test.tsx | 4 +- .../components/SearchModal/SearchModal.tsx | 2 +- .../src/search/components/TechDocsSearch.tsx | 147 ++--- 16 files changed, 684 insertions(+), 968 deletions(-) create mode 100644 .changeset/search-zebras-tap.md create mode 100644 .changeset/techdocs-feet-dress.md create mode 100644 plugins/search-react/src/components/SearchAutocomplete/SearchAutocomplete.stories.tsx create mode 100644 plugins/search-react/src/components/SearchAutocomplete/SearchAutocompleteDefaultOption.stories.tsx create mode 100644 plugins/search-react/src/components/SearchAutocomplete/SearchAutocompleteDefaultOption.tsx diff --git a/.changeset/search-feet-flash.md b/.changeset/search-feet-flash.md index fb4a27975d..c7423f0317 100644 --- a/.changeset/search-feet-flash.md +++ b/.changeset/search-feet-flash.md @@ -1,21 +1,59 @@ --- -'@backstage/plugin-search-react': patch +'@backstage/plugin-search-react': minor --- -Add the term autocomplete functionality to the search bar with a `SearchAutocomplete` component. Additionally, we provide a `SearchAutocompleteDefaultOption` to render options with an icon, a primary text and a secondary text. +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 { SearchAutocomplete, SearchAutocompleteDefaultOption} from '@backstage/plugin-search-react'; - option.title} - renderOption={option => ( - } - primaryText={option.title} - secondaryText={option.text} - /> - )} -/> +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 index cc81b8c209..49431128f9 100644 --- a/.changeset/search-planets-flash.md +++ b/.changeset/search-planets-flash.md @@ -2,4 +2,4 @@ '@backstage/plugin-search': patch --- -Add `userParentContext` prop to the `SearchContextProvider`, this added property does not create a local context and consumes the parent if it already exists. +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 fcb0378ab0..7d5e64757b 100644 --- a/plugins/search-react/api-report.md +++ b/plugins/search-react/api-report.md @@ -8,7 +8,7 @@ import { ApiRef } from '@backstage/core-plugin-api'; import { AsyncState } from 'react-use/lib/useAsync'; import { AutocompleteProps } from '@material-ui/lab'; -import { AutocompleteRenderInputParams } 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'; @@ -78,13 +78,11 @@ export interface SearchApi { export const searchApiRef: ApiRef; // @public -export const SearchAutocomplete: < - T, - Multiple extends boolean | undefined, - DisableClearable extends boolean | undefined, - FreeSolo extends boolean | undefined, ->( - props: SearchAutocompleteProps, +export const SearchAutocomplete: SearchAutocompleteComponent; + +// @public +export type SearchAutocompleteComponent =