diff --git a/.changeset/famous-walls-visit.md b/.changeset/famous-walls-visit.md new file mode 100644 index 0000000000..b57702a392 --- /dev/null +++ b/.changeset/famous-walls-visit.md @@ -0,0 +1,6 @@ +--- +'@backstage/plugin-search': patch +'@backstage/plugin-search-react': patch +--- + +Components ``, `` (including ``), `` (including `.Checkbox`, `.Select`, and `.Autocomplete` static prop components), ``, and `` are now exported from `@backstage/plugin-search-react`. They are now deprecated in `@backstage/plugin-search` and will be removed in a future release. diff --git a/.changeset/red-apes-tell.md b/.changeset/red-apes-tell.md new file mode 100644 index 0000000000..ee0ace6a4c --- /dev/null +++ b/.changeset/red-apes-tell.md @@ -0,0 +1,17 @@ +--- +'@backstage/create-app': patch +--- + +Components ``, ``, ``, and `` are now deprecated in `@backstage/plugin-search` and should be imported from `@backstage/plugin-search-react` instead. + +To upgrade your App, update the following in `packages/app/src/components/search/SearchPage.tsx`: + +```diff +import { + DefaultResultListItem + SearchBar + SearchFilter + SearchResult +- } from `@backstage/plugin-search`; ++ } from `@backstage/plugin-search-react`; +``` diff --git a/.changeset/strange-flies-march.md b/.changeset/strange-flies-march.md new file mode 100644 index 0000000000..70a15b41d9 --- /dev/null +++ b/.changeset/strange-flies-march.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-search': minor +--- + +The pre-alpha ``, ``, `etc...` components have been removed. In the unlikely event you were still using/referencing them, please update to using their non-`*Next` equivalents from either `@backstage/plugin-search-react` or `@backstage/plugin-search`. diff --git a/docs/assets/search/architecture.drawio.svg b/docs/assets/search/architecture.drawio.svg index 709a008b43..b85541b804 100644 --- a/docs/assets/search/architecture.drawio.svg +++ b/docs/assets/search/architecture.drawio.svg @@ -1,17 +1,36 @@ - + + + + + +
+
+
+
+ +
+
+
+
+
+
+
+ +
+
- + -
+
@@ -21,7 +40,7 @@
- + @@ -103,11 +122,11 @@ - + -
+
@backstage/plugin-xyz @@ -115,7 +134,7 @@
- + @backstage/plugin-xyz @@ -251,10 +270,7 @@ - - - - + @@ -274,11 +290,14 @@ - + + + + -
+
@@ -288,18 +307,18 @@
- + Search API - - + + -
+
@@ -309,7 +328,7 @@
- + Components @@ -824,11 +843,11 @@ - + -
+
@@ -844,16 +863,19 @@
- + Individual frontend... - + + + + -
+
@@ -863,20 +885,79 @@
- + Search Cont... - - - - - - - - - + + + + + + + + + + + + + +
+
+
+ @backstage/plugin-search-react +
+
+
+
+ + @backstage/plugin-search-react + +
+
+ + + + + + + + +
+
+
+ + Components + +
+
+
+
+ + Components + +
+
+ + + + +
+
+
+ + Search Client + +
+
+
+
+ + Search Client + +
+
diff --git a/docs/features/search/getting-started.md b/docs/features/search/getting-started.md index 14ad523740..d0e13eda55 100644 --- a/docs/features/search/getting-started.md +++ b/docs/features/search/getting-started.md @@ -18,7 +18,7 @@ If you haven't setup Backstage already, start ```bash # From your Backstage root directory -yarn add --cwd packages/app @backstage/plugin-search +yarn add --cwd packages/app @backstage/plugin-search @backstage/plugin-search-react ``` Create a new `packages/app/src/components/search/SearchPage.tsx` file in your @@ -33,7 +33,7 @@ import { SearchResult, DefaultResultListItem, SearchFilter, -} from '@backstage/plugin-search'; +} from '@backstage/plugin-search-react'; import { CatalogResultListItem } from '@backstage/plugin-catalog'; export const searchPage = ( @@ -213,7 +213,7 @@ apiRouter.use('/search', await search(searchEnv)); ### Frontend -The Search Plugin exposes several default filter types as static properties, +The Search Plugin web library (`@backstage/plugin-search-react`) exposes several default filter types as static properties, including `` and ``. These allow you to provide values relevant to your Backstage instance that, when selected, get passed to the backend. @@ -237,7 +237,7 @@ If you have advanced filter needs, you can specify your own filter component like this (although new core filter contributions are welcome): ```tsx -import { useSearch, SearchFilter } from '@backstage/plugin-search'; +import { useSearch, SearchFilter } from '@backstage/plugin-search-react'; const MyCustomFilter = () => { // Note: filters contain filter data from other filter components. Be sure diff --git a/packages/app/src/components/search/SearchModal.tsx b/packages/app/src/components/search/SearchModal.tsx index 4af5511443..f5f4204302 100644 --- a/packages/app/src/components/search/SearchModal.tsx +++ b/packages/app/src/components/search/SearchModal.tsx @@ -38,16 +38,15 @@ import { catalogApiRef, CATALOG_FILTER_EXISTS, } from '@backstage/plugin-catalog-react'; +import { searchPlugin, SearchType } from '@backstage/plugin-search'; import { DefaultResultListItem, - SearchBar, SearchFilter, - searchPlugin, + SearchBar, SearchResult, SearchResultPager, - SearchType, -} from '@backstage/plugin-search'; -import { useSearch } from '@backstage/plugin-search-react'; + useSearch, +} from '@backstage/plugin-search-react'; import { TechDocsSearchResultListItem } from '@backstage/plugin-techdocs'; const useStyles = makeStyles(theme => ({ diff --git a/packages/app/src/components/search/SearchPage.tsx b/packages/app/src/components/search/SearchPage.tsx index ee144b2cde..9a2e85bd8c 100644 --- a/packages/app/src/components/search/SearchPage.tsx +++ b/packages/app/src/components/search/SearchPage.tsx @@ -29,15 +29,15 @@ import { catalogApiRef, CATALOG_FILTER_EXISTS, } from '@backstage/plugin-catalog-react'; +import { SearchType } from '@backstage/plugin-search'; import { DefaultResultListItem, SearchBar, SearchFilter, SearchResult, SearchResultPager, - SearchType, -} from '@backstage/plugin-search'; -import { useSearch } from '@backstage/plugin-search-react'; + useSearch, +} from '@backstage/plugin-search-react'; import { TechDocsSearchResultListItem } from '@backstage/plugin-techdocs'; import { Grid, List, makeStyles, Paper, Theme } from '@material-ui/core'; import React from 'react'; diff --git a/packages/create-app/templates/default-app/packages/app/src/components/search/SearchPage.tsx b/packages/create-app/templates/default-app/packages/app/src/components/search/SearchPage.tsx index d4c7c92346..1b3bf5b77d 100644 --- a/packages/create-app/templates/default-app/packages/app/src/components/search/SearchPage.tsx +++ b/packages/create-app/templates/default-app/packages/app/src/components/search/SearchPage.tsx @@ -8,14 +8,14 @@ import { } from '@backstage/plugin-catalog-react'; import { TechDocsSearchResultListItem } from '@backstage/plugin-techdocs'; +import { SearchType } from '@backstage/plugin-search'; import { + DefaultResultListItem, SearchBar, SearchFilter, SearchResult, - SearchType, - DefaultResultListItem, -} from '@backstage/plugin-search'; -import { useSearch } from '@backstage/plugin-search-react'; + useSearch, +} from '@backstage/plugin-search-react'; import { CatalogIcon, Content, diff --git a/plugins/search-react/api-report.md b/plugins/search-react/api-report.md index 9f61070854..f9bb993fd6 100644 --- a/plugins/search-react/api-report.md +++ b/plugins/search-react/api-report.md @@ -7,12 +7,40 @@ import { ApiRef } from '@backstage/core-plugin-api'; import { AsyncState } from 'react-use/lib/useAsync'; +import { InputBaseProps } from '@material-ui/core'; import { JsonObject } from '@backstage/types'; import { PropsWithChildren } from 'react'; import { default as React_2 } from 'react'; +import { ReactElement } from 'react'; +import { ReactNode } from 'react'; +import { ResultHighlight } from '@backstage/plugin-search-common'; +import { SearchDocument } from '@backstage/plugin-search-common'; import { SearchQuery } from '@backstage/plugin-search-common'; +import { SearchResult as SearchResult_2 } from '@backstage/plugin-search-common'; import { SearchResultSet } from '@backstage/plugin-search-common'; +// @public (undocumented) +export const AutocompleteFilter: ( + props: SearchAutocompleteFilterProps, +) => JSX.Element; + +// @public (undocumented) +export const CheckboxFilter: (props: SearchFilterComponentProps) => JSX.Element; + +// @public (undocumented) +export const DefaultResultListItem: ( + props: DefaultResultListItemProps, +) => JSX.Element; + +// @public +export type DefaultResultListItemProps = { + icon?: ReactNode; + secondaryAction?: ReactNode; + result: SearchDocument; + highlight?: ResultHighlight; + lineClamp?: number; +}; + // @public (undocumented) export const HighlightedSearchResultText: ({ text, @@ -20,7 +48,7 @@ export const HighlightedSearchResultText: ({ postTag, }: HighlightedSearchResultTextProps) => JSX.Element; -// @public (undocumented) +// @public export type HighlightedSearchResultTextProps = { text: string; preTag: string; @@ -45,6 +73,42 @@ export interface SearchApi { // @public (undocumented) export const searchApiRef: ApiRef; +// @public (undocumented) +export type SearchAutocompleteFilterProps = SearchFilterComponentProps & { + filterSelectedOptions?: boolean; + limitTags?: number; + multiple?: boolean; +}; + +// @public +export const SearchBar: ({ onChange, ...props }: SearchBarProps) => JSX.Element; + +// @public +export const SearchBarBase: ({ + onChange, + onKeyDown, + onSubmit, + debounceTime, + clearButton, + fullWidth, + value: defaultValue, + inputProps: defaultInputProps, + endAdornment: defaultEndAdornment, + ...props +}: SearchBarBaseProps) => JSX.Element; + +// @public +export type SearchBarBaseProps = Omit & { + debounceTime?: number; + clearButton?: boolean; + onClear?: () => void; + onSubmit?: () => void; + onChange: (value: string) => void; +}; + +// @public +export type SearchBarProps = Partial; + // @public export const SearchContextProvider: ( props: SearchContextProviderProps, @@ -74,6 +138,55 @@ export type SearchContextValue = { fetchPreviousPage?: React_2.DispatchWithoutAction; } & SearchContextState; +// @public (undocumented) +export const SearchFilter: { + ({ component: Element, ...props }: SearchFilterWrapperProps): JSX.Element; + Checkbox( + props: Omit & + SearchFilterComponentProps, + ): JSX.Element; + Select( + props: Omit & + SearchFilterComponentProps, + ): JSX.Element; + Autocomplete(props: SearchAutocompleteFilterProps): JSX.Element; +}; + +// @public (undocumented) +export type SearchFilterComponentProps = { + className?: string; + name: string; + label?: string; + values?: string[] | ((partial: string) => Promise); + defaultValue?: string[] | string | null; + valuesDebounceMs?: number; +}; + +// @public (undocumented) +export type SearchFilterWrapperProps = SearchFilterComponentProps & { + component: (props: SearchFilterComponentProps) => ReactElement; + debug?: boolean; +}; + +// @public (undocumented) +export const SearchResult: (props: SearchResultProps) => JSX.Element; + +// @public +export const SearchResultComponent: ({ + children, +}: SearchResultProps) => JSX.Element; + +// @public (undocumented) +export const SearchResultPager: () => JSX.Element; + +// @public +export type SearchResultProps = { + children: (results: { results: SearchResult_2[] }) => JSX.Element; +}; + +// @public (undocumented) +export const SelectFilter: (props: SearchFilterComponentProps) => JSX.Element; + // @public export const useSearch: () => SearchContextValue; diff --git a/plugins/search-react/package.json b/plugins/search-react/package.json index 5646ce9ee1..60cdc479bb 100644 --- a/plugins/search-react/package.json +++ b/plugins/search-react/package.json @@ -32,11 +32,16 @@ }, "dependencies": { "@backstage/plugin-search-common": "^0.3.5-next.0", + "@backstage/core-components": "^0.9.5-next.1", "@backstage/core-plugin-api": "^1.0.3-next.0", "@backstage/version-bridge": "^1.0.1", - "react-use": "^17.3.2", + "@backstage/theme": "^0.2.15", "@backstage/types": "^1.0.0", - "@material-ui/core": "^4.12.2" + "@material-ui/core": "^4.12.2", + "@material-ui/icons": "^4.9.1", + "@material-ui/lab": "4.0.0-alpha.57", + "react-router": "6.0.0-beta.0", + "react-use": "^17.3.2" }, "peerDependencies": { "@types/react": "^16.13.1 || ^17.0.0", @@ -47,7 +52,8 @@ "@backstage/test-utils": "^1.1.1-next.0", "@testing-library/react": "^12.1.3", "@testing-library/react-hooks": "^8.0.0", - "@testing-library/jest-dom": "^5.10.1" + "@testing-library/jest-dom": "^5.10.1", + "@testing-library/user-event": "^14.0.0" }, "files": [ "dist" diff --git a/plugins/search/src/components/DefaultResultListItem/DefaultResultListItem.stories.tsx b/plugins/search-react/src/components/DefaultResultListItem/DefaultResultListItem.stories.tsx similarity index 98% rename from plugins/search/src/components/DefaultResultListItem/DefaultResultListItem.stories.tsx rename to plugins/search-react/src/components/DefaultResultListItem/DefaultResultListItem.stories.tsx index fb56d1ecdb..3b54e69a19 100644 --- a/plugins/search/src/components/DefaultResultListItem/DefaultResultListItem.stories.tsx +++ b/plugins/search-react/src/components/DefaultResultListItem/DefaultResultListItem.stories.tsx @@ -1,5 +1,5 @@ /* - * Copyright 2021 The Backstage Authors + * 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. diff --git a/plugins/search/src/components/DefaultResultListItem/DefaultResultListItem.test.tsx b/plugins/search-react/src/components/DefaultResultListItem/DefaultResultListItem.test.tsx similarity index 98% rename from plugins/search/src/components/DefaultResultListItem/DefaultResultListItem.test.tsx rename to plugins/search-react/src/components/DefaultResultListItem/DefaultResultListItem.test.tsx index 26d081b3b6..e94d394639 100644 --- a/plugins/search/src/components/DefaultResultListItem/DefaultResultListItem.test.tsx +++ b/plugins/search-react/src/components/DefaultResultListItem/DefaultResultListItem.test.tsx @@ -1,5 +1,5 @@ /* - * Copyright 2021 The Backstage Authors + * 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. diff --git a/plugins/search/src/components/DefaultResultListItem/DefaultResultListItem.tsx b/plugins/search-react/src/components/DefaultResultListItem/DefaultResultListItem.tsx similarity index 73% rename from plugins/search/src/components/DefaultResultListItem/DefaultResultListItem.tsx rename to plugins/search-react/src/components/DefaultResultListItem/DefaultResultListItem.tsx index 5ce2d0744a..ff6f3d7fe1 100644 --- a/plugins/search/src/components/DefaultResultListItem/DefaultResultListItem.tsx +++ b/plugins/search-react/src/components/DefaultResultListItem/DefaultResultListItem.tsx @@ -1,5 +1,5 @@ /* - * Copyright 2021 The Backstage Authors + * 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. @@ -15,11 +15,12 @@ */ import React, { ReactNode } from 'react'; +import { AnalyticsContext } from '@backstage/core-plugin-api'; import { ResultHighlight, SearchDocument, } from '@backstage/plugin-search-common'; -import { HighlightedSearchResultText } from '@backstage/plugin-search-react'; +import { HighlightedSearchResultText } from '../HighlightedSearchResultText'; import { ListItem, ListItemIcon, @@ -29,7 +30,12 @@ import { } from '@material-ui/core'; import { Link } from '@backstage/core-components'; -type Props = { +/** + * Props for {@link DefaultResultListItem} + * + * @public + */ +export type DefaultResultListItemProps = { icon?: ReactNode; secondaryAction?: ReactNode; result: SearchDocument; @@ -37,13 +43,18 @@ type Props = { lineClamp?: number; }; -export const DefaultResultListItem = ({ +/** + * A default result list item. + * + * @public + */ +export const DefaultResultListItemComponent = ({ result, highlight, icon, secondaryAction, lineClamp = 5, -}: Props) => { +}: DefaultResultListItemProps) => { return ( @@ -88,3 +99,23 @@ export const DefaultResultListItem = ({ ); }; + +/** + * @public + */ +const HigherOrderDefaultResultListItem = ( + props: DefaultResultListItemProps, +) => { + return ( + + + + ); +}; + +export { HigherOrderDefaultResultListItem as DefaultResultListItem }; diff --git a/plugins/search/src/components/DefaultResultListItem/index.ts b/plugins/search-react/src/components/DefaultResultListItem/index.ts similarity index 84% rename from plugins/search/src/components/DefaultResultListItem/index.ts rename to plugins/search-react/src/components/DefaultResultListItem/index.ts index 77f975a9ef..a555f25c02 100644 --- a/plugins/search/src/components/DefaultResultListItem/index.ts +++ b/plugins/search-react/src/components/DefaultResultListItem/index.ts @@ -1,5 +1,5 @@ /* - * Copyright 2021 The Backstage Authors + * 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. @@ -15,3 +15,4 @@ */ export { DefaultResultListItem } from './DefaultResultListItem'; +export type { DefaultResultListItemProps } from './DefaultResultListItem'; diff --git a/plugins/search-react/src/components/HighlightedSearchResultText/HighlightedSearchResultText.tsx b/plugins/search-react/src/components/HighlightedSearchResultText/HighlightedSearchResultText.tsx index a749f184fe..bd6ef2d09c 100644 --- a/plugins/search-react/src/components/HighlightedSearchResultText/HighlightedSearchResultText.tsx +++ b/plugins/search-react/src/components/HighlightedSearchResultText/HighlightedSearchResultText.tsx @@ -25,6 +25,8 @@ const useStyles = makeStyles( ); /** + * Props for {@link HighlightedSearchResultText}. + * * @public */ export type HighlightedSearchResultTextProps = { diff --git a/plugins/search/src/components/SearchBar/SearchBar.stories.tsx b/plugins/search-react/src/components/SearchBar/SearchBar.stories.tsx similarity index 93% rename from plugins/search/src/components/SearchBar/SearchBar.stories.tsx rename to plugins/search-react/src/components/SearchBar/SearchBar.stories.tsx index 9728101e97..88aed35f8d 100644 --- a/plugins/search/src/components/SearchBar/SearchBar.stories.tsx +++ b/plugins/search-react/src/components/SearchBar/SearchBar.stories.tsx @@ -1,5 +1,5 @@ /* - * Copyright 2021 The Backstage Authors + * 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. @@ -14,14 +14,14 @@ * limitations under the License. */ -import { Grid, makeStyles, Paper } from '@material-ui/core'; import React, { ComponentType } from 'react'; -import { - searchApiRef, - MockSearchApi, - SearchContextProvider, -} from '@backstage/plugin-search-react'; +import { Grid, makeStyles, Paper } from '@material-ui/core'; + import { TestApiProvider } from '@backstage/test-utils'; + +import { searchApiRef, MockSearchApi } from '../../api'; +import { SearchContextProvider } from '../../context'; + import { SearchBar } from './SearchBar'; export default { diff --git a/plugins/search-react/src/components/SearchBar/SearchBar.test.tsx b/plugins/search-react/src/components/SearchBar/SearchBar.test.tsx new file mode 100644 index 0000000000..d65278f7ec --- /dev/null +++ b/plugins/search-react/src/components/SearchBar/SearchBar.test.tsx @@ -0,0 +1,352 @@ +/* + * 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, render, waitFor, act } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; + +import { configApiRef, analyticsApiRef } from '@backstage/core-plugin-api'; +import { ApiProvider, ConfigReader } from '@backstage/core-app-api'; +import { MockAnalyticsApi, TestApiRegistry } from '@backstage/test-utils'; + +import { searchApiRef } from '../../api'; +import { SearchContextProvider } from '../../context'; +import { SearchBar } from './SearchBar'; + +jest.mock('@backstage/core-plugin-api', () => ({ + ...jest.requireActual('@backstage/core-plugin-api'), +})); + +describe('SearchBar', () => { + const initialState = { + term: '', + filters: {}, + types: ['*'], + pageCursor: '', + }; + + const query = jest.fn().mockResolvedValue({}); + const analyticsApiSpy = new MockAnalyticsApi(); + let apiRegistry: TestApiRegistry; + + apiRegistry = TestApiRegistry.from( + [ + configApiRef, + new ConfigReader({ + app: { title: 'Mock title' }, + }), + ], + [searchApiRef, { query }], + ); + + const name = 'Search'; + const term = 'term'; + + afterAll(() => { + jest.resetAllMocks(); + }); + + it('Renders without exploding', async () => { + render( + + + + + , + ); + + await waitFor(() => { + expect(screen.getByRole('textbox', { name })).toBeInTheDocument(); + expect( + screen.getByPlaceholderText('Search in Mock title'), + ).toBeInTheDocument(); + }); + }); + + it('Renders with custom placeholder', async () => { + render( + + + + + , + , + ); + + await waitFor(() => { + expect( + screen.getByPlaceholderText('This is a custom placeholder'), + ).toBeInTheDocument(); + }); + }); + + it('Renders based on initial search', async () => { + render( + + + + + , + , + ); + + await waitFor(() => { + expect(screen.getByRole('textbox', { name })).toHaveValue(term); + }); + }); + + it('Updates term state when text is entered', async () => { + const user = userEvent.setup({ delay: null }); + jest.useFakeTimers(); + const defaultDebounceTime = 200; + + render( + + + + + , + , + ); + + const textbox = screen.getByRole('textbox', { name }); + + const value = 'value'; + + await user.type(textbox, value); + + act(() => { + jest.advanceTimersByTime(defaultDebounceTime); + }); + + await waitFor(() => { + expect(textbox).toHaveValue(value); + }); + + expect(query).toHaveBeenLastCalledWith( + expect.objectContaining({ term: value }), + ); + jest.useRealTimers(); + }); + + it('Clear button clears term state', async () => { + render( + + + + + , + ); + + await waitFor(() => { + expect(screen.getByRole('textbox', { name })).toHaveValue(term); + }); + + await userEvent.click(screen.getByRole('button', { name: 'Clear' })); + + await waitFor(() => { + expect(screen.getByRole('textbox', { name })).toHaveValue(''); + }); + + expect(query).toHaveBeenLastCalledWith( + expect.objectContaining({ term: '' }), + ); + }); + + it('Should not show clear button', async () => { + render( + + + + + , + ); + + await waitFor(() => { + expect( + screen.queryByRole('button', { name: 'Clear' }), + ).not.toBeInTheDocument(); + }); + }); + + it('Adheres to provided debounceTime', async () => { + const user = userEvent.setup({ delay: null }); + jest.useFakeTimers(); + + const debounceTime = 600; + + render( + + + + + , + , + ); + + await waitFor(() => { + expect(screen.getByRole('textbox', { name })).toBeInTheDocument(); + }); + + const textbox = screen.getByRole('textbox', { name }); + + const value = 'value'; + + await user.type(textbox, value); + + expect(query).not.toHaveBeenLastCalledWith( + expect.objectContaining({ term: value }), + ); + + act(() => { + jest.advanceTimersByTime(debounceTime); + }); + expect(textbox).toHaveValue(value); + + expect(query).toHaveBeenLastCalledWith( + expect.objectContaining({ term: value }), + ); + jest.useRealTimers(); + }); + + it('does not capture analytics event if not enabled in app', async () => { + const user = userEvent.setup({ delay: null }); + jest.useFakeTimers(); + + const debounceTime = 600; + + render( + + + + + , + , + ); + + await waitFor(() => { + expect(screen.getByRole('textbox', { name })).toBeInTheDocument(); + }); + + const textbox = screen.getByRole('textbox', { name }); + + const value = 'value'; + + await user.type(textbox, value); + + act(() => { + jest.advanceTimersByTime(debounceTime); + }); + + await waitFor(() => expect(textbox).toHaveValue(value)); + + expect(analyticsApiSpy.getEvents()).toHaveLength(0); + jest.useRealTimers(); + }); + + it('captures analytics events if enabled in app', async () => { + const user = userEvent.setup({ delay: null }); + jest.useFakeTimers(); + + const debounceTime = 600; + + apiRegistry = TestApiRegistry.from( + [analyticsApiRef, analyticsApiSpy], + [ + configApiRef, + new ConfigReader({ + app: { + title: 'Mock title', + analytics: { + ga: { + trackingId: 'xyz123', + }, + }, + }, + }), + ], + [searchApiRef, { query }], + ); + + render( + + + + + , + ); + + await waitFor(() => { + expect(screen.getByRole('textbox', { name })).toBeInTheDocument(); + }); + + const textbox = screen.getByRole('textbox', { name }); + + const value = 'value'; + + await user.type(textbox, value); + + expect(analyticsApiSpy.getEvents()).toHaveLength(0); + + act(() => { + jest.advanceTimersByTime(debounceTime); + }); + + await waitFor(() => expect(textbox).toHaveValue(value)); + + expect(analyticsApiSpy.getEvents()).toHaveLength(1); + expect(analyticsApiSpy.getEvents()[0]).toEqual({ + action: 'search', + context: { + extension: 'SearchBar', + pluginId: 'search', + routeRef: 'unknown', + searchTypes: 'software-catalog,techdocs', + }, + subject: 'value', + }); + + await user.clear(textbox); + + // make sure new term is captured + await user.type(textbox, 'new value'); + + act(() => { + jest.advanceTimersByTime(debounceTime); + }); + + await waitFor(() => expect(textbox).toHaveValue('new value')); + + expect(analyticsApiSpy.getEvents()).toHaveLength(2); + expect(analyticsApiSpy.getEvents()[1]).toEqual({ + action: 'search', + context: { + extension: 'SearchBar', + pluginId: 'search', + routeRef: 'unknown', + searchTypes: 'software-catalog,techdocs', + }, + subject: 'new value', + }); + jest.useRealTimers(); + }); +}); diff --git a/plugins/search-react/src/components/SearchBar/SearchBar.tsx b/plugins/search-react/src/components/SearchBar/SearchBar.tsx new file mode 100644 index 0000000000..d3d25455ce --- /dev/null +++ b/plugins/search-react/src/components/SearchBar/SearchBar.tsx @@ -0,0 +1,189 @@ +/* + * 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, + KeyboardEvent, + useState, + useEffect, + useCallback, +} from 'react'; +import useDebounce from 'react-use/lib/useDebounce'; +import { + InputBase, + InputBaseProps, + InputAdornment, + IconButton, +} from '@material-ui/core'; +import SearchIcon from '@material-ui/icons/Search'; +import ClearButton from '@material-ui/icons/Clear'; + +import { + AnalyticsContext, + configApiRef, + useApi, +} from '@backstage/core-plugin-api'; + +import { + SearchContextProvider, + useSearch, + useSearchContextCheck, +} from '../../context'; +import { TrackSearch } from '../SearchTracker'; + +/** + * Props for {@link SearchBarBase}. + * + * @public + */ +export type SearchBarBaseProps = Omit & { + debounceTime?: number; + clearButton?: boolean; + onClear?: () => void; + onSubmit?: () => void; + onChange: (value: string) => void; +}; + +/** + * All search boxes exported by the search plugin are based on the , + * and this one is based on the component from Material UI. + * Recommended if you don't use Search Provider or Search Context. + * + * @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(); + + useEffect(() => { + setValue(prevValue => + prevValue !== defaultValue ? (defaultValue as string) : prevValue, + ); + }, [defaultValue]); + + 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(''); + }, [onChange]); + + const placeholder = `Search in ${ + configApi.getOptionalString('app.title') || 'Backstage' + }`; + + const startAdornment = ( + + + + + + ); + + const endAdornment = ( + + + + + + ); + + const searchBar = ( + + + + ); + + return hasSearchContext ? ( + searchBar + ) : ( + {searchBar} + ); +}; + +/** + * Props for {@link SearchBar}. + * + * @public + */ +export type SearchBarProps = Partial; + +/** + * Recommended search bar when you use the Search Provider or Search Context. + * + * @public + */ +export const SearchBar = ({ onChange, ...props }: SearchBarProps) => { + const { term, setTerm } = useSearch(); + + 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 new file mode 100644 index 0000000000..075a0c7dc2 --- /dev/null +++ b/plugins/search-react/src/components/SearchBar/index.tsx @@ -0,0 +1,18 @@ +/* + * 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 { SearchBar, SearchBarBase } from './SearchBar'; +export type { SearchBarProps, SearchBarBaseProps } from './SearchBar'; diff --git a/plugins/search-react/src/components/SearchFilter/SearchFilter.Autocomplete.test.tsx b/plugins/search-react/src/components/SearchFilter/SearchFilter.Autocomplete.test.tsx new file mode 100644 index 0000000000..14c8967b57 --- /dev/null +++ b/plugins/search-react/src/components/SearchFilter/SearchFilter.Autocomplete.test.tsx @@ -0,0 +1,361 @@ +/* + * Copyright 2021 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 { TestApiProvider } from '@backstage/test-utils'; +import { screen, render, waitFor, within } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import React from 'react'; + +import { searchApiRef } from '../../api'; +import { SearchContextProvider, useSearch } from '../../context'; +import { SearchFilter } from './SearchFilter'; + +const SearchContextFilterSpy = ({ name }: { name: string }) => { + const { filters } = useSearch(); + const value = filters[name]; + return ( + + {Array.isArray(value) ? value.join(',') : value} + + ); +}; + +describe('SearchFilter.Autocomplete', () => { + const query = jest.fn().mockResolvedValue({}); + const emptySearchContext = { + term: '', + types: [], + filters: {}, + }; + + const name = 'field'; + const values = ['value1', 'value2']; + + it('renders as expected', async () => { + render( + + + + + , + ); + + const autocomplete = screen.getByRole('combobox'); + const input = within(autocomplete).getByRole('textbox'); + await userEvent.click(input); + + await waitFor(() => { + screen.getByRole('listbox'); + }); + + expect(screen.getByRole('option', { name: values[0] })).toBeInTheDocument(); + expect(screen.getByRole('option', { name: values[1] })).toBeInTheDocument(); + }); + + it('renders as expected with async values', async () => { + render( + + + values} /> + + , + ); + + const autocomplete = screen.getByRole('combobox'); + const input = within(autocomplete).getByRole('textbox'); + await userEvent.click(input); + + await waitFor(() => { + screen.getByRole('listbox'); + }); + + expect(screen.getByRole('option', { name: values[0] })).toBeInTheDocument(); + expect(screen.getByRole('option', { name: values[1] })).toBeInTheDocument(); + }); + + it('does not affect unrelated filter state', async () => { + render( + + + + + + + , + ); + + // The spy should show the initial value. + expect(screen.getByTestId('unrelated-filter-spy')).toHaveTextContent( + 'value', + ); + + // Select a value from the autocomplete filter. + const autocomplete = screen.getByRole('combobox'); + const input = within(autocomplete).getByRole('textbox'); + await userEvent.click(input); + await waitFor(() => { + screen.getByRole('listbox'); + }); + await userEvent.click(screen.getByRole('option', { name: values[1] })); + + // Wait for the autocomplete filter's value to change. + await waitFor(() => { + expect(screen.getByTestId(`${name}-filter-spy`)).toHaveTextContent( + values[1], + ); + }); + + // Unrelated filter spy should maintain the same value. + expect(screen.getByTestId('unrelated-filter-spy')).toHaveTextContent( + 'value', + ); + }); + + describe('single', () => { + it('renders as expected with defaultValue', async () => { + render( + + + + + + , + ); + + const autocomplete = screen.getByRole('combobox'); + const input = within(autocomplete).getByRole('textbox'); + + await waitFor(() => { + expect(input).toHaveValue(values[1]); + expect(screen.getByTestId(`${name}-filter-spy`)).toHaveTextContent( + values[1], + ); + }); + }); + + it('renders as expected with initial context', async () => { + render( + + + + + + , + ); + + const autocomplete = screen.getByRole('combobox'); + const input = within(autocomplete).getByRole('textbox'); + + await waitFor(() => { + expect(input).toHaveValue(values[0]); + expect(screen.getByTestId(`${name}-filter-spy`)).toHaveTextContent( + values[0], + ); + }); + }); + + it('sets filter state when selecting a value', async () => { + render( + + + + + + , + ); + + // Select the first option in the autocomplete. + const autocomplete = screen.getByRole('combobox'); + const input = within(autocomplete).getByRole('textbox'); + await userEvent.click(input); + await waitFor(() => { + screen.getByRole('listbox'); + }); + await userEvent.click(screen.getByRole('option', { name: values[0] })); + + // The value should be present in the context. + await waitFor(() => { + expect(screen.getByTestId(`${name}-filter-spy`)).toHaveTextContent( + values[0], + ); + }); + + // Click the "Clear" button to remove the value. + const clearButton = within(autocomplete).getByLabelText('Clear'); + await userEvent.click(clearButton); + + // That value should have been unset from the context. + await waitFor(() => { + expect(screen.getByTestId(`${name}-filter-spy`)).toHaveTextContent(''); + }); + }); + }); + + describe('multiple', () => { + it('renders as expected with defaultValue', async () => { + render( + + + + + + , + ); + + await waitFor(() => { + expect(screen.getByText(values[0])).toBeInTheDocument(); + expect(screen.getByText(values[1])).toBeInTheDocument(); + expect(screen.getByTestId(`${name}-filter-spy`)).toHaveTextContent( + values.join(','), + ); + }); + }); + + it('renders as expected with initial context', async () => { + render( + + + + + + , + ); + + await waitFor(() => { + expect(screen.getByText(values[0])).toBeInTheDocument(); + expect(screen.getByText(values[1])).toBeInTheDocument(); + expect(screen.getByTestId(`${name}-filter-spy`)).toHaveTextContent( + values.join(','), + ); + }); + }); + + it('respects tag limit configuration', async () => { + render( + + + + + , + ); + + const autocomplete = screen.getByRole('combobox'); + const input = within(autocomplete).getByRole('textbox'); + + // Select the second value. + await userEvent.click(input); + await waitFor(() => { + screen.getByRole('listbox'); + }); + await userEvent.click(screen.getByRole('option', { name: values[1] })); + await waitFor(() => { + expect( + screen.getByRole('button', { name: values[1] }), + ).toBeInTheDocument(); + }); + + // Select the first value. + await userEvent.click(input); + await waitFor(() => { + screen.getByRole('listbox'); + }); + await userEvent.click(screen.getByRole('option', { name: values[0] })); + await waitFor(() => { + expect( + screen.getByRole('button', { name: values[0] }), + ).toBeInTheDocument(); + }); + + // Blur the field and only one tag should be shown with a +1. + input.blur(); + expect( + screen.queryByRole('button', { name: values[0] }), + ).not.toBeInTheDocument(); + expect(screen.getByText('+1')).toBeInTheDocument(); + }); + + it('sets filter state when selecting a value', async () => { + render( + + + + + + , + ); + + const autocomplete = screen.getByRole('combobox'); + + // Select both values in the autocomplete. + const input = within(autocomplete).getByRole('textbox'); + await userEvent.click(input); + await waitFor(() => { + screen.getByRole('listbox'); + }); + await userEvent.click(screen.getByRole('option', { name: values[0] })); + await userEvent.click(input); + await waitFor(() => { + screen.getByRole('listbox'); + }); + await userEvent.click(screen.getByRole('option', { name: values[1] })); + + // Both options should be present in the context. + await waitFor(() => { + expect(screen.getByTestId(`${name}-filter-spy`)).toHaveTextContent( + values.join(','), + ); + }); + + // Click the "Clear" button to remove the value. + const clearButton = within(autocomplete).getByLabelText('Clear'); + await userEvent.click(clearButton); + + // There should be no content in the filter context. + await waitFor(() => { + expect(screen.getByTestId(`${name}-filter-spy`)).toHaveTextContent(''); + }); + }); + }); +}); diff --git a/plugins/search-react/src/components/SearchFilter/SearchFilter.Autocomplete.tsx b/plugins/search-react/src/components/SearchFilter/SearchFilter.Autocomplete.tsx new file mode 100644 index 0000000000..35d1f376e5 --- /dev/null +++ b/plugins/search-react/src/components/SearchFilter/SearchFilter.Autocomplete.tsx @@ -0,0 +1,120 @@ +/* + * 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, useState } from 'react'; +import { Chip, TextField } from '@material-ui/core'; +import { + Autocomplete, + AutocompleteGetTagProps, + AutocompleteRenderInputParams, +} from '@material-ui/lab'; + +import { useSearch } from '../../context'; +import { useAsyncFilterValues, useDefaultFilterValue } from './hooks'; +import { SearchFilterComponentProps } from './SearchFilter'; + +/** + * @public + */ +export type SearchAutocompleteFilterProps = SearchFilterComponentProps & { + filterSelectedOptions?: boolean; + limitTags?: number; + multiple?: boolean; +}; + +/** + * @public + */ +export const AutocompleteFilter = (props: SearchAutocompleteFilterProps) => { + const { + className, + defaultValue, + name, + values: givenValues, + valuesDebounceMs, + label, + filterSelectedOptions, + limitTags, + multiple, + } = props; + const [inputValue, setInputValue] = useState(''); + useDefaultFilterValue(name, defaultValue); + const asyncValues = + typeof givenValues === 'function' ? givenValues : undefined; + const defaultValues = + typeof givenValues === 'function' ? undefined : givenValues; + const { value: values, loading } = useAsyncFilterValues( + asyncValues, + inputValue, + defaultValues, + valuesDebounceMs, + ); + const { filters, setFilters } = useSearch(); + const filterValue = + (filters[name] as string | string[] | undefined) || (multiple ? [] : null); + + // Set new filter values on input change. + const handleChange = ( + _: ChangeEvent<{}>, + newValue: string | string[] | null, + ) => { + setFilters(prevState => { + const { [name]: filter, ...others } = prevState; + + if (newValue) { + return { ...others, [name]: newValue }; + } + return { ...others }; + }); + }; + + // Provide the input field. + const renderInput = (params: AutocompleteRenderInputParams) => ( + + ); + + // Render tags as primary-colored chips. + const renderTags = ( + tagValue: string[], + getTagProps: AutocompleteGetTagProps, + ) => + tagValue.map((option: string, index: number) => ( + + )); + + return ( + setInputValue(newValue)} + renderInput={renderInput} + renderTags={renderTags} + /> + ); +}; diff --git a/plugins/search/src/components/SearchFilter/SearchFilter.stories.tsx b/plugins/search-react/src/components/SearchFilter/SearchFilter.stories.tsx similarity index 95% rename from plugins/search/src/components/SearchFilter/SearchFilter.stories.tsx rename to plugins/search-react/src/components/SearchFilter/SearchFilter.stories.tsx index 7e8a7d585a..2c8a629f01 100644 --- a/plugins/search/src/components/SearchFilter/SearchFilter.stories.tsx +++ b/plugins/search-react/src/components/SearchFilter/SearchFilter.stories.tsx @@ -1,5 +1,5 @@ /* - * Copyright 2021 The Backstage Authors + * 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. @@ -14,14 +14,13 @@ * limitations under the License. */ -import { Grid, Paper } from '@material-ui/core'; import React, { ComponentType } from 'react'; -import { - searchApiRef, - MockSearchApi, - SearchContextProvider, -} from '@backstage/plugin-search-react'; +import { Grid, Paper } from '@material-ui/core'; + import { TestApiProvider } from '@backstage/test-utils'; + +import { searchApiRef, MockSearchApi } from '../../api'; +import { SearchContextProvider } from '../../context'; import { SearchFilter } from './SearchFilter'; export default { diff --git a/plugins/search-react/src/components/SearchFilter/SearchFilter.test.tsx b/plugins/search-react/src/components/SearchFilter/SearchFilter.test.tsx new file mode 100644 index 0000000000..89a1818e8d --- /dev/null +++ b/plugins/search-react/src/components/SearchFilter/SearchFilter.test.tsx @@ -0,0 +1,410 @@ +/* + * 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, render, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; + +import { useApi } from '@backstage/core-plugin-api'; + +import { SearchContextProvider } from '../../context'; +import { SearchFilter } from './SearchFilter'; + +jest.mock('@backstage/core-plugin-api', () => ({ + ...jest.requireActual('@backstage/core-plugin-api'), + useApi: jest.fn().mockReturnValue({}), +})); + +describe('SearchFilter', () => { + const initialState = { + term: '', + filters: {}, + types: [], + }; + + const label = 'Field'; + const name = 'field'; + const values = ['value1', 'value2']; + const filters = { unrelated: 'unrelated' }; + + const query = jest.fn().mockResolvedValue({}); + (useApi as jest.Mock).mockReturnValue({ query: query }); + + afterAll(() => { + jest.resetAllMocks(); + }); + + it('Check that element was rendered and received props', async () => { + const CustomFilter = (props: { name: string }) =>
{props.name}
; + + render(); + + expect(screen.getByRole('heading', { name })).toBeInTheDocument(); + }); + + describe('Checkbox', () => { + it('Renders field name and values when provided as props', async () => { + render( + + + , + ); + + await waitFor(() => { + expect(screen.getByText(label)).toBeInTheDocument(); + }); + + expect( + screen.getByRole('checkbox', { name: values[0] }), + ).toBeInTheDocument(); + expect( + screen.getByRole('checkbox', { name: values[1] }), + ).toBeInTheDocument(); + }); + + it('Renders correctly based on filter state', async () => { + render( + + + , + ); + + await waitFor(() => { + expect(screen.getByText(label)).toBeInTheDocument(); + }); + + expect( + screen.getByRole('checkbox', { name: values[0] }), + ).not.toBeChecked(); + expect(screen.getByRole('checkbox', { name: values[1] })).toBeChecked(); + }); + + it('Renders correctly based on defaultValue', async () => { + render( + + + , + ); + + await waitFor(() => { + expect(screen.getByText(label)).toBeInTheDocument(); + }); + + expect(screen.getByRole('checkbox', { name: values[0] })).toBeChecked(); + expect( + screen.getByRole('checkbox', { name: values[1] }), + ).not.toBeChecked(); + }); + + it('Checking / unchecking a value sets filter state', async () => { + render( + + + , + ); + + await waitFor(() => { + expect(screen.getByText(label)).toBeInTheDocument(); + }); + + const checkBox = screen.getByRole('checkbox', { name: values[0] }); + + // Check the box. + await userEvent.click(checkBox); + await waitFor(() => { + expect(query).toHaveBeenLastCalledWith( + expect.objectContaining({ filters: { field: [values[0]] } }), + ); + }); + + // Uncheck the box. + await userEvent.click(checkBox); + await waitFor(() => { + expect(query).toHaveBeenLastCalledWith( + expect.objectContaining({ filters: {} }), + ); + }); + }); + + it('Checking / unchecking a value maintains unrelated filter state', async () => { + render( + + + , + ); + + await waitFor(() => { + expect(screen.getByText(label)).toBeInTheDocument(); + }); + + const checkBox = screen.getByRole('checkbox', { name: values[0] }); + + // Check the box. + await userEvent.click(checkBox); + await waitFor(() => { + expect(query).toHaveBeenLastCalledWith( + expect.objectContaining({ + filters: { ...filters, field: [values[0]] }, + }), + ); + }); + + // Uncheck the box. + await userEvent.click(checkBox); + await waitFor(() => { + expect(query).toHaveBeenLastCalledWith( + expect.objectContaining({ filters }), + ); + }); + }); + }); + + describe('Select', () => { + it('Renders field name and values when provided as props', async () => { + render( + + + , + ); + + await waitFor(() => { + expect(screen.getByText(label)).toBeInTheDocument(); + }); + + await userEvent.click(screen.getByRole('button')); + + await waitFor(() => { + expect(screen.getByRole('listbox')).toBeInTheDocument(); + }); + + expect( + screen.getByRole('option', { name: values[0] }), + ).toBeInTheDocument(); + expect( + screen.getByRole('option', { name: values[1] }), + ).toBeInTheDocument(); + }); + + it('Renders values when provided asynchronously', async () => { + render( + + values} + /> + , + ); + + await waitFor(() => { + expect(screen.getByRole('button')).toBeInTheDocument(); + expect( + screen.getByRole('button').getAttribute('aria-disabled'), + ).not.toBe('true'); + }); + + await userEvent.click(screen.getByRole('button')); + + await waitFor(() => { + expect(screen.getByRole('listbox')).toBeInTheDocument(); + }); + + expect( + screen.getByRole('option', { name: values[0] }), + ).toBeInTheDocument(); + expect( + screen.getByRole('option', { name: values[1] }), + ).toBeInTheDocument(); + }); + + it('Renders correctly based on filter state', async () => { + render( + + + , + ); + + await waitFor(() => { + expect(screen.getByText(label)).toBeInTheDocument(); + }); + + await userEvent.click(screen.getByRole('button')); + + await waitFor(() => { + expect(screen.getByRole('listbox')).toBeInTheDocument(); + }); + + expect(screen.getByRole('option', { name: values[0] })).toHaveAttribute( + 'aria-selected', + 'true', + ); + expect( + screen.getByRole('option', { name: values[1] }), + ).not.toHaveAttribute('aria-selected'); + expect(screen.getByRole('option', { name: 'All' })).not.toHaveAttribute( + 'aria-selected', + ); + }); + + it('Renders correctly based on defaultValue', async () => { + render( + + + , + ); + + await waitFor(() => { + expect(screen.getByText(label)).toBeInTheDocument(); + }); + + await userEvent.click(screen.getByRole('button')); + + await waitFor(() => { + expect(screen.getByRole('listbox')).toBeInTheDocument(); + }); + + expect(screen.getByRole('option', { name: values[0] })).toHaveAttribute( + 'aria-selected', + 'true', + ); + expect( + screen.getByRole('option', { name: values[1] }), + ).not.toHaveAttribute('aria-selected'); + expect(screen.getByRole('option', { name: 'All' })).not.toHaveAttribute( + 'aria-selected', + ); + }); + + it('Selecting a value sets filter state', async () => { + render( + + + , + ); + + await waitFor(() => { + expect(screen.getByText(label)).toBeInTheDocument(); + }); + + const button = screen.getByRole('button'); + + await userEvent.click(button); + + await waitFor(() => { + expect(screen.getByRole('listbox')).toBeInTheDocument(); + }); + + await userEvent.click(screen.getByRole('option', { name: values[0] })); + + await waitFor(() => { + expect(query).toHaveBeenLastCalledWith( + expect.objectContaining({ + filters: { [name]: values[0] }, + }), + ); + }); + + await userEvent.click(button); + + await waitFor(() => { + expect(screen.getByRole('listbox')).toBeInTheDocument(); + }); + + await userEvent.click(screen.getByRole('option', { name: 'All' })); + + await waitFor(() => { + expect(query).toHaveBeenLastCalledWith( + expect.objectContaining({ + filters: {}, + }), + ); + }); + }); + + it('Selecting a value maintains unrelated filter state', async () => { + render( + + + , + ); + + await waitFor(() => { + expect(screen.getByText(label)).toBeInTheDocument(); + }); + + const button = screen.getByRole('button'); + + await userEvent.click(button); + + await waitFor(() => { + expect(screen.getByRole('listbox')).toBeInTheDocument(); + }); + + await userEvent.click(screen.getByRole('option', { name: values[0] })); + + await waitFor(() => { + expect(query).toHaveBeenLastCalledWith( + expect.objectContaining({ + filters: { ...filters, [name]: values[0] }, + }), + ); + }); + + await userEvent.click(button); + + await waitFor(() => { + expect(screen.getByRole('listbox')).toBeInTheDocument(); + }); + + await userEvent.click(screen.getByRole('option', { name: 'All' })); + + await waitFor(() => { + expect(query).toHaveBeenLastCalledWith( + expect.objectContaining({ filters }), + ); + }); + }); + }); +}); diff --git a/plugins/search-react/src/components/SearchFilter/SearchFilter.tsx b/plugins/search-react/src/components/SearchFilter/SearchFilter.tsx new file mode 100644 index 0000000000..2a6d7baafa --- /dev/null +++ b/plugins/search-react/src/components/SearchFilter/SearchFilter.tsx @@ -0,0 +1,237 @@ +/* + * 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, { ReactElement, ChangeEvent } from 'react'; +import { + makeStyles, + FormControl, + FormControlLabel, + InputLabel, + Checkbox, + Select, + MenuItem, + FormLabel, +} from '@material-ui/core'; + +import { useSearch } from '../../context'; +import { + AutocompleteFilter, + SearchAutocompleteFilterProps, +} from './SearchFilter.Autocomplete'; +import { useAsyncFilterValues, useDefaultFilterValue } from './hooks'; + +const useStyles = makeStyles({ + label: { + textTransform: 'capitalize', + }, +}); + +/** + * @public + */ +export type SearchFilterComponentProps = { + className?: string; + name: string; + label?: string; + /** + * Either an array of values directly, or an async function to return a list + * of values to be used in the filter. In the autocomplete filter, the last + * input value is provided as an input to allow values to be filtered. This + * function is debounced and values cached. + */ + values?: string[] | ((partial: string) => Promise); + defaultValue?: string[] | string | null; + /** + * Debounce time in milliseconds, used when values is an async callback. + * Defaults to 250ms. + */ + valuesDebounceMs?: number; +}; + +/** + * @public + */ +export type SearchFilterWrapperProps = SearchFilterComponentProps & { + component: (props: SearchFilterComponentProps) => ReactElement; + debug?: boolean; +}; + +/** + * @public + */ +export const CheckboxFilter = (props: SearchFilterComponentProps) => { + const { + className, + defaultValue, + label, + name, + values: givenValues = [], + valuesDebounceMs, + } = props; + const classes = useStyles(); + const { filters, setFilters } = useSearch(); + useDefaultFilterValue(name, defaultValue); + const asyncValues = + typeof givenValues === 'function' ? givenValues : undefined; + const defaultValues = + typeof givenValues === 'function' ? undefined : givenValues; + const { value: values = [], loading } = useAsyncFilterValues( + asyncValues, + '', + defaultValues, + valuesDebounceMs, + ); + + const handleChange = (e: ChangeEvent) => { + const { + target: { value, checked }, + } = e; + + setFilters(prevFilters => { + const { [name]: filter, ...others } = prevFilters; + const rest = ((filter as string[]) || []).filter(i => i !== value); + const items = checked ? [...rest, value] : rest; + return items.length ? { ...others, [name]: items } : others; + }); + }; + + return ( + + {label ? {label} : null} + {values.map((value: string) => ( + + } + label={value} + /> + ))} + + ); +}; + +/** + * @public + */ +export const SelectFilter = (props: SearchFilterComponentProps) => { + const { + className, + defaultValue, + label, + name, + values: givenValues, + valuesDebounceMs, + } = props; + const classes = useStyles(); + useDefaultFilterValue(name, defaultValue); + const asyncValues = + typeof givenValues === 'function' ? givenValues : undefined; + const defaultValues = + typeof givenValues === 'function' ? undefined : givenValues; + const { value: values = [], loading } = useAsyncFilterValues( + asyncValues, + '', + defaultValues, + valuesDebounceMs, + ); + const { filters, setFilters } = useSearch(); + + const handleChange = (e: ChangeEvent<{ value: unknown }>) => { + const { + target: { value }, + } = e; + + setFilters(prevFilters => { + const { [name]: filter, ...others } = prevFilters; + return value ? { ...others, [name]: value as string } : others; + }); + }; + + return ( + + {label ? ( + + {label} + + ) : null} + + + ); +}; + +/** + * @public + */ +const SearchFilter = ({ + component: Element, + ...props +}: SearchFilterWrapperProps) => ; + +SearchFilter.Checkbox = ( + props: Omit & + SearchFilterComponentProps, +) => ; + +SearchFilter.Select = ( + props: Omit & + SearchFilterComponentProps, +) => ; + +/** + * A control surface for a given filter field name, rendered as an autocomplete + * textfield. A hard-coded list of values may be provided, or an async function + * which returns values may be provided instead. + * + * @public + */ +SearchFilter.Autocomplete = (props: SearchAutocompleteFilterProps) => ( + +); + +export { SearchFilter }; diff --git a/plugins/search/src/components/SearchFilter/hooks.test.tsx b/plugins/search-react/src/components/SearchFilter/hooks.test.tsx similarity index 98% rename from plugins/search/src/components/SearchFilter/hooks.test.tsx rename to plugins/search-react/src/components/SearchFilter/hooks.test.tsx index 14346a29eb..80c4a3a3a2 100644 --- a/plugins/search/src/components/SearchFilter/hooks.test.tsx +++ b/plugins/search-react/src/components/SearchFilter/hooks.test.tsx @@ -17,11 +17,9 @@ import React from 'react'; import { ApiProvider } from '@backstage/core-app-api'; import { TestApiRegistry } from '@backstage/test-utils'; import { renderHook } from '@testing-library/react-hooks'; -import { - SearchContextProvider, - useSearch, - searchApiRef, -} from '@backstage/plugin-search-react'; + +import { searchApiRef } from '../../api'; +import { SearchContextProvider, useSearch } from '../../context'; import { useDefaultFilterValue, useAsyncFilterValues } from './hooks'; jest.useFakeTimers(); diff --git a/plugins/search/src/components/SearchFilter/hooks.ts b/plugins/search-react/src/components/SearchFilter/hooks.ts similarity index 96% rename from plugins/search/src/components/SearchFilter/hooks.ts rename to plugins/search-react/src/components/SearchFilter/hooks.ts index da30466522..5771cc5288 100644 --- a/plugins/search/src/components/SearchFilter/hooks.ts +++ b/plugins/search-react/src/components/SearchFilter/hooks.ts @@ -1,5 +1,5 @@ /* - * Copyright 2021 The Backstage Authors + * 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. @@ -17,11 +17,14 @@ import { useEffect, useRef } from 'react'; import useAsyncFn from 'react-use/lib/useAsyncFn'; import useDebounce from 'react-use/lib/useDebounce'; -import { useSearch } from '@backstage/plugin-search-react'; + +import { useSearch } from '../../context'; /** * Utility hook for either asynchronously loading filter values from a given * function or synchronously providing a given list of default values. + * + * @public */ export const useAsyncFilterValues = ( fn: ((partial: string) => Promise) | undefined, @@ -75,6 +78,8 @@ export const useAsyncFilterValues = ( /** * Utility hook for applying a given default value to the search context. + * + * @public */ export const useDefaultFilterValue = ( name: string, diff --git a/plugins/search-react/src/components/SearchFilter/index.ts b/plugins/search-react/src/components/SearchFilter/index.ts new file mode 100644 index 0000000000..9f92c7c8e2 --- /dev/null +++ b/plugins/search-react/src/components/SearchFilter/index.ts @@ -0,0 +1,23 @@ +/* + * 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 { CheckboxFilter, SearchFilter, SelectFilter } from './SearchFilter'; +export { AutocompleteFilter } from './SearchFilter.Autocomplete'; +export type { + SearchFilterComponentProps, + SearchFilterWrapperProps, +} from './SearchFilter'; +export type { SearchAutocompleteFilterProps } from './SearchFilter.Autocomplete'; diff --git a/plugins/search/src/components/SearchResult/SearchResult.stories.tsx b/plugins/search-react/src/components/SearchResult/SearchResult.stories.tsx similarity index 94% rename from plugins/search/src/components/SearchResult/SearchResult.stories.tsx rename to plugins/search-react/src/components/SearchResult/SearchResult.stories.tsx index 207f1ae1d7..32f9a19a07 100644 --- a/plugins/search/src/components/SearchResult/SearchResult.stories.tsx +++ b/plugins/search-react/src/components/SearchResult/SearchResult.stories.tsx @@ -1,5 +1,5 @@ /* - * Copyright 2021 The Backstage Authors + * 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. @@ -14,20 +14,18 @@ * limitations under the License. */ -import { Link } from '@backstage/core-components'; -import { List, ListItem } from '@material-ui/core'; import React, { ComponentType } from 'react'; +import { List, ListItem } from '@material-ui/core'; import { MemoryRouter } from 'react-router'; -import { DefaultResultListItem } from '../DefaultResultListItem'; -import { - searchApiRef, - MockSearchApi, - SearchContextProvider, -} from '@backstage/plugin-search-react'; -import { SearchResult } from './SearchResult'; +import { Link } from '@backstage/core-components'; import { TestApiProvider } from '@backstage/test-utils'; +import { searchApiRef, MockSearchApi } from '../../api'; +import { SearchContextProvider } from '../../context'; +import { DefaultResultListItem } from '../DefaultResultListItem'; +import { SearchResult } from './SearchResult'; + const mockResults = { results: [ { diff --git a/plugins/search/src/components/SearchResult/SearchResult.test.tsx b/plugins/search-react/src/components/SearchResult/SearchResult.test.tsx similarity index 94% rename from plugins/search/src/components/SearchResult/SearchResult.test.tsx rename to plugins/search-react/src/components/SearchResult/SearchResult.test.tsx index d93eacaef0..988d59d7cf 100644 --- a/plugins/search/src/components/SearchResult/SearchResult.test.tsx +++ b/plugins/search-react/src/components/SearchResult/SearchResult.test.tsx @@ -1,5 +1,5 @@ /* - * Copyright 2021 The Backstage Authors + * 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. @@ -14,14 +14,16 @@ * limitations under the License. */ -import { renderInTestApp } from '@backstage/test-utils'; -import { waitFor } from '@testing-library/react'; import React from 'react'; -import { useSearch } from '@backstage/plugin-search-react'; +import { waitFor } from '@testing-library/react'; + +import { renderInTestApp } from '@backstage/test-utils'; + +import { useSearch } from '../../context'; import { SearchResult } from './SearchResult'; -jest.mock('@backstage/plugin-search-react', () => ({ - ...jest.requireActual('@backstage/plugin-search-react'), +jest.mock('../../context', () => ({ + ...jest.requireActual('../../context'), useSearch: jest.fn().mockReturnValue({ result: {}, }), diff --git a/plugins/search/src/components/SearchResult/SearchResult.tsx b/plugins/search-react/src/components/SearchResult/SearchResult.tsx similarity index 63% rename from plugins/search/src/components/SearchResult/SearchResult.tsx rename to plugins/search-react/src/components/SearchResult/SearchResult.tsx index a62dddb924..46a2dde305 100644 --- a/plugins/search/src/components/SearchResult/SearchResult.tsx +++ b/plugins/search-react/src/components/SearchResult/SearchResult.tsx @@ -1,5 +1,5 @@ /* - * Copyright 2021 The Backstage Authors + * 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. @@ -14,20 +14,33 @@ * limitations under the License. */ +import React from 'react'; + import { EmptyState, Progress, ResponseErrorPanel, } from '@backstage/core-components'; +import { AnalyticsContext } from '@backstage/core-plugin-api'; import { SearchResult } from '@backstage/plugin-search-common'; -import React from 'react'; -import { useSearch } from '@backstage/plugin-search-react'; -type Props = { +import { useSearch } from '../../context'; + +/** + * Props for {@link SearchResultComponent} + * + * @public + */ +export type SearchResultProps = { children: (results: { results: SearchResult[] }) => JSX.Element; }; -export const SearchResultComponent = ({ children }: Props) => { +/** + * A component returning the search result. + * + * @public + */ +export const SearchResultComponent = ({ children }: SearchResultProps) => { const { result: { loading, error, value }, } = useSearch(); @@ -51,4 +64,20 @@ export const SearchResultComponent = ({ children }: Props) => { return <>{children({ results: value.results })}; }; -export { SearchResultComponent as SearchResult }; +/** + * @public + */ +const HigherOrderSearchResult = (props: SearchResultProps) => { + return ( + + + + ); +}; + +export { HigherOrderSearchResult as SearchResult }; diff --git a/plugins/search-react/src/components/SearchResult/index.tsx b/plugins/search-react/src/components/SearchResult/index.tsx new file mode 100644 index 0000000000..503ac47bbd --- /dev/null +++ b/plugins/search-react/src/components/SearchResult/index.tsx @@ -0,0 +1,18 @@ +/* + * 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 { SearchResult, SearchResultComponent } from './SearchResult'; +export type { SearchResultProps } from './SearchResult'; diff --git a/plugins/search-react/src/components/SearchResultPager/SearchResultPager.test.tsx b/plugins/search-react/src/components/SearchResultPager/SearchResultPager.test.tsx new file mode 100644 index 0000000000..24d9a76e51 --- /dev/null +++ b/plugins/search-react/src/components/SearchResultPager/SearchResultPager.test.tsx @@ -0,0 +1,58 @@ +/* + * 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 { renderInTestApp } from '@backstage/test-utils'; +import { waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; + +import { useSearch } from '../../context'; +import { SearchResultPager } from './SearchResultPager'; + +jest.mock('../../context', () => ({ + ...jest.requireActual('../../context'), + useSearch: jest.fn().mockReturnValue({ + result: {}, + }), +})); + +describe('SearchResultPager', () => { + it('renders pager buttons', async () => { + const fetchNextPage = jest.fn(); + const fetchPreviousPage = jest.fn(); + (useSearch as jest.Mock).mockReturnValueOnce({ + result: { loading: false, value: [] }, + fetchNextPage, + fetchPreviousPage, + }); + + const { getByLabelText } = await renderInTestApp(); + + await waitFor(() => { + expect(getByLabelText('previous page')).toBeInTheDocument(); + }); + await userEvent.click(getByLabelText('previous page')); + expect(fetchPreviousPage).toBeCalled(); + + await waitFor(() => { + expect(getByLabelText('next page')).toBeInTheDocument(); + }); + await userEvent.click(getByLabelText('next page')); + + expect(fetchNextPage).toBeCalled(); + }); +}); diff --git a/plugins/search-react/src/components/SearchResultPager/SearchResultPager.tsx b/plugins/search-react/src/components/SearchResultPager/SearchResultPager.tsx new file mode 100644 index 0000000000..68f8223ca0 --- /dev/null +++ b/plugins/search-react/src/components/SearchResultPager/SearchResultPager.tsx @@ -0,0 +1,66 @@ +/* + * 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 { Button, makeStyles } from '@material-ui/core'; +import ArrowBackIosIcon from '@material-ui/icons/ArrowBackIos'; +import ArrowForwardIosIcon from '@material-ui/icons/ArrowForwardIos'; + +import { useSearch } from '../../context'; + +const useStyles = makeStyles(theme => ({ + root: { + display: 'flex', + justifyContent: 'space-between', + gap: theme.spacing(2), + margin: theme.spacing(2, 0), + }, +})); + +/** + * @public + */ +export const SearchResultPager = () => { + const { fetchNextPage, fetchPreviousPage } = useSearch(); + const classes = useStyles(); + + if (!fetchNextPage && !fetchPreviousPage) { + return <>; + } + + return ( + + ); +}; diff --git a/plugins/search/src/components/SearchResult/index.tsx b/plugins/search-react/src/components/SearchResultPager/index.ts similarity index 85% rename from plugins/search/src/components/SearchResult/index.tsx rename to plugins/search-react/src/components/SearchResultPager/index.ts index e7d088f286..5ff203b15d 100644 --- a/plugins/search/src/components/SearchResult/index.tsx +++ b/plugins/search-react/src/components/SearchResultPager/index.ts @@ -1,5 +1,5 @@ /* - * Copyright 2021 The Backstage Authors + * 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. @@ -14,4 +14,4 @@ * limitations under the License. */ -export { SearchResult } from './SearchResult'; +export { SearchResultPager } from './SearchResultPager'; diff --git a/plugins/search/src/components/SearchTracker/SearchTracker.tsx b/plugins/search-react/src/components/SearchTracker/SearchTracker.tsx similarity index 91% rename from plugins/search/src/components/SearchTracker/SearchTracker.tsx rename to plugins/search-react/src/components/SearchTracker/SearchTracker.tsx index 35cfa1ec38..f900e45d45 100644 --- a/plugins/search/src/components/SearchTracker/SearchTracker.tsx +++ b/plugins/search-react/src/components/SearchTracker/SearchTracker.tsx @@ -1,5 +1,5 @@ /* - * Copyright 2021 The Backstage Authors + * 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. @@ -16,7 +16,7 @@ import React, { useEffect } from 'react'; import { useAnalytics } from '@backstage/core-plugin-api'; -import { useSearch } from '@backstage/plugin-search-react'; +import { useSearch } from '../../context'; /** * Capture search event on term change. diff --git a/plugins/search/src/components/SearchTracker/index.ts b/plugins/search-react/src/components/SearchTracker/index.ts similarity index 93% rename from plugins/search/src/components/SearchTracker/index.ts rename to plugins/search-react/src/components/SearchTracker/index.ts index 5e6a75aa25..9932f2eaec 100644 --- a/plugins/search/src/components/SearchTracker/index.ts +++ b/plugins/search-react/src/components/SearchTracker/index.ts @@ -1,5 +1,5 @@ /* - * Copyright 2021 The Backstage Authors + * 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. diff --git a/plugins/search-react/src/components/index.ts b/plugins/search-react/src/components/index.ts index 679a254827..263ca4ad59 100644 --- a/plugins/search-react/src/components/index.ts +++ b/plugins/search-react/src/components/index.ts @@ -15,3 +15,8 @@ */ export * from './HighlightedSearchResultText'; +export * from './SearchFilter'; +export * from './SearchResult'; +export * from './SearchResultPager'; +export * from './SearchBar'; +export * from './DefaultResultListItem'; diff --git a/plugins/search/README.md b/plugins/search/README.md index f4a32c9597..3803b71e43 100644 --- a/plugins/search/README.md +++ b/plugins/search/README.md @@ -13,14 +13,17 @@ Run `yarn dev` in the root directory, and then navigate to [/search](http://loca This search plugin is primarily responsible for the following: - Providing a `` routable extension. -- Exposing various search-related components (like ``, - ``, etc), which can be composed by a Backstage App or by +- Exposing various search-related components (like ``, + ``, etc), which can be composed by a Backstage App or by other Backstage Plugins to power search experiences of all kinds. -- Exposing a ``, which manages search state and API - communication with the Backstage backend. -Don't forget, a lot of functionality is available in backend plugins: +Don't forget, a lot of functionality is available in web libraries and backend plugins: +- `@backstage/plugin-search-react`, which is responsible for: + - Exposing a ``, which manages search state and API + communication with the Backstage backend. + - Exposing the `SearchApi` and its corresponding ref. + - Exposing reusable components, such as `` and ``, etc. - `@backstage/plugin-search-backend-node`, which is responsible for the search index management - `@backstage/plugin-search-backend`, which is responsible for query processing diff --git a/plugins/search/api-report.md b/plugins/search/api-report.md index 21b9df5dda..800fa7e7fc 100644 --- a/plugins/search/api-report.md +++ b/plugins/search/api-report.md @@ -6,36 +6,29 @@ /// import { BackstagePlugin } from '@backstage/core-plugin-api'; +import { DefaultResultListItemProps } from '@backstage/plugin-search-react'; import { IconComponent } from '@backstage/core-plugin-api'; import { InputBaseProps } from '@material-ui/core'; import { ReactElement } from 'react'; import { ReactNode } from 'react'; -import { ResultHighlight } from '@backstage/plugin-search-common'; import { RouteRef } from '@backstage/core-plugin-api'; -import { SearchDocument } from '@backstage/plugin-search-common'; -import { SearchResult as SearchResult_2 } from '@backstage/plugin-search-common'; +import { SearchAutocompleteFilterProps as SearchAutocompleteFilterProps_2 } from '@backstage/plugin-search-react'; +import { SearchBarBaseProps as SearchBarBaseProps_2 } from '@backstage/plugin-search-react'; +import { SearchFilterComponentProps as SearchFilterComponentProps_2 } from '@backstage/plugin-search-react'; +import { SearchResultProps } from '@backstage/plugin-search-react'; -// Warning: (ae-missing-release-tag) "DefaultResultListItem" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) -// -// @public (undocumented) -export const DefaultResultListItem: ({ - result, - highlight, - icon, - secondaryAction, - lineClamp, -}: { - icon?: ReactNode; - secondaryAction?: ReactNode; - result: SearchDocument; - highlight?: ResultHighlight | undefined; - lineClamp?: number | undefined; -}) => JSX.Element; +// @public @deprecated (undocumented) +export const DefaultResultListItem: ( + props: DefaultResultListItemProps, +) => JSX.Element; -// Warning: (ae-forgotten-export) The symbol "FiltersProps" needs to be exported by the entry point index.d.ts -// Warning: (ae-missing-release-tag) "Filters" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) -// -// @public (undocumented) +// @public @deprecated (undocumented) +export type FilterOptions = { + kind: Array; + lifecycle: Array; +}; + +// @public @deprecated (undocumented) export const Filters: ({ filters, filterOptions, @@ -44,51 +37,57 @@ export const Filters: ({ updateChecked, }: FiltersProps) => JSX.Element; -// Warning: (ae-forgotten-export) The symbol "FiltersButtonProps" needs to be exported by the entry point index.d.ts -// Warning: (ae-missing-release-tag) "FiltersButton" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) -// -// @public (undocumented) +// @public @deprecated (undocumented) export const FiltersButton: ({ numberOfSelectedFilters, handleToggleFilters, }: FiltersButtonProps) => JSX.Element; -// Warning: (ae-missing-release-tag) "FiltersState" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) -// -// @public (undocumented) +// @public @deprecated +export type FiltersButtonProps = { + numberOfSelectedFilters: number; + handleToggleFilters: () => void; +}; + +// @public @deprecated +export type FiltersProps = { + filters: FiltersState; + filterOptions: FilterOptions; + resetFilters: () => void; + updateSelected: (filter: string) => void; + updateChecked: (filter: string) => void; +}; + +// @public @deprecated (undocumented) export type FiltersState = { selected: string; checked: Array; }; -// Warning: (ae-missing-release-tag) "HomePageSearchBar" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) -// // @public (undocumented) export const HomePageSearchBar: ({ ...props -}: Partial>) => JSX.Element; +}: Partial>) => JSX.Element; // @public export type HomePageSearchBarProps = Partial< - Omit + Omit >; -// Warning: (ae-missing-release-tag) "SearchPage" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) -// // @public (undocumented) export const Router: () => JSX.Element; -// @public (undocumented) -export type SearchAutocompleteFilterProps = SearchFilterComponentProps & { +// @public @deprecated (undocumented) +export type SearchAutocompleteFilterProps = SearchFilterComponentProps_2 & { filterSelectedOptions?: boolean; limitTags?: number; multiple?: boolean; }; -// @public +// @public @deprecated export const SearchBar: ({ onChange, ...props }: SearchBarProps) => JSX.Element; -// @public +// @public @deprecated export const SearchBarBase: ({ onChange, onKeyDown, @@ -100,9 +99,9 @@ export const SearchBarBase: ({ inputProps: defaultInputProps, endAdornment: defaultEndAdornment, ...props -}: SearchBarBaseProps) => JSX.Element; +}: SearchBarBaseProps_2) => JSX.Element; -// @public +// @public @deprecated export type SearchBarBaseProps = Omit & { debounceTime?: number; clearButton?: boolean; @@ -111,20 +110,10 @@ export type SearchBarBaseProps = Omit & { onChange: (value: string) => void; }; -// Warning: (ae-missing-release-tag) "SearchBarNext" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) -// -// @public @deprecated (undocumented) -export const SearchBarNext: ({ - onChange, - ...props -}: Partial) => JSX.Element; - -// @public +// @public @deprecated export type SearchBarProps = Partial; -// Warning: (ae-missing-release-tag) "SearchFilter" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) -// -// @public (undocumented) +// @public @deprecated (undocumented) export const SearchFilter: { ({ component: Element, ...props }: SearchFilterWrapperProps): JSX.Element; Checkbox( @@ -135,10 +124,10 @@ export const SearchFilter: { props: Omit & SearchFilterComponentProps, ): JSX.Element; - Autocomplete(props: SearchAutocompleteFilterProps): JSX.Element; + Autocomplete(props: SearchAutocompleteFilterProps_2): JSX.Element; }; -// @public (undocumented) +// @public @deprecated (undocumented) export type SearchFilterComponentProps = { className?: string; name: string; @@ -148,30 +137,12 @@ export type SearchFilterComponentProps = { valuesDebounceMs?: number; }; -// Warning: (ae-missing-release-tag) "SearchFilterNext" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) -// // @public @deprecated (undocumented) -export const SearchFilterNext: { - ({ component: Element, ...props }: SearchFilterWrapperProps): JSX.Element; - Checkbox( - props: Omit & - SearchFilterComponentProps, - ): JSX.Element; - Select( - props: Omit & - SearchFilterComponentProps, - ): JSX.Element; - Autocomplete(props: SearchAutocompleteFilterProps): JSX.Element; -}; - -// @public (undocumented) export type SearchFilterWrapperProps = SearchFilterComponentProps & { component: (props: SearchFilterComponentProps) => ReactElement; debug?: boolean; }; -// Warning: (ae-missing-release-tag) "SearchModal" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) -// // @public (undocumented) export const SearchModal: ({ open, @@ -185,8 +156,6 @@ export interface SearchModalChildrenProps { toggleModal: () => void; } -// Warning: (ae-missing-release-tag) "SearchModalProps" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) -// // @public (undocumented) export interface SearchModalProps { children?: (props: SearchModalChildrenProps) => JSX.Element; @@ -217,45 +186,25 @@ export type SearchModalValue = { setOpen: (open: boolean) => void; }; -// Warning: (ae-missing-release-tag) "SearchPage" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) -// // @public (undocumented) export const SearchPage: () => JSX.Element; -// Warning: (ae-missing-release-tag) "SearchPageNext" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) -// -// @public @deprecated (undocumented) -export const SearchPageNext: () => JSX.Element; - -// Warning: (ae-missing-release-tag) "searchPlugin" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) -// // @public (undocumented) const searchPlugin: BackstagePlugin< { root: RouteRef; - nextRoot: RouteRef; }, {} >; export { searchPlugin as plugin }; export { searchPlugin }; -// Warning: (ae-missing-release-tag) "SearchResult" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) -// -// @public (undocumented) -export const SearchResult: ({ - children, -}: { - children: (results: { results: SearchResult_2[] }) => JSX.Element; -}) => JSX.Element; +// @public @deprecated (undocumented) +export const SearchResult: (props: SearchResultProps) => JSX.Element; -// Warning: (ae-missing-release-tag) "SearchResultPager" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) -// -// @public (undocumented) +// @public @deprecated (undocumented) export const SearchResultPager: () => JSX.Element; -// Warning: (ae-missing-release-tag) "SearchType" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) -// // @public (undocumented) export const SearchType: { (props: SearchTypeProps): JSX.Element; @@ -274,7 +223,7 @@ export type SearchTypeAccordionProps = { defaultValue?: string; }; -// @public (undocumented) +// @public export type SearchTypeProps = { className?: string; name: string; @@ -291,29 +240,21 @@ export type SearchTypeTabsProps = { defaultValue?: string; }; -// Warning: (ae-missing-release-tag) "SidebarSearch" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) -// // @public (undocumented) export const SidebarSearch: (props: SidebarSearchProps) => JSX.Element; -// Warning: (ae-missing-release-tag) "SidebarSearchModal" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) -// // @public (undocumented) export const SidebarSearchModal: ( props: SidebarSearchModalProps, ) => JSX.Element; -// Warning: (ae-missing-release-tag) "SidebarSearchModalProps" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) -// -// @public (undocumented) +// @public export type SidebarSearchModalProps = { icon?: IconComponent; children?: (props: SearchModalChildrenProps) => JSX.Element; }; -// Warning: (ae-missing-release-tag) "SidebarSearchProps" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) -// -// @public (undocumented) +// @public export type SidebarSearchProps = { icon?: IconComponent; }; diff --git a/plugins/search/src/components/Filters/Filters.tsx b/plugins/search/src/components/Filters/Filters.tsx index a0ca4914a2..9170961e18 100644 --- a/plugins/search/src/components/Filters/Filters.tsx +++ b/plugins/search/src/components/Filters/Filters.tsx @@ -44,17 +44,34 @@ const useStyles = makeStyles(theme => ({ }, })); +/** + * @public + * @deprecated This type and corresponding component will be removed in a + * future release. + */ export type FiltersState = { selected: string; checked: Array; }; +/** + * @public + * @deprecated This type and corresponding component will be removed in a + * future release. + */ export type FilterOptions = { kind: Array; lifecycle: Array; }; -type FiltersProps = { +/** + * Props for {@link Filters}. + * + * @public + * @deprecated This type and corresponding component will be removed in a + * future release. + */ +export type FiltersProps = { filters: FiltersState; filterOptions: FilterOptions; resetFilters: () => void; @@ -62,6 +79,11 @@ type FiltersProps = { updateChecked: (filter: string) => void; }; +/** + * @public + * @deprecated This component will be removed in a future release. Use + * `SearchFilter` from `@backstage/plugin-search-react` instead. + */ export const Filters = ({ filters, filterOptions, diff --git a/plugins/search/src/components/Filters/FiltersButton.tsx b/plugins/search/src/components/Filters/FiltersButton.tsx index 1c2829defe..890c787689 100644 --- a/plugins/search/src/components/Filters/FiltersButton.tsx +++ b/plugins/search/src/components/Filters/FiltersButton.tsx @@ -28,11 +28,22 @@ const useStyles = makeStyles(theme => ({ }, })); -type FiltersButtonProps = { +/** + * Props for {@link FiltersButton}. + * + * @public + * @deprecated This type and corresponding component will be removed in a + * future release. + */ +export type FiltersButtonProps = { numberOfSelectedFilters: number; handleToggleFilters: () => void; }; +/** + * @public + * @deprecated See `SearchFilter` in `@backstage/plugin-search-react` instead. + */ export const FiltersButton = ({ numberOfSelectedFilters, handleToggleFilters, diff --git a/plugins/search/src/components/Filters/index.tsx b/plugins/search/src/components/Filters/index.tsx index de890e0eaf..dc4d811dbd 100644 --- a/plugins/search/src/components/Filters/index.tsx +++ b/plugins/search/src/components/Filters/index.tsx @@ -15,5 +15,6 @@ */ export { FiltersButton } from './FiltersButton'; +export type { FiltersButtonProps } from './FiltersButton'; export { Filters } from './Filters'; -export type { FiltersState } from './Filters'; +export type { FilterOptions, FiltersProps, FiltersState } from './Filters'; diff --git a/plugins/search/src/components/HomePageComponent/HomePageSearchBar.tsx b/plugins/search/src/components/HomePageComponent/HomePageSearchBar.tsx index bc4b409d57..6e9787c2e0 100644 --- a/plugins/search/src/components/HomePageComponent/HomePageSearchBar.tsx +++ b/plugins/search/src/components/HomePageComponent/HomePageSearchBar.tsx @@ -16,7 +16,10 @@ import React, { useCallback, useState } from 'react'; import { makeStyles } from '@material-ui/core/styles'; -import { SearchBarBase, SearchBarBaseProps } from '../SearchBar'; +import { + SearchBarBase, + SearchBarBaseProps, +} from '@backstage/plugin-search-react'; import { useNavigateToQuery } from '../util'; const useStyles = makeStyles({ @@ -37,9 +40,7 @@ export type HomePageSearchBarProps = Partial< >; /** - * The search bar created specifically for the composable home page - * - * @public + * The search bar created specifically for the composable home page. */ export const HomePageSearchBar = ({ ...props }: HomePageSearchBarProps) => { const classes = useStyles(props); diff --git a/plugins/search/src/components/SearchBar/SearchBar.tsx b/plugins/search/src/components/SearchBar/SearchBar.tsx index b5d55fed8e..a854869976 100644 --- a/plugins/search/src/components/SearchBar/SearchBar.tsx +++ b/plugins/search/src/components/SearchBar/SearchBar.tsx @@ -14,35 +14,20 @@ * limitations under the License. */ -import React, { - ChangeEvent, - KeyboardEvent, - useState, - useEffect, - useCallback, -} from 'react'; -import useDebounce from 'react-use/lib/useDebounce'; -import { configApiRef, useApi } from '@backstage/core-plugin-api'; -import { - InputBase, - InputBaseProps, - InputAdornment, - IconButton, -} from '@material-ui/core'; -import SearchIcon from '@material-ui/icons/Search'; -import ClearButton from '@material-ui/icons/Clear'; +import React, { useCallback } from 'react'; + +import { InputBaseProps } from '@material-ui/core'; import { - SearchContextProvider, + SearchBarBase as RealSearchBarBase, useSearch, - useSearchContextCheck, } from '@backstage/plugin-search-react'; -import { TrackSearch } from '../SearchTracker'; /** * Props for {@link SearchBarBase}. * * @public + * @deprecated Import from `@backstage/plugin-search-react` instead. */ export type SearchBarBaseProps = Omit & { debounceTime?: number; @@ -58,100 +43,15 @@ export type SearchBarBaseProps = Omit & { * Recommended if you don't use Search Provider or Search Context. * * @public + * @deprecated Import from `@backstage/plugin-search-react` instead. */ -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(); - - useEffect(() => { - setValue(prevValue => - prevValue !== defaultValue ? (defaultValue as string) : prevValue, - ); - }, [defaultValue]); - - 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(''); - }, [onChange]); - - const placeholder = `Search in ${ - configApi.getOptionalString('app.title') || 'Backstage' - }`; - - const startAdornment = ( - - - - - - ); - - const endAdornment = ( - - - - - - ); - - const searchBar = ( - - - - ); - - return hasSearchContext ? ( - searchBar - ) : ( - {searchBar} - ); -}; +export const SearchBarBase = RealSearchBarBase; /** * Props for {@link SearchBar}. * * @public + * @deprecated Import from `@backstage/plugin-search-react` instead. */ export type SearchBarProps = Partial; @@ -159,6 +59,7 @@ export type SearchBarProps = Partial; * Recommended search bar when you use the Search Provider or Search Context. * * @public + * @deprecated Import from `@backstage/plugin-search-react` instead. */ export const SearchBar = ({ onChange, ...props }: SearchBarProps) => { const { term, setTerm } = useSearch(); diff --git a/plugins/search/src/components/SearchFilter/SearchFilter.Autocomplete.tsx b/plugins/search/src/components/SearchFilter/SearchFilter.Autocomplete.tsx index 03f4f28943..3db5112b88 100644 --- a/plugins/search/src/components/SearchFilter/SearchFilter.Autocomplete.tsx +++ b/plugins/search/src/components/SearchFilter/SearchFilter.Autocomplete.tsx @@ -14,103 +14,14 @@ * limitations under the License. */ -import React, { ChangeEvent, useState } from 'react'; -import { Chip, TextField } from '@material-ui/core'; -import { - Autocomplete, - AutocompleteGetTagProps, - AutocompleteRenderInputParams, -} from '@material-ui/lab'; -import { useSearch } from '@backstage/plugin-search-react'; -import { useAsyncFilterValues, useDefaultFilterValue } from './hooks'; -import { SearchFilterComponentProps } from './SearchFilter'; +import { SearchFilterComponentProps } from '@backstage/plugin-search-react'; /** * @public + * @deprecated Import from `@backstage/plugin-search-react` instead. */ export type SearchAutocompleteFilterProps = SearchFilterComponentProps & { filterSelectedOptions?: boolean; limitTags?: number; multiple?: boolean; }; - -export const AutocompleteFilter = (props: SearchAutocompleteFilterProps) => { - const { - className, - defaultValue, - name, - values: givenValues, - valuesDebounceMs, - label, - filterSelectedOptions, - limitTags, - multiple, - } = props; - const [inputValue, setInputValue] = useState(''); - useDefaultFilterValue(name, defaultValue); - const asyncValues = - typeof givenValues === 'function' ? givenValues : undefined; - const defaultValues = - typeof givenValues === 'function' ? undefined : givenValues; - const { value: values, loading } = useAsyncFilterValues( - asyncValues, - inputValue, - defaultValues, - valuesDebounceMs, - ); - const { filters, setFilters } = useSearch(); - const filterValue = - (filters[name] as string | string[] | undefined) || (multiple ? [] : null); - - // Set new filter values on input change. - const handleChange = ( - _: ChangeEvent<{}>, - newValue: string | string[] | null, - ) => { - setFilters(prevState => { - const { [name]: filter, ...others } = prevState; - - if (newValue) { - return { ...others, [name]: newValue }; - } - return { ...others }; - }); - }; - - // Provide the input field. - const renderInput = (params: AutocompleteRenderInputParams) => ( - - ); - - // Render tags as primary-colored chips. - const renderTags = ( - tagValue: string[], - getTagProps: AutocompleteGetTagProps, - ) => - tagValue.map((option: string, index: number) => ( - - )); - - return ( - setInputValue(newValue)} - renderInput={renderInput} - renderTags={renderTags} - /> - ); -}; diff --git a/plugins/search/src/components/SearchFilter/SearchFilter.tsx b/plugins/search/src/components/SearchFilter/SearchFilter.tsx index bfbab8531c..e58bbd5c14 100644 --- a/plugins/search/src/components/SearchFilter/SearchFilter.tsx +++ b/plugins/search/src/components/SearchFilter/SearchFilter.tsx @@ -14,33 +14,18 @@ * limitations under the License. */ -import React, { ReactElement, ChangeEvent } from 'react'; -import { - makeStyles, - FormControl, - FormControlLabel, - InputLabel, - Checkbox, - Select, - MenuItem, - FormLabel, -} from '@material-ui/core'; +import React, { ReactElement } from 'react'; import { AutocompleteFilter, + CheckboxFilter, SearchAutocompleteFilterProps, -} from './SearchFilter.Autocomplete'; -import { useSearch } from '@backstage/plugin-search-react'; -import { useAsyncFilterValues, useDefaultFilterValue } from './hooks'; - -const useStyles = makeStyles({ - label: { - textTransform: 'capitalize', - }, -}); + SelectFilter, +} from '@backstage/plugin-search-react'; /** * @public + * @deprecated Import from `@backstage/plugin-search-react` instead. */ export type SearchFilterComponentProps = { className?: string; @@ -63,152 +48,33 @@ export type SearchFilterComponentProps = { /** * @public + * @deprecated Import from `@backstage/plugin-search-react` instead. */ export type SearchFilterWrapperProps = SearchFilterComponentProps & { component: (props: SearchFilterComponentProps) => ReactElement; debug?: boolean; }; -const CheckboxFilter = (props: SearchFilterComponentProps) => { - const { - className, - defaultValue, - label, - name, - values: givenValues = [], - valuesDebounceMs, - } = props; - const classes = useStyles(); - const { filters, setFilters } = useSearch(); - useDefaultFilterValue(name, defaultValue); - const asyncValues = - typeof givenValues === 'function' ? givenValues : undefined; - const defaultValues = - typeof givenValues === 'function' ? undefined : givenValues; - const { value: values = [], loading } = useAsyncFilterValues( - asyncValues, - '', - defaultValues, - valuesDebounceMs, - ); - - const handleChange = (e: ChangeEvent) => { - const { - target: { value, checked }, - } = e; - - setFilters(prevFilters => { - const { [name]: filter, ...others } = prevFilters; - const rest = ((filter as string[]) || []).filter(i => i !== value); - const items = checked ? [...rest, value] : rest; - return items.length ? { ...others, [name]: items } : others; - }); - }; - - return ( - - {label ? {label} : null} - {values.map((value: string) => ( - - } - label={value} - /> - ))} - - ); -}; - -const SelectFilter = (props: SearchFilterComponentProps) => { - const { - className, - defaultValue, - label, - name, - values: givenValues, - valuesDebounceMs, - } = props; - const classes = useStyles(); - useDefaultFilterValue(name, defaultValue); - const asyncValues = - typeof givenValues === 'function' ? givenValues : undefined; - const defaultValues = - typeof givenValues === 'function' ? undefined : givenValues; - const { value: values = [], loading } = useAsyncFilterValues( - asyncValues, - '', - defaultValues, - valuesDebounceMs, - ); - const { filters, setFilters } = useSearch(); - - const handleChange = (e: ChangeEvent<{ value: unknown }>) => { - const { - target: { value }, - } = e; - - setFilters(prevFilters => { - const { [name]: filter, ...others } = prevFilters; - return value ? { ...others, [name]: value as string } : others; - }); - }; - - return ( - - {label ? ( - - {label} - - ) : null} - - - ); -}; - +/** + * @public + * @deprecated Import from `@backstage/plugin-search-react` instead. + */ const SearchFilter = ({ component: Element, ...props }: SearchFilterWrapperProps) => ; +/** + * @deprecated Import from `@backstage/plugin-search-react` instead. + */ SearchFilter.Checkbox = ( props: Omit & SearchFilterComponentProps, ) => ; +/** + * @deprecated Import from `@backstage/plugin-search-react` instead. + */ SearchFilter.Select = ( props: Omit & SearchFilterComponentProps, @@ -218,18 +84,12 @@ SearchFilter.Select = ( * A control surface for a given filter field name, rendered as an autocomplete * textfield. A hard-coded list of values may be provided, or an async function * which returns values may be provided instead. + * * @public + * @deprecated Import from `@backstage/plugin-search-react` instead. */ SearchFilter.Autocomplete = (props: SearchAutocompleteFilterProps) => ( ); -/** - * @deprecated This component was used for rapid prototyping of the Backstage - * Search platform. Now that the API has stabilized, you should use the - * component instead. This component will be removed in an - * upcoming release. - */ -const SearchFilterNext = SearchFilter; - -export { SearchFilter, SearchFilterNext }; +export { SearchFilter }; diff --git a/plugins/search/src/components/SearchFilter/index.ts b/plugins/search/src/components/SearchFilter/index.ts index 86cd66ff6b..3050330038 100644 --- a/plugins/search/src/components/SearchFilter/index.ts +++ b/plugins/search/src/components/SearchFilter/index.ts @@ -14,7 +14,7 @@ * limitations under the License. */ -export { SearchFilter, SearchFilterNext } from './SearchFilter'; +export { SearchFilter } from './SearchFilter'; export type { SearchFilterComponentProps, SearchFilterWrapperProps, diff --git a/plugins/search/src/components/SearchModal/SearchModal.stories.tsx b/plugins/search/src/components/SearchModal/SearchModal.stories.tsx index a4e92f53ec..aad018c334 100644 --- a/plugins/search/src/components/SearchModal/SearchModal.stories.tsx +++ b/plugins/search/src/components/SearchModal/SearchModal.stories.tsx @@ -27,17 +27,17 @@ import { import { makeStyles } from '@material-ui/core/styles'; import React, { ComponentType } from 'react'; import { rootRouteRef } from '../../plugin'; -import { DefaultResultListItem } from '../DefaultResultListItem'; import { SearchBar } from '../SearchBar'; import { + DefaultResultListItem, searchApiRef, MockSearchApi, SearchContextProvider, + SearchResult, + SearchResultPager, } from '@backstage/plugin-search-react'; import { TestApiProvider } from '@backstage/test-utils'; import { SearchModal } from './SearchModal'; -import { SearchResult } from '../SearchResult'; -import { SearchResultPager } from '../SearchResultPager'; import { SearchType } from '../SearchType'; import { useSearchModal } from './useSearchModal'; diff --git a/plugins/search/src/components/SearchModal/SearchModal.tsx b/plugins/search/src/components/SearchModal/SearchModal.tsx index b3df52d42e..6c37f20001 100644 --- a/plugins/search/src/components/SearchModal/SearchModal.tsx +++ b/plugins/search/src/components/SearchModal/SearchModal.tsx @@ -28,14 +28,14 @@ import { } from '@material-ui/core'; import LaunchIcon from '@material-ui/icons/Launch'; import { makeStyles } from '@material-ui/core/styles'; -import { SearchBar } from '../SearchBar'; -import { DefaultResultListItem } from '../DefaultResultListItem'; -import { SearchResult } from '../SearchResult'; import { + DefaultResultListItem, SearchContextProvider, + SearchBar, + SearchResult, + SearchResultPager, useSearch, } from '@backstage/plugin-search-react'; -import { SearchResultPager } from '../SearchResultPager'; import { useRouteRef } from '@backstage/core-plugin-api'; import { Link, useContent } from '@backstage/core-components'; import { rootRouteRef } from '../../plugin'; @@ -50,6 +50,9 @@ export interface SearchModalChildrenProps { toggleModal: () => void; } +/** + * @public + **/ export interface SearchModalProps { /** * If true, it renders the modal. @@ -167,6 +170,9 @@ export const Modal = ({ toggleModal }: SearchModalProps) => { ); }; +/** + * @public + */ export const SearchModal = ({ open = true, hidden, diff --git a/plugins/search/src/components/SearchPage/SearchPage.tsx b/plugins/search/src/components/SearchPage/SearchPage.tsx index 29a22a6f5c..7145c7a5ef 100644 --- a/plugins/search/src/components/SearchPage/SearchPage.tsx +++ b/plugins/search/src/components/SearchPage/SearchPage.tsx @@ -87,6 +87,9 @@ export const UrlUpdater = () => { return null; }; +/** + * @public + */ export const SearchPage = () => { const outlet = useOutlet(); diff --git a/plugins/search/src/components/SearchResultPager/SearchResultPager.tsx b/plugins/search/src/components/SearchResultPager/SearchResultPager.tsx index 6f4c8628a6..287e485dd1 100644 --- a/plugins/search/src/components/SearchResultPager/SearchResultPager.tsx +++ b/plugins/search/src/components/SearchResultPager/SearchResultPager.tsx @@ -29,6 +29,10 @@ const useStyles = makeStyles(theme => ({ }, })); +/** + * @public + * @deprecated Import from `@backstage/plugin-search-react` instead. + */ export const SearchResultPager = () => { const { fetchNextPage, fetchPreviousPage } = useSearch(); const classes = useStyles(); diff --git a/plugins/search/src/components/SearchType/SearchType.tsx b/plugins/search/src/components/SearchType/SearchType.tsx index 79c3dda2b9..b20e8b5fba 100644 --- a/plugins/search/src/components/SearchType/SearchType.tsx +++ b/plugins/search/src/components/SearchType/SearchType.tsx @@ -47,6 +47,8 @@ const useStyles = makeStyles(theme => ({ })); /** + * Props for {@link SearchType}. + * * @public */ export type SearchTypeProps = { @@ -56,6 +58,9 @@ export type SearchTypeProps = { defaultValue?: string[] | string | null; }; +/** + * @public + */ const SearchType = (props: SearchTypeProps) => { const { className, defaultValue, name, values = [] } = props; const classes = useStyles(); diff --git a/plugins/search/src/components/SidebarSearch/SidebarSearch.tsx b/plugins/search/src/components/SidebarSearch/SidebarSearch.tsx index 25d41fa10f..36ead2ab15 100644 --- a/plugins/search/src/components/SidebarSearch/SidebarSearch.tsx +++ b/plugins/search/src/components/SidebarSearch/SidebarSearch.tsx @@ -21,10 +21,18 @@ import { rootRouteRef } from '../../plugin'; import { useRouteRef, IconComponent } from '@backstage/core-plugin-api'; import { SidebarSearchField, useContent } from '@backstage/core-components'; +/** + * Props for {@link SidebarSearch}. + * + * @public + */ export type SidebarSearchProps = { icon?: IconComponent; }; +/** + * @public + */ export const SidebarSearch = (props: SidebarSearchProps) => { const searchRoute = useRouteRef(rootRouteRef); const { focusContent } = useContent(); diff --git a/plugins/search/src/components/SidebarSearchModal/SidebarSearchModal.tsx b/plugins/search/src/components/SidebarSearchModal/SidebarSearchModal.tsx index 191a09395c..fb4afc75fd 100644 --- a/plugins/search/src/components/SidebarSearchModal/SidebarSearchModal.tsx +++ b/plugins/search/src/components/SidebarSearchModal/SidebarSearchModal.tsx @@ -24,6 +24,11 @@ import { useSearchModal, } from '../SearchModal'; +/** + * Props for {@link SidebarSearchModal}. + * + * @public + */ export type SidebarSearchModalProps = { icon?: IconComponent; children?: (props: SearchModalChildrenProps) => JSX.Element; diff --git a/plugins/search/src/index.ts b/plugins/search/src/index.ts index 1457ac781d..fd0e70c61a 100644 --- a/plugins/search/src/index.ts +++ b/plugins/search/src/index.ts @@ -21,14 +21,19 @@ */ export { Filters, FiltersButton } from './components/Filters'; -export type { FiltersState } from './components/Filters'; +export type { + FilterOptions, + FiltersState, + FiltersProps, + FiltersButtonProps, +} from './components/Filters'; export type { HomePageSearchBarProps } from './components/HomePageComponent'; export { SearchBar, SearchBarBase } from './components/SearchBar'; export type { SearchBarBaseProps, SearchBarProps, } from './components/SearchBar'; -export { SearchFilter, SearchFilterNext } from './components/SearchFilter'; +export { SearchFilter } from './components/SearchFilter'; export type { SearchAutocompleteFilterProps, SearchFilterComponentProps, @@ -60,9 +65,7 @@ export type { SidebarSearchModalProps } from './components/SidebarSearchModal'; export { DefaultResultListItem, HomePageSearchBar, - SearchBarNext, SearchPage, - SearchPageNext, searchPlugin as plugin, searchPlugin, SearchResult, diff --git a/plugins/search/src/plugin.ts b/plugins/search/src/plugin.ts index 96e7bc1eed..19fd501129 100644 --- a/plugins/search/src/plugin.ts +++ b/plugins/search/src/plugin.ts @@ -15,7 +15,11 @@ */ import { SearchClient } from './apis'; -import { searchApiRef } from '@backstage/plugin-search-react'; +import { + searchApiRef, + SearchResult as RealSearchResult, + DefaultResultListItem as RealDefaultResultListItem, +} from '@backstage/plugin-search-react'; import { createApiFactory, createPlugin, @@ -30,10 +34,9 @@ export const rootRouteRef = createRouteRef({ id: 'search', }); -export const rootNextRouteRef = createRouteRef({ - id: 'search:next', -}); - +/** + * @public + */ export const searchPlugin = createPlugin({ id: 'search', apis: [ @@ -47,10 +50,12 @@ export const searchPlugin = createPlugin({ ], routes: { root: rootRouteRef, - nextRoot: rootNextRouteRef, }, }); +/** + * @public + */ export const SearchPage = searchPlugin.provide( createRoutableExtension({ name: 'SearchPage', @@ -60,67 +65,14 @@ export const SearchPage = searchPlugin.provide( ); /** - * @deprecated This component was used for rapid prototyping of the Backstage - * Search platform. Now that the API has stabilized, you should use the - * component instead. This component will be removed in an - * upcoming release. + * @public + * @deprecated Import from `@backstage/plugin-search-react` instead. */ -export const SearchPageNext = searchPlugin.provide( - createRoutableExtension({ - name: 'SearchPageNext', - component: () => import('./components/SearchPage').then(m => m.SearchPage), - mountPoint: rootNextRouteRef, - }), -); - -export const SearchBar = searchPlugin.provide( - createComponentExtension({ - name: 'SearchBar', - component: { - lazy: () => import('./components/SearchBar').then(m => m.SearchBar), - }, - }), -); +export const SearchResult = RealSearchResult; /** - * @deprecated This component was used for rapid prototyping of the Backstage - * Search platform. Now that the API has stabilized, you should use the - * component instead. This component will be removed in an - * upcoming release. + * @public */ -export const SearchBarNext = searchPlugin.provide( - createComponentExtension({ - name: 'SearchBarNext', - component: { - lazy: () => import('./components/SearchBar').then(m => m.SearchBar), - }, - }), -); - -export const SearchResult = searchPlugin.provide( - createComponentExtension({ - name: 'SearchResult', - component: { - lazy: () => import('./components/SearchResult').then(m => m.SearchResult), - }, - }), -); - -/** - * @deprecated This component was used for rapid prototyping of the Backstage - * Search platform. Now that the API has stabilized, you should use the - * component instead. This component will be removed in an - * upcoming release. - */ -export const SearchResultNext = searchPlugin.provide( - createComponentExtension({ - name: 'SearchResultNext', - component: { - lazy: () => import('./components/SearchResult').then(m => m.SearchResult), - }, - }), -); - export const SidebarSearchModal = searchPlugin.provide( createComponentExtension({ name: 'SidebarSearchModal', @@ -133,18 +85,15 @@ export const SidebarSearchModal = searchPlugin.provide( }), ); -export const DefaultResultListItem = searchPlugin.provide( - createComponentExtension({ - name: 'DefaultResultListItem', - component: { - lazy: () => - import('./components/DefaultResultListItem').then( - m => m.DefaultResultListItem, - ), - }, - }), -); +/** + * @public + * @deprecated Import from `@backstage/plugin-search-react` instead. + */ +export const DefaultResultListItem = RealDefaultResultListItem; +/** + * @public + */ export const HomePageSearchBar = searchPlugin.provide( createComponentExtension({ name: 'HomePageSearchBar', diff --git a/scripts/api-extractor.ts b/scripts/api-extractor.ts index 15e43fb02d..619dafabd4 100644 --- a/scripts/api-extractor.ts +++ b/scripts/api-extractor.ts @@ -256,6 +256,7 @@ const NO_WARNING_PACKAGES = [ 'plugins/scaffolder-backend-module-rails', 'plugins/scaffolder-backend-module-yeoman', 'plugins/scaffolder-common', + 'plugins/search', 'plugins/search-backend', 'plugins/search-backend-node', 'plugins/search-backend-module-elasticsearch',