Search Bar Home Page Component (#7013)

feat: add search bar home page component
This commit is contained in:
Ruben Lindström
2021-09-03 14:50:14 +02:00
committed by GitHub
parent 0993a2df1a
commit 7f00902d97
17 changed files with 339 additions and 72 deletions
+6
View File
@@ -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`
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/plugin-search': patch
---
Add Home Page Search Bar Component, to be included in composable Home Page.
+12 -11
View File
@@ -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 = () => (
<Grid container spacing={3}>
<Grid item xs={12} md={4}>
<RandomJokeHomePageComponent />
<Grid item xs={12}>
<HomePageSearchBar />
</Grid>
<Grid item xs={12} md={4}>
<RandomJokeHomePageComponent
defaultCategory="any"
Renderer={ComponentAccordion}
/>
<RandomJokeHomePageComponent
<HomePageRandomJoke />
</Grid>
<Grid item xs={12} md={4}>
<HomePageRandomJoke defaultCategory="any" Renderer={ComponentAccordion} />
<HomePageRandomJoke
title="Another Random Joke"
Renderer={ComponentAccordion}
/>
<RandomJokeHomePageComponent
<HomePageRandomJoke
title="One More Random Joke"
defaultCategory="programming"
Renderer={ComponentAccordion}
@@ -50,7 +51,7 @@ export const HomePage = () => (
{
label: 'Programming',
Component: () => (
<RandomJokeHomePageComponent
<HomePageRandomJoke
defaultCategory="programming"
Renderer={ComponentTab}
/>
@@ -59,7 +60,7 @@ export const HomePage = () => (
{
label: 'Any',
Component: () => (
<RandomJokeHomePageComponent
<HomePageRandomJoke
defaultCategory="any"
Renderer={ComponentTab}
/>
+13 -13
View File
@@ -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)
+1 -1
View File
@@ -17,7 +17,7 @@
export {
homePlugin,
HomepageCompositionRoot,
RandomJokeHomePageComponent,
HomePageRandomJoke,
ComponentAccordion,
ComponentTabs,
ComponentTab,
+1 -1
View File
@@ -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'),
+9
View File
@@ -55,6 +55,15 @@ export type FiltersState = {
checked: Array<string>;
};
// 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)
@@ -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 (
<SearchBarBase
onSubmit={handleSubmit}
onChange={handleChange}
value={query}
className={classes.searchBar}
placeholder={placeholder}
/>
);
};
@@ -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';
@@ -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(
<SearchContextProvider initialState={initialState}>
<SearchBar />
</SearchContextProvider>,
<ApiProvider apis={apiRegistry}>
<SearchContextProvider initialState={initialState}>
<SearchBar />
</SearchContextProvider>
</ApiProvider>,
);
await waitFor(() => {
@@ -58,9 +67,12 @@ describe('SearchBar', () => {
it('Renders based on initial search', async () => {
render(
<SearchContextProvider initialState={{ ...initialState, term }}>
<SearchBar />
</SearchContextProvider>,
<ApiProvider apis={apiRegistry}>
<SearchContextProvider initialState={{ ...initialState, term }}>
<SearchBar />
</SearchContextProvider>
,
</ApiProvider>,
);
await waitFor(() => {
@@ -70,9 +82,12 @@ describe('SearchBar', () => {
it('Updates term state when text is entered', async () => {
render(
<SearchContextProvider initialState={initialState}>
<SearchBar />
</SearchContextProvider>,
<ApiProvider apis={apiRegistry}>
<SearchContextProvider initialState={initialState}>
<SearchBar />
</SearchContextProvider>
,
</ApiProvider>,
);
const textbox = screen.getByRole('textbox', { name });
@@ -92,16 +107,18 @@ describe('SearchBar', () => {
it('Clear button clears term state', async () => {
render(
<SearchContextProvider initialState={{ ...initialState, term }}>
<SearchBar />
</SearchContextProvider>,
<ApiProvider apis={apiRegistry}>
<SearchContextProvider initialState={{ ...initialState, term }}>
<SearchBar />
</SearchContextProvider>
</ApiProvider>,
);
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(
<SearchContextProvider initialState={initialState}>
<SearchBar debounceTime={debounceTime} />
</SearchContextProvider>,
<ApiProvider apis={apiRegistry}>
<SearchContextProvider initialState={initialState}>
<SearchBar debounceTime={debounceTime} />
</SearchContextProvider>
,
</ApiProvider>,
);
await waitFor(() => {
@@ -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<HTMLInputElement>) => {
if (onSubmit && e.key === 'Enter') {
onSubmit();
}
},
[onSubmit],
);
const handleClear = React.useCallback(() => {
onChange('');
}, [onChange]);
const placeholder =
overridePlaceholder ?? `Search in ${configApi.getString('app.title')}`;
return (
<InputBase
data-testid="search-bar-next"
fullWidth
placeholder={placeholder}
value={value}
onChange={e => onChange(e.target.value)}
inputProps={{ 'aria-label': 'Search' }}
startAdornment={
<InputAdornment position="start">
<IconButton aria-label="Query" disabled>
<SearchIcon />
</IconButton>
</InputAdornment>
}
endAdornment={
<InputAdornment position="end">
<IconButton aria-label="Clear" onClick={handleClear}>
<ClearButton />
</IconButton>
</InputAdornment>
}
{...(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<HTMLInputElement>) => {
setValue(e.target.value);
const handleQuery = (newValue: string) => {
setValue(newValue);
};
const handleClear = () => setValue('');
return (
<InputBase
<SearchBarBase
className={className}
data-testid="search-bar-next"
fullWidth
placeholder="Search in Backstage"
value={value}
onChange={handleQuery}
inputProps={{ 'aria-label': 'Search term' }}
startAdornment={
<InputAdornment position="start">
<IconButton aria-label="Query term" disabled>
<SearchIcon />
</IconButton>
</InputAdornment>
}
endAdornment={
<InputAdornment position="end">
<IconButton aria-label="Clear term" onClick={handleClear}>
<ClearButton />
</IconButton>
</InputAdornment>
}
onClear={handleClear}
/>
);
};
@@ -14,4 +14,4 @@
* limitations under the License.
*/
export { SearchBar } from './SearchBar';
export { SearchBar, SearchBarBase } from './SearchBar';
@@ -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 <div>test</div>;
};
await act(async () => {
await render(
wrapInTestApp(
<Routes>
<Route element={<MyComponent />} />
</Routes>,
{
mountedRoutes: {
'/search': rootRouteRef,
},
},
),
);
expect(navigate).toHaveBeenCalledTimes(1);
expect(navigate).toHaveBeenCalledWith('/search?query=test');
});
});
});
});
+34
View File
@@ -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],
);
};
+1
View File
@@ -37,4 +37,5 @@ export {
searchPlugin as plugin,
searchPlugin,
SearchResult,
HomePageSearchBar,
} from './plugin';
+9
View File
@@ -126,3 +126,12 @@ export const DefaultResultListItem = searchPlugin.provide(
},
}),
);
export const HomePageSearchBar = searchPlugin.provide(
createComponentExtension({
component: {
lazy: () =>
import('./components/HomePageComponent').then(m => m.HomePageSearchBar),
},
}),
);
@@ -141,7 +141,7 @@ const TechDocsSearchBar = ({
...params.InputProps,
startAdornment: (
<InputAdornment position="start">
<IconButton aria-label="Query term" disabled>
<IconButton aria-label="Query" disabled>
<SearchIcon />
</IconButton>
</InputAdornment>