Search Bar Home Page Component (#7013)
feat: add search bar home page component
This commit is contained in:
@@ -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`
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'@backstage/plugin-search': patch
|
||||
---
|
||||
|
||||
Add Home Page Search Bar Component, to be included in composable Home Page.
|
||||
@@ -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
@@ -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)
|
||||
|
||||
@@ -17,7 +17,7 @@
|
||||
export {
|
||||
homePlugin,
|
||||
HomepageCompositionRoot,
|
||||
RandomJokeHomePageComponent,
|
||||
HomePageRandomJoke,
|
||||
ComponentAccordion,
|
||||
ComponentTabs,
|
||||
ComponentTab,
|
||||
|
||||
@@ -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'),
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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],
|
||||
);
|
||||
};
|
||||
@@ -37,4 +37,5 @@ export {
|
||||
searchPlugin as plugin,
|
||||
searchPlugin,
|
||||
SearchResult,
|
||||
HomePageSearchBar,
|
||||
} from './plugin';
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user