feat(SidebarSearchModal): support customizing content

Signed-off-by: Phil Kuang <pkuang@factset.com>
This commit is contained in:
Phil Kuang
2022-03-23 15:26:08 -04:00
parent db21d78179
commit 5c062f275e
11 changed files with 407 additions and 8 deletions
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/plugin-search': patch
---
Support customizing the content of the `SidebarSearchModal`
+4 -1
View File
@@ -49,6 +49,7 @@ import {
} from '@backstage/core-components';
import { MyGroupsSidebarItem } from '@backstage/plugin-org';
import GroupIcon from '@material-ui/icons/People';
import { SearchModal } from '../search/SearchModal';
const useSidebarLogoStyles = makeStyles({
root: {
@@ -88,7 +89,9 @@ export const Root = ({ children }: PropsWithChildren<{}>) => (
<Sidebar>
<SidebarLogo />
<SidebarGroup label="Search" icon={<SearchIcon />} to="/search">
<SidebarSearchModal />
<SidebarSearchModal>
{({ toggleModal }) => <SearchModal toggleModal={toggleModal} />}
</SidebarSearchModal>
</SidebarGroup>
<SidebarDivider />
<SidebarGroup label="Menu" icon={<MenuIcon />}>
@@ -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 from 'react';
import {
DialogActions,
DialogContent,
DialogTitle,
Grid,
List,
makeStyles,
Paper,
useTheme,
} from '@material-ui/core';
import LaunchIcon from '@material-ui/icons/Launch';
import { Link, useContent } from '@backstage/core-components';
import { useApi, useRouteRef } from '@backstage/core-plugin-api';
import { CatalogSearchResultListItem } from '@backstage/plugin-catalog';
import {
catalogApiRef,
CATALOG_FILTER_EXISTS,
} from '@backstage/plugin-catalog-react';
import {
DefaultResultListItem,
SearchBar,
SearchFilter,
searchPlugin,
SearchResult,
SearchResultPager,
SearchType,
useSearch,
} from '@backstage/plugin-search';
import { TechDocsSearchResultListItem } from '@backstage/plugin-techdocs';
const useStyles = makeStyles(theme => ({
container: {
borderRadius: 30,
display: 'flex',
height: '2.4em',
},
filter: {
'& + &': {
marginTop: theme.spacing(2.5),
},
},
filters: {
padding: theme.spacing(2),
marginTop: theme.spacing(2),
},
input: {
flex: 1,
},
dialogActionsContainer: { padding: theme.spacing(1, 3) },
viewResultsLink: { verticalAlign: '0.5em' },
}));
export const SearchModal = ({ toggleModal }: { toggleModal: () => void }) => {
const getSearchLink = useRouteRef(searchPlugin.routes.root);
const classes = useStyles();
const catalogApi = useApi(catalogApiRef);
const { term, types } = useSearch();
const { focusContent } = useContent();
const { transitions } = useTheme();
const handleResultClick = () => {
toggleModal();
setTimeout(focusContent, transitions.duration.leavingScreen);
};
const handleKeyPress = () => {
handleResultClick();
};
return (
<>
<DialogTitle>
<Paper className={classes.container}>
<SearchBar className={classes.input} />
</Paper>
</DialogTitle>
<DialogContent>
<Grid container direction="column">
<Grid item>
<SearchType.Tabs
defaultValue="software-catalog"
types={[
{
value: 'software-catalog',
name: 'Software Catalog',
},
{
value: 'techdocs',
name: 'Documentation',
},
]}
/>
</Grid>
<Grid item container>
{types.includes('techdocs') && (
<Grid item xs={2}>
<SearchFilter.Select
className={classes.filter}
label="Entity"
name="name"
values={async () => {
// Return a list of entities which are documented.
const { items } = await catalogApi.getEntities({
fields: ['metadata.name'],
filter: {
'metadata.annotations.backstage.io/techdocs-ref':
CATALOG_FILTER_EXISTS,
},
});
const names = items.map(entity => entity.metadata.name);
names.sort();
return names;
}}
/>
</Grid>
)}
<Grid item xs={2}>
<SearchFilter.Select
className={classes.filter}
label="Kind"
name="kind"
values={['Component', 'Template']}
/>
</Grid>
<Grid item xs={2}>
<SearchFilter.Select
className={classes.filter}
label="Lifecycle"
name="lifecycle"
values={['experimental', 'production']}
/>
</Grid>
<Grid
item
xs={types.includes('techdocs') ? 6 : 8}
container
direction="row-reverse"
justifyContent="flex-start"
alignItems="center"
>
<Grid item>
<Link
onClick={() => {
toggleModal();
setTimeout(
focusContent,
transitions.duration.leavingScreen,
);
}}
to={`${getSearchLink()}?query=${term}`}
>
<span className={classes.viewResultsLink}>
View Full Results
</span>
<LaunchIcon color="primary" />
</Link>
</Grid>
</Grid>
</Grid>
<Grid item xs>
<SearchResult>
{({ results }) => (
<List>
{results.map(({ type, document }) => {
let resultItem;
switch (type) {
case 'software-catalog':
resultItem = (
<CatalogSearchResultListItem
key={document.location}
result={document}
/>
);
break;
case 'techdocs':
resultItem = (
<TechDocsSearchResultListItem
key={document.location}
result={document}
/>
);
break;
default:
resultItem = (
<DefaultResultListItem
key={document.location}
result={document}
/>
);
}
return (
<div
role="button"
tabIndex={0}
key={`${document.location}-btn`}
onClick={handleResultClick}
onKeyPress={handleKeyPress}
>
{resultItem}
</div>
);
})}
</List>
)}
</SearchResult>
</Grid>
</Grid>
</DialogContent>
<DialogActions className={classes.dialogActionsContainer}>
<Grid container direction="row">
<Grid item xs={12}>
<SearchResultPager />
</Grid>
</Grid>
</DialogActions>
</>
);
};
+8
View File
@@ -211,12 +211,19 @@ export const SearchModal: ({
open,
hidden,
toggleModal,
children,
}: SearchModalProps) => JSX.Element;
// @public (undocumented)
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;
hidden?: boolean;
open?: boolean;
toggleModal: () => void;
@@ -313,6 +320,7 @@ export const SidebarSearchModal: (
// @public (undocumented)
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)
@@ -15,11 +15,25 @@
*/
import { wrapInTestApp } from '@backstage/test-utils';
import { Button } from '@material-ui/core';
import {
Button,
DialogActions,
DialogContent,
DialogTitle,
Grid,
List,
Paper,
} from '@material-ui/core';
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 { SearchApiProvider } from '../SearchContext/SearchContextForStorybook.stories';
import { SearchModal } from './SearchModal';
import { SearchResult } from '../SearchResult';
import { SearchResultPager } from '../SearchResultPager';
import { SearchType } from '../SearchType';
import { useSearchModal } from './useSearchModal';
const mockResults = {
@@ -77,3 +91,87 @@ export const Default = () => {
</>
);
};
const useStyles = makeStyles(theme => ({
container: {
borderRadius: 30,
display: 'flex',
height: '2.4em',
},
input: {
flex: 1,
},
dialogActionsContainer: { padding: theme.spacing(1, 3) },
}));
export const CustomModal = () => {
const classes = useStyles();
const { state, toggleModal } = useSearchModal();
return (
<>
<Button variant="contained" color="primary" onClick={toggleModal}>
Toggle Custom Search Modal
</Button>
<SearchModal {...state} toggleModal={toggleModal}>
{() => (
<>
<DialogTitle>
<Paper className={classes.container}>
<SearchBar className={classes.input} />
</Paper>
</DialogTitle>
<DialogContent>
<Grid container direction="column">
<Grid item>
<SearchType.Tabs
defaultValue=""
types={[
{
value: 'custom-result-item',
name: 'Custom Item',
},
{
value: 'no-custom-result-item',
name: 'No Custom Item',
},
]}
/>
</Grid>
<Grid item>
<SearchResult>
{({ results }) => (
<List>
{results.map(({ document }) => (
<div
role="button"
tabIndex={0}
key={`${document.location}-btn`}
onClick={toggleModal}
onKeyPress={toggleModal}
>
<DefaultResultListItem
key={document.location}
result={document}
/>
</div>
))}
</List>
)}
</SearchResult>
</Grid>
</Grid>
</DialogContent>
<DialogActions className={classes.dialogActionsContainer}>
<Grid container direction="row">
<Grid item xs={12}>
<SearchResultPager />
</Grid>
</Grid>
</DialogActions>
</>
)}
</SearchModal>
</>
);
};
@@ -55,6 +55,23 @@ describe('SearchModal', () => {
expect(query).toHaveBeenCalledTimes(1);
});
it('Should render a custom Modal correctly', async () => {
await renderInTestApp(
<ApiProvider apis={apiRegistry}>
<SearchModal open hidden={false} toggleModal={toggleModal}>
{() => <div>Custom Search Modal</div>}
</SearchModal>
</ApiProvider>,
{
mountedRoutes: {
'/search': rootRouteRef,
},
},
);
expect(screen.getByText('Custom Search Modal')).toBeInTheDocument();
});
it('Calls toggleModal handler', async () => {
await renderInTestApp(
<ApiProvider apis={apiRegistry}>
@@ -37,6 +37,16 @@ import { useRouteRef } from '@backstage/core-plugin-api';
import { Link, useContent } from '@backstage/core-components';
import { rootRouteRef } from '../../plugin';
/**
* @public
**/
export interface SearchModalChildrenProps {
/**
* A function that should be invoked when navigating away from the modal.
*/
toggleModal: () => void;
}
export interface SearchModalProps {
/**
* If true, it renders the modal.
@@ -54,6 +64,11 @@ export interface SearchModalProps {
* should be closed.
*/
toggleModal: () => void;
/**
* A function that returns custom content to render in the search modal in
* place of the default.
*/
children?: (props: SearchModalChildrenProps) => JSX.Element;
}
const useStyles = makeStyles(theme => ({
@@ -152,6 +167,7 @@ export const SearchModal = ({
open = true,
hidden,
toggleModal,
children,
}: SearchModalProps) => {
const classes = useStyles();
@@ -169,7 +185,9 @@ export const SearchModal = ({
>
{open && (
<SearchContextProvider>
<Modal toggleModal={toggleModal} />
{(children && children({ toggleModal })) ?? (
<Modal toggleModal={toggleModal} />
)}
</SearchContextProvider>
)}
</Dialog>
@@ -14,5 +14,5 @@
* limitations under the License.
*/
export { SearchModal } from './SearchModal';
export type { SearchModalProps } from './SearchModal';
export type { SearchModalChildrenProps, SearchModalProps } from './SearchModal';
export { useSearchModal } from './useSearchModal';
@@ -77,8 +77,9 @@ export const SearchTypeTabs = (props: SearchTypeTabsProps) => {
value={types.length === 0 ? '' : types[0]}
onChange={changeTab}
>
{definedTypes.map(type => (
{definedTypes.map((type, idx) => (
<Tab
key={idx}
className={classes.tab}
disableRipple
label={type.name}
@@ -17,10 +17,15 @@ import React from 'react';
import SearchIcon from '@material-ui/icons/Search';
import { SidebarItem } from '@backstage/core-components';
import { IconComponent } from '@backstage/core-plugin-api';
import { SearchModal, useSearchModal } from '../SearchModal';
import {
SearchModal,
SearchModalChildrenProps,
useSearchModal,
} from '../SearchModal';
export type SidebarSearchModalProps = {
icon?: IconComponent;
children?: (props: SearchModalChildrenProps) => JSX.Element;
};
export const SidebarSearchModal = (props: SidebarSearchModalProps) => {
@@ -35,7 +40,11 @@ export const SidebarSearchModal = (props: SidebarSearchModalProps) => {
text="Search"
onClick={toggleModal}
/>
<SearchModal {...state} toggleModal={toggleModal} />
<SearchModal
{...state}
toggleModal={toggleModal}
children={props.children}
/>
</>
);
};
+4 -1
View File
@@ -40,7 +40,10 @@ export type {
SearchFilterWrapperProps,
} from './components/SearchFilter';
export { SearchModal, useSearchModal } from './components/SearchModal';
export type { SearchModalProps } from './components/SearchModal';
export type {
SearchModalChildrenProps,
SearchModalProps,
} from './components/SearchModal';
export { SearchPage as Router } from './components/SearchPage';
export { SearchResultPager } from './components/SearchResultPager';
export { SearchType } from './components/SearchType';