diff --git a/.changeset/good-singers-flourish.md b/.changeset/good-singers-flourish.md new file mode 100644 index 0000000000..d8b6e1a140 --- /dev/null +++ b/.changeset/good-singers-flourish.md @@ -0,0 +1,6 @@ +--- +'@backstage/plugin-home': minor +--- + +Rename RandomJokeHomePageComponent to HomePageRandomJoke to fit convention, and update example app accordingly. +**NOTE**: If you're using the RandomJoke component in your instance, it now has to be renamed to `HomePageRandomJoke` diff --git a/.changeset/twenty-ravens-fetch.md b/.changeset/twenty-ravens-fetch.md new file mode 100644 index 0000000000..28393b7936 --- /dev/null +++ b/.changeset/twenty-ravens-fetch.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-search': patch +--- + +Add Home Page Search Bar Component, to be included in composable Home Page. diff --git a/packages/app/src/components/home/HomePage.tsx b/packages/app/src/components/home/HomePage.tsx index e171e22ae9..5831b49d2c 100644 --- a/packages/app/src/components/home/HomePage.tsx +++ b/packages/app/src/components/home/HomePage.tsx @@ -17,27 +17,28 @@ import React from 'react'; import Grid from '@material-ui/core/Grid'; import { - RandomJokeHomePageComponent, + HomePageRandomJoke, ComponentAccordion, ComponentTabs, ComponentTab, } from '@backstage/plugin-home'; +import { HomePageSearchBar } from '@backstage/plugin-search'; export const HomePage = () => ( - - + + - - + + + + - ( { label: 'Programming', Component: () => ( - @@ -59,7 +60,7 @@ export const HomePage = () => ( { label: 'Any', Component: () => ( - diff --git a/plugins/home/api-report.md b/plugins/home/api-report.md index a2350b803b..66b6e55306 100644 --- a/plugins/home/api-report.md +++ b/plugins/home/api-report.md @@ -84,6 +84,19 @@ export const HomepageCompositionRoot: (props: { children?: ReactNode; }) => JSX.Element; +// Warning: (ae-missing-release-tag) "HomePageRandomJoke" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// +// @public (undocumented) +export const HomePageRandomJoke: ({ + Renderer, + title: overrideTitle, + ...childProps +}: ComponentRenderer & { + title?: string | undefined; +} & { + defaultCategory?: 'any' | 'programming' | undefined; +}) => JSX.Element; + // Warning: (ae-missing-release-tag) "homePlugin" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) // // @public (undocumented) @@ -94,19 +107,6 @@ export const homePlugin: BackstagePlugin< {} >; -// Warning: (ae-missing-release-tag) "RandomJokeHomePageComponent" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) -// -// @public (undocumented) -export const RandomJokeHomePageComponent: ({ - Renderer, - title: overrideTitle, - ...childProps -}: ComponentRenderer & { - title?: string | undefined; -} & { - defaultCategory?: 'any' | 'programming' | undefined; -}) => JSX.Element; - // Warning: (ae-missing-release-tag) "SettingsModal" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) // // @public (undocumented) diff --git a/plugins/home/src/index.ts b/plugins/home/src/index.ts index 812e1b2495..0c7cc2c09f 100644 --- a/plugins/home/src/index.ts +++ b/plugins/home/src/index.ts @@ -17,7 +17,7 @@ export { homePlugin, HomepageCompositionRoot, - RandomJokeHomePageComponent, + HomePageRandomJoke, ComponentAccordion, ComponentTabs, ComponentTab, diff --git a/plugins/home/src/plugin.ts b/plugins/home/src/plugin.ts index 5600d2d315..46df84c1bf 100644 --- a/plugins/home/src/plugin.ts +++ b/plugins/home/src/plugin.ts @@ -60,7 +60,7 @@ export const ComponentTab = homePlugin.provide( }), ); -export const RandomJokeHomePageComponent = homePlugin.provide( +export const HomePageRandomJoke = homePlugin.provide( createCardExtension<{ defaultCategory?: 'any' | 'programming' }>({ title: 'Random Joke', components: () => import('./homePageComponents/RandomJoke'), diff --git a/plugins/search/api-report.md b/plugins/search/api-report.md index 03c24609e2..bdb2ab2a54 100644 --- a/plugins/search/api-report.md +++ b/plugins/search/api-report.md @@ -55,6 +55,15 @@ export type FiltersState = { 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: ({ + placeholder, +}: { + placeholder?: string | undefined; +}) => JSX.Element; + // 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) diff --git a/plugins/search/src/components/HomePageComponent/HomePageSearchBar.tsx b/plugins/search/src/components/HomePageComponent/HomePageSearchBar.tsx new file mode 100644 index 0000000000..94c72df3a8 --- /dev/null +++ b/plugins/search/src/components/HomePageComponent/HomePageSearchBar.tsx @@ -0,0 +1,60 @@ +/* + * 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 React from 'react'; +import { makeStyles } from '@material-ui/core/styles'; + +import { SearchBarBase } from '../SearchBar'; +import { useNavigateToQuery } from '../util'; + +const useStyles = makeStyles({ + searchBar: { + border: '1px solid #555', + borderRadius: '6px', + fontSize: '1.5em', + }, +}); + +type Props = { + placeholder?: string; +}; + +export const HomePageSearchBar = ({ placeholder }: Props) => { + const [query, setQuery] = React.useState(''); + const handleSearch = useNavigateToQuery(); + const classes = useStyles(); + + const handleSubmit = () => { + handleSearch({ query }); + }; + + const handleChange = React.useCallback( + value => { + setQuery(value); + }, + [setQuery], + ); + + return ( + + ); +}; diff --git a/plugins/search/src/components/HomePageComponent/index.ts b/plugins/search/src/components/HomePageComponent/index.ts new file mode 100644 index 0000000000..2b1cfe27b3 --- /dev/null +++ b/plugins/search/src/components/HomePageComponent/index.ts @@ -0,0 +1,17 @@ +/* + * 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. + */ + +export { HomePageSearchBar } from './HomePageSearchBar'; diff --git a/plugins/search/src/components/SearchBar/SearchBar.test.tsx b/plugins/search/src/components/SearchBar/SearchBar.test.tsx index 188ffb279b..2c5a02eacb 100644 --- a/plugins/search/src/components/SearchBar/SearchBar.test.tsx +++ b/plugins/search/src/components/SearchBar/SearchBar.test.tsx @@ -20,11 +20,12 @@ import userEvent from '@testing-library/user-event'; import { SearchContextProvider } from '../SearchContext'; import { SearchBar } from './SearchBar'; -import { useApi } from '@backstage/core-plugin-api'; +import { configApiRef } from '@backstage/core-plugin-api'; +import { ApiProvider, ApiRegistry } from '@backstage/core-app-api'; +import { searchApiRef } from '../../apis'; jest.mock('@backstage/core-plugin-api', () => ({ ...jest.requireActual('@backstage/core-plugin-api'), - useApi: jest.fn().mockReturnValue({}), })); describe('SearchBar', () => { @@ -32,13 +33,19 @@ describe('SearchBar', () => { term: '', filters: {}, types: ['*'], + pageCursor: '', }; - const name = 'Search term'; - const term = 'term'; - const query = jest.fn().mockResolvedValue({}); - (useApi as jest.Mock).mockReturnValue({ query }); + const getString = jest.fn().mockReturnValue('Mock title'); + + const apiRegistry = ApiRegistry.from([ + [configApiRef, { getString }], + [searchApiRef, { query }], + ]); + + const name = 'Search'; + const term = 'term'; afterAll(() => { jest.resetAllMocks(); @@ -46,9 +53,11 @@ describe('SearchBar', () => { it('Renders without exploding', async () => { render( - - - , + + + + + , ); await waitFor(() => { @@ -58,9 +67,12 @@ describe('SearchBar', () => { it('Renders based on initial search', async () => { render( - - - , + + + + + , + , ); await waitFor(() => { @@ -70,9 +82,12 @@ describe('SearchBar', () => { it('Updates term state when text is entered', async () => { render( - - - , + + + + + , + , ); const textbox = screen.getByRole('textbox', { name }); @@ -92,16 +107,18 @@ describe('SearchBar', () => { it('Clear button clears term state', async () => { render( - - - , + + + + + , ); await waitFor(() => { expect(screen.getByRole('textbox', { name })).toHaveValue(term); }); - userEvent.click(screen.getByRole('button', { name: 'Clear term' })); + userEvent.click(screen.getByRole('button', { name: 'Clear' })); await waitFor(() => { expect(screen.getByRole('textbox', { name })).toHaveValue(''); @@ -118,9 +135,12 @@ describe('SearchBar', () => { const debounceTime = 600; render( - - - , + + + + + , + , ); await waitFor(() => { diff --git a/plugins/search/src/components/SearchBar/SearchBar.tsx b/plugins/search/src/components/SearchBar/SearchBar.tsx index bdff003b7a..a1b88cbae7 100644 --- a/plugins/search/src/components/SearchBar/SearchBar.tsx +++ b/plugins/search/src/components/SearchBar/SearchBar.tsx @@ -14,7 +14,8 @@ * limitations under the License. */ -import React, { ChangeEvent, useEffect, useState } from 'react'; +import React, { useEffect, KeyboardEvent, useState } from 'react'; +import { configApiRef, useApi } from '@backstage/core-plugin-api'; import { useDebounce } from 'react-use'; import { InputBase, InputAdornment, IconButton } from '@material-ui/core'; import SearchIcon from '@material-ui/icons/Search'; @@ -22,6 +23,68 @@ import ClearButton from '@material-ui/icons/Clear'; import { useSearch } from '../SearchContext'; +type PresenterProps = { + value: string; + onChange: (value: string) => void; + onClear?: () => void; + onSubmit?: () => void; + className?: string; + placeholder?: string; +}; + +export const SearchBarBase = ({ + value, + onChange, + onSubmit, + className, + placeholder: overridePlaceholder, +}: PresenterProps) => { + const configApi = useApi(configApiRef); + + const onKeyDown = React.useCallback( + (e: KeyboardEvent) => { + if (onSubmit && e.key === 'Enter') { + onSubmit(); + } + }, + [onSubmit], + ); + + const handleClear = React.useCallback(() => { + onChange(''); + }, [onChange]); + + const placeholder = + overridePlaceholder ?? `Search in ${configApi.getString('app.title')}`; + + return ( + onChange(e.target.value)} + inputProps={{ 'aria-label': 'Search' }} + startAdornment={ + + + + + + } + endAdornment={ + + + + + + } + {...(className && { className })} + {...(onSubmit && { onKeyDown })} + /> + ); +}; + type Props = { className?: string; debounceTime?: number; @@ -37,35 +100,18 @@ export const SearchBar = ({ className, debounceTime = 0 }: Props) => { useDebounce(() => setTerm(value), debounceTime, [value]); - const handleQuery = (e: ChangeEvent) => { - setValue(e.target.value); + const handleQuery = (newValue: string) => { + setValue(newValue); }; const handleClear = () => setValue(''); return ( - - - - - - } - endAdornment={ - - - - - - } + onClear={handleClear} /> ); }; diff --git a/plugins/search/src/components/SearchBar/index.tsx b/plugins/search/src/components/SearchBar/index.tsx index 840142264e..5adbda282b 100644 --- a/plugins/search/src/components/SearchBar/index.tsx +++ b/plugins/search/src/components/SearchBar/index.tsx @@ -14,4 +14,4 @@ * limitations under the License. */ -export { SearchBar } from './SearchBar'; +export { SearchBar, SearchBarBase } from './SearchBar'; diff --git a/plugins/search/src/components/util.test.tsx b/plugins/search/src/components/util.test.tsx new file mode 100644 index 0000000000..a5b82370e3 --- /dev/null +++ b/plugins/search/src/components/util.test.tsx @@ -0,0 +1,59 @@ +/* + * 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 React from 'react'; +import { wrapInTestApp } from '@backstage/test-utils'; +import { render } from '@testing-library/react'; +import { useNavigateToQuery } from './util'; +import { Routes, Route } from 'react-router-dom'; +import { rootRouteRef } from '../plugin'; +import { act } from 'react-dom/test-utils'; + +const navigate = jest.fn(); +jest.mock('react-router-dom', () => ({ + ...jest.requireActual('react-router-dom'), + useNavigate: () => navigate, +})); + +describe('util', () => { + describe('useNavigateToQuery', () => { + it('navigates to query', async () => { + const MyComponent = () => { + const navigateToQuery = useNavigateToQuery(); + navigateToQuery({ query: 'test' }); + return
test
; + }; + + await act(async () => { + await render( + wrapInTestApp( + + } /> + , + { + mountedRoutes: { + '/search': rootRouteRef, + }, + }, + ), + ); + + expect(navigate).toHaveBeenCalledTimes(1); + expect(navigate).toHaveBeenCalledWith('/search?query=test'); + }); + }); + }); +}); diff --git a/plugins/search/src/components/util.ts b/plugins/search/src/components/util.ts new file mode 100644 index 0000000000..1e50a44b91 --- /dev/null +++ b/plugins/search/src/components/util.ts @@ -0,0 +1,34 @@ +/* + * 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 qs from 'qs'; +import { useCallback } from 'react'; +import { useNavigate } from 'react-router-dom'; +import { rootRouteRef } from '../plugin'; + +import { useRouteRef } from '@backstage/core-plugin-api'; + +export const useNavigateToQuery = () => { + const searchRoute = useRouteRef(rootRouteRef); + const navigate = useNavigate(); + return useCallback( + ({ query }: { query: string }): void => { + const queryString = qs.stringify({ query }, { addQueryPrefix: true }); + + navigate(`${searchRoute()}${queryString}`); + }, + [navigate, searchRoute], + ); +}; diff --git a/plugins/search/src/index.ts b/plugins/search/src/index.ts index d178329ab4..d98d5416cc 100644 --- a/plugins/search/src/index.ts +++ b/plugins/search/src/index.ts @@ -37,4 +37,5 @@ export { searchPlugin as plugin, searchPlugin, SearchResult, + HomePageSearchBar, } from './plugin'; diff --git a/plugins/search/src/plugin.ts b/plugins/search/src/plugin.ts index d89bb566e9..36094234a0 100644 --- a/plugins/search/src/plugin.ts +++ b/plugins/search/src/plugin.ts @@ -126,3 +126,12 @@ export const DefaultResultListItem = searchPlugin.provide( }, }), ); + +export const HomePageSearchBar = searchPlugin.provide( + createComponentExtension({ + component: { + lazy: () => + import('./components/HomePageComponent').then(m => m.HomePageSearchBar), + }, + }), +); diff --git a/plugins/techdocs/src/reader/components/TechDocsSearch.tsx b/plugins/techdocs/src/reader/components/TechDocsSearch.tsx index 0f3ca00c0a..6f2df2cc55 100644 --- a/plugins/techdocs/src/reader/components/TechDocsSearch.tsx +++ b/plugins/techdocs/src/reader/components/TechDocsSearch.tsx @@ -141,7 +141,7 @@ const TechDocsSearchBar = ({ ...params.InputProps, startAdornment: ( - +