feat(SidebarSearchModal): support customizing content
Signed-off-by: Phil Kuang <pkuang@factset.com>
This commit is contained in:
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'@backstage/plugin-search': patch
|
||||
---
|
||||
|
||||
Support customizing the content of the `SidebarSearchModal`
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -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}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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';
|
||||
|
||||
Reference in New Issue
Block a user