Add Search Modal component (#7316)

Signed-off-by: rodmachen <rmachen@expediagroup.com>
This commit is contained in:
Rod Machen
2021-11-11 02:26:15 -06:00
committed by GitHub
parent f085a4fb67
commit 5dcea2586c
14 changed files with 417 additions and 5 deletions
+28
View File
@@ -0,0 +1,28 @@
---
'@backstage/create-app': patch
---
Integrated `SidebarSearchModal` component into default-app to use the `SearchModal`.
The `SidebarSearchModal` component can also be used in other generated apps:
```diff
import {
- SidebarSearch,
+ SidebarSearchModal
} from '@backstage/plugin-search';
...
<SidebarPage>
<Sidebar>
<SidebarLogo />
- <SidebarSearch />
+ <SidebarSearchModal />
<SidebarDivider />
...
```
If you only want to use the `SearchModal` you can import it from `'@backstage/plugin-search'`:
```js
import { SearchModal } from '@backstage/plugin-search';
```
+17
View File
@@ -0,0 +1,17 @@
---
'@backstage/plugin-search': patch
---
Added `SearchModal` component.
Now you can import `SearchModal` in your apps:
```js
import { SearchModal } from '@backstage/plugin-search';
```
You can also use the `SidebarSearchModal` component to integrate it into the sidebar of your sample apps:
```js
import { SidebarSearchModal } from '@backstage/plugin-search';
```
+19
View File
@@ -109,6 +109,25 @@ const routes = (
);
```
### Using the Search Modal
In `Root.tsx`, add the `SidebarSearchModal` component:
```bash
import { SidebarSearchModal } from '@backstage/plugin-search';
export const Root = ({ children }: PropsWithChildren<{}>) => (
<SidebarPage>
<Sidebar>
<SidebarLogo />
<SidebarSearchModal />
<SidebarDivider />
...
```
For more information about using `Root.tsx`, please see
[the changelog](https://github.com/backstage/backstage/blob/master/packages/create-app/CHANGELOG.md#0315).
## Adding Search to the Backend
Add the following plugins into your backend app:
+2 -2
View File
@@ -29,7 +29,7 @@ import LogoIcon from './LogoIcon';
import { NavLink } from 'react-router-dom';
import { GraphiQLIcon } from '@backstage/plugin-graphiql';
import { Settings as SidebarSettings } from '@backstage/plugin-user-settings';
import { SidebarSearch } from '@backstage/plugin-search';
import { SidebarSearchModal } from '@backstage/plugin-search';
import { Shortcuts } from '@backstage/plugin-shortcuts';
import {
Sidebar,
@@ -79,7 +79,7 @@ export const Root = ({ children }: PropsWithChildren<{}>) => (
<SidebarPage>
<Sidebar>
<SidebarLogo />
<SidebarSearch />
<SidebarSearchModal />
<SidebarDivider />
{/* Global nav, not org-specific */}
<SidebarItem icon={HomeIcon} to="catalog" text="Home" />
@@ -25,7 +25,7 @@ import LogoFull from './LogoFull';
import LogoIcon from './LogoIcon';
import { NavLink } from 'react-router-dom';
import { Settings as SidebarSettings } from '@backstage/plugin-user-settings';
import { SidebarSearch } from '@backstage/plugin-search';
import { SidebarSearchModal } from '@backstage/plugin-search';
import {
Sidebar,
SidebarPage,
@@ -74,7 +74,7 @@ export const Root = ({ children }: PropsWithChildren<{}>) => (
<SidebarPage>
<Sidebar>
<SidebarLogo />
<SidebarSearch />
<SidebarSearchModal />
<SidebarDivider />
{/* Global nav, not org-specific */}
<SidebarItem icon={HomeIcon} to="catalog" text="Home" />
+23
View File
@@ -126,6 +126,24 @@ export const SearchFilterNext: {
Select(props: Omit<Props_2, 'component'> & Component): JSX.Element;
};
// 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,
toggleModal,
}: SearchModalProps) => JSX.Element;
// 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 {
// (undocumented)
open?: boolean;
// (undocumented)
toggleModal: () => 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)
@@ -179,6 +197,11 @@ export const SearchType: ({
// @public (undocumented)
export const SidebarSearch: () => 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: () => JSX.Element;
// Warning: (ae-forgotten-export) The symbol "SearchContextValue" needs to be exported by the entry point index.d.ts
// Warning: (ae-missing-release-tag) "useSearch" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal)
//
@@ -0,0 +1,77 @@
/*
* 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 { screen } from '@testing-library/react';
import { renderInTestApp } from '@backstage/test-utils';
import userEvent from '@testing-library/user-event';
import { configApiRef } from '@backstage/core-plugin-api';
import {
ApiProvider,
ApiRegistry,
ConfigReader,
} from '@backstage/core-app-api';
import { rootRouteRef } from '../../plugin';
import { searchApiRef } from '../../apis';
import { SearchModal } from './SearchModal';
jest.mock('../SearchContext', () => ({
...jest.requireActual('../SearchContext'),
useSearch: jest.fn().mockReturnValue({
result: {},
}),
}));
describe('SearchModal', () => {
const query = jest.fn().mockResolvedValue({});
const apiRegistry = ApiRegistry.from([
[configApiRef, new ConfigReader({ app: { title: 'Mock app' } })],
[searchApiRef, { query }],
]);
const toggleModal = jest.fn();
it('Should render the Modal correctly', async () => {
await renderInTestApp(
<ApiProvider apis={apiRegistry}>
<SearchModal open toggleModal={toggleModal} />
</ApiProvider>,
{
mountedRoutes: {
'/search': rootRouteRef,
},
},
);
expect(screen.getByRole('dialog')).toBeInTheDocument();
});
it('Calls toggleModal handler', async () => {
await renderInTestApp(
<ApiProvider apis={apiRegistry}>
<SearchModal open toggleModal={toggleModal} />
</ApiProvider>,
{
mountedRoutes: {
'/search': rootRouteRef,
},
},
);
userEvent.keyboard('{esc}');
expect(toggleModal).toHaveBeenCalledTimes(1);
});
});
@@ -0,0 +1,162 @@
/*
* 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, { useEffect, useState } from 'react';
import {
Dialog,
DialogActions,
DialogContent,
DialogTitle,
Divider,
Grid,
List,
Paper,
} from '@material-ui/core';
import { Launch } from '@material-ui/icons/';
import { makeStyles } from '@material-ui/core/styles';
import { SearchBarBase } from '../SearchBar';
import { DefaultResultListItem } from '../DefaultResultListItem';
import { SearchResult } from '../SearchResult';
import { SearchContextProvider, useSearch } from '../SearchContext';
import { SearchResultPager } from '../SearchResultPager';
import { useRouteRef } from '@backstage/core-plugin-api';
import { Link } from '@backstage/core-components';
import { rootRouteRef } from '../../plugin';
import { useDebounce } from 'react-use';
export interface SearchModalProps {
open?: boolean;
toggleModal: () => void;
}
const useStyles = makeStyles(theme => ({
container: {
borderRadius: 30,
display: 'flex',
height: '2.4em',
},
input: {
flex: 1,
},
// Reduces default height of the modal, keeping a gap of 128px between the top and bottom of the page.
paperFullWidth: { height: 'calc(100% - 128px)' },
dialogActionsContainer: { padding: theme.spacing(1, 3) },
viewResultsLink: { verticalAlign: '0.5em' },
}));
export const Modal = ({ open = true, toggleModal }: SearchModalProps) => {
const getSearchLink = useRouteRef(rootRouteRef);
const classes = useStyles();
const { term, setTerm } = useSearch();
const [value, setValue] = useState<string>(term);
useEffect(() => {
setValue(prevValue => (prevValue !== term ? term : prevValue));
}, [term]);
useDebounce(() => setTerm(value), 1000, [value]);
const handleQuery = (newValue: string) => {
setValue(newValue);
};
const handleClear = () => setValue('');
const handleResultClick = () => {
toggleModal();
handleClear();
};
const handleKeyPress = () => {
handleResultClick();
};
return (
<Dialog
classes={{
paperFullWidth: classes.paperFullWidth,
}}
onClose={toggleModal}
aria-labelledby="search-modal-title"
open={open}
fullWidth
maxWidth="lg"
>
<DialogTitle>
<Paper className={classes.container}>
<SearchBarBase
className={classes.input}
value={value}
onChange={handleQuery}
onClear={handleClear}
/>
</Paper>
</DialogTitle>
<DialogContent>
<Grid
container
direction="row-reverse"
justifyContent="flex-start"
alignItems="center"
>
<Grid item>
<Link to={`${getSearchLink()}?query=${value}`}>
<span className={classes.viewResultsLink}>View Full Results</span>
<Launch color="primary" />
</Link>
</Grid>
</Grid>
<Divider />
<SearchResult>
{({ results }) => (
<List>
{results.map(({ document }) => (
<div
role="button"
tabIndex={0}
key={`${document.location}-btn`}
onClick={handleResultClick}
onKeyPress={handleKeyPress}
>
<DefaultResultListItem
key={document.location}
result={document}
/>
</div>
))}
</List>
)}
</SearchResult>
</DialogContent>
<DialogActions className={classes.dialogActionsContainer}>
<Grid container direction="row">
<Grid item xs={12}>
<SearchResultPager />
</Grid>
</Grid>
</DialogActions>
</Dialog>
);
};
export const SearchModal = ({ open = true, toggleModal }: SearchModalProps) => {
return (
<SearchContextProvider>
<Modal open={open} toggleModal={toggleModal} />
</SearchContextProvider>
);
};
@@ -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 { SearchModal } from './SearchModal';
export type { SearchModalProps } from './SearchModal';
@@ -0,0 +1,36 @@
/*
* 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, { useState } from 'react';
import SearchIcon from '@material-ui/icons/Search';
import { SearchModal } from '../SearchModal';
import { SidebarItem } from '@backstage/core-components';
export const SidebarSearchModal = () => {
const [open, setOpen] = useState<boolean>(false);
const toggleModal = (): void => setOpen(prevState => !prevState);
return (
<>
<SidebarItem
className="search-icon"
icon={SearchIcon}
text="Search"
onClick={toggleModal}
/>
<SearchModal open={open} toggleModal={toggleModal} />
</>
);
};
@@ -0,0 +1,16 @@
/*
* 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 { SidebarSearchModal } from './SidebarSearchModal';
+2
View File
@@ -19,8 +19,10 @@ export * from './Filters';
export * from './SearchBar';
export * from './SearchContext';
export * from './SearchFilter';
export * from './SearchModal';
export * from './SearchPage';
export * from './SearchResult';
export * from './SearchResultPager';
export * from './SearchType';
export * from './SidebarSearch';
export * from './SidebarSearchModal';
+4 -1
View File
@@ -28,20 +28,23 @@ export {
SearchContextProvider,
SearchFilter,
SearchFilterNext,
SearchModal,
SearchPage as Router,
SearchResultPager,
SearchType,
SidebarSearch,
useSearch,
} from './components';
export type { SearchModalProps } from './components';
export type { FiltersState } from './components';
export {
DefaultResultListItem,
HomePageSearchBar,
SearchBarNext,
SearchPage,
SearchPageNext,
searchPlugin as plugin,
searchPlugin,
SearchResult,
HomePageSearchBar,
SidebarSearchModal,
} from './plugin';
+12
View File
@@ -122,6 +122,18 @@ export const SearchResultNext = searchPlugin.provide(
}),
);
export const SidebarSearchModal = searchPlugin.provide(
createComponentExtension({
name: 'SidebarSearchModal',
component: {
lazy: () =>
import('./components/SidebarSearchModal').then(
m => m.SidebarSearchModal,
),
},
}),
);
export const DefaultResultListItem = searchPlugin.provide(
createComponentExtension({
name: 'DefaultResultListItem',