feat(search): create search modal extension

Signed-off-by: Camila Belo <camilaibs@gmail.com>
This commit is contained in:
Camila Belo
2023-09-27 09:43:12 +02:00
parent d0fb8e1481
commit 99f557f256
+257 -7
View File
@@ -14,10 +14,24 @@
* limitations under the License.
*/
import React from 'react';
import React, { useCallback, useEffect, useRef } from 'react';
import { useNavigate } from 'react-router-dom';
import { Grid, makeStyles, Paper, Theme } from '@material-ui/core';
import {
makeStyles,
Theme,
Grid,
Box,
Paper,
DialogActions,
DialogContent,
DialogTitle,
Button,
IconButton,
} from '@material-ui/core';
import CloseIcon from '@material-ui/icons/Close';
import SearchIcon from '@material-ui/icons/Search';
import ArrowForwardIcon from '@material-ui/icons/ArrowForward';
import {
CatalogIcon,
@@ -41,6 +55,8 @@ import {
createPageExtension,
createExtensionInput,
createNavItemExtension,
createExtensionDataRef,
createExtension,
createSchemaFromZod,
} from '@backstage/frontend-plugin-api';
@@ -49,7 +65,6 @@ import {
CATALOG_FILTER_EXISTS,
} from '@backstage/plugin-catalog-react';
import { SearchType } from '@backstage/plugin-search';
import {
DefaultResultListItem,
SearchBar,
@@ -64,9 +79,11 @@ import { SearchResult } from '@backstage/plugin-search-common';
import { searchApiRef } from '@backstage/plugin-search-react';
import { searchResultItemExtensionData } from '@backstage/plugin-search-react/alpha';
import { UrlUpdater } from './components/SearchPage/SearchPage';
import { SearchClient } from './apis';
import { SearchType } from './components/SearchType';
import { UrlUpdater } from './components/SearchPage/SearchPage';
import { SidebarSearchModal } from './components/SidebarSearchModal/SidebarSearchModal';
import { useSearchModal } from './components/SearchModal';
/** @alpha */
export const SearchApi = createApiExtension({
@@ -83,7 +100,7 @@ export const SearchApi = createApiExtension({
},
});
const useStyles = makeStyles((theme: Theme) => ({
const useSearchPageStyles = makeStyles((theme: Theme) => ({
filter: {
'& + &': {
marginTop: theme.spacing(2.5),
@@ -119,7 +136,7 @@ export const SearchPage = createPageExtension({
};
const Component = () => {
const classes = useStyles();
const classes = useSearchPageStyles();
const { isMobile } = useSidebarPinState();
const { types } = useSearch();
const catalogApi = useApi(catalogApiRef);
@@ -230,10 +247,243 @@ export const SearchPage = createPageExtension({
},
});
const searchModalExtensionData = createExtensionDataRef<JSX.Element>(
'plugin.search.modal.element',
);
const useSearchModalStyles = makeStyles(theme => ({
dialogTitle: {
gap: theme.spacing(1),
display: 'grid',
alignItems: 'center',
gridTemplateColumns: '1fr auto',
'&> button': {
marginTop: theme.spacing(1),
},
},
container: {
borderRadius: 30,
display: 'flex',
height: '2.4em',
padding: theme.spacing(1),
},
filter: {
'& + &': {
marginTop: theme.spacing(2.5),
},
},
filters: {
padding: theme.spacing(2),
marginTop: theme.spacing(2),
},
input: {
flex: 1,
},
button: {
'&:hover': {
background: 'none',
},
},
dialogActionsContainer: { padding: theme.spacing(1, 3) },
viewResultsLink: { verticalAlign: '0.5em' },
}));
/** @alpha */
export const SearchModal = createExtension({
id: 'plugin.search.modal',
at: 'plugin.search.nav.index/modal',
configSchema: createSchemaFromZod(z =>
z.object({
noTrack: z.boolean().default(false),
}),
),
inputs: {
items: createExtensionInput({
item: searchResultItemExtensionData,
}),
},
output: {
element: searchModalExtensionData,
},
factory({ bind, config, inputs }) {
const getResultItemComponent = (result: SearchResult) => {
const value = inputs.items.find(({ item }) => item?.predicate?.(result));
return value?.item.component ?? DefaultResultListItem;
};
const Component = () => {
const classes = useSearchModalStyles();
const navigate = useNavigate();
const catalogApi = useApi(catalogApiRef);
const { types } = useSearch();
const { toggleModal } = useSearchModal();
const searchRootRoute = '/search';
const searchBarRef = useRef<HTMLInputElement | null>(null);
useEffect(() => {
searchBarRef?.current?.focus();
});
// This handler is called when "enter" is pressed
const handleSearchBarSubmit = useCallback(() => {
toggleModal();
// Using ref to get the current field value without waiting for a query debounce
const query = searchBarRef.current?.value ?? '';
navigate(`${searchRootRoute}?query=${query}`);
}, [navigate, toggleModal, searchRootRoute]);
return (
<>
<DialogTitle>
<Box className={classes.dialogTitle}>
<SearchBar
className={classes.input}
inputProps={{ ref: searchBarRef }}
onSubmit={handleSearchBarSubmit}
/>
<IconButton aria-label="close" onClick={toggleModal}>
<CloseIcon />
</IconButton>
</Box>
</DialogTitle>
<DialogContent>
<Grid container direction="column">
<Grid item>
<SearchType.Tabs
defaultValue="software-catalog"
types={[
{
value: 'software-catalog',
name: 'Software Catalog',
},
{
value: 'techdocs',
name: 'Documentation',
},
{
value: 'tools',
name: 'Tools',
},
]}
/>
</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>
<Button
className={classes.button}
color="primary"
endIcon={<ArrowForwardIcon />}
onClick={handleSearchBarSubmit}
disableRipple
>
View Full Results
</Button>
</Grid>
</Grid>
</Grid>
<Grid item xs>
<SearchResults>
{({ results }) =>
results.map((result, index) => {
const SearchResultListItem =
getResultItemComponent(result);
return (
<SearchResultListItem
key={index}
rank={result.rank}
result={result.document}
noTrack={config.noTrack}
/>
);
})
}
</SearchResults>
</Grid>
</Grid>
</DialogContent>
<DialogActions className={classes.dialogActionsContainer}>
<Grid container direction="row">
<Grid item xs={12}>
<SearchResultPager />
</Grid>
</Grid>
</DialogActions>
</>
);
};
bind({
element: <Component />,
});
},
});
/** @alpha */
export const SearchNavItem = createNavItemExtension({
id: 'plugin.search.nav.index',
routeRef: searchRouteRef,
title: 'Search',
icon: SearchIcon,
group: async ({ inputs }) => (
<SidebarSearchModal>{() => inputs.modal.element}</SidebarSearchModal>
),
inputs: {
modal: createExtensionInput(
{
element: searchModalExtensionData,
},
{
singleton: true,
},
),
},
});