Implement in-context search bar for tech docs. (#6651)

* Implement in-context search bar for tech docs

* Add in-context search functionality to tech docs
* Use the existing backend search functionality with tech docs specific filters
* Use material-ui autocomplete to display 10 first search results
* Add tests

Signed-off-by: Jussi Hallila <jussi@hallila.com>

* Remove context and replace it with `withSearch` prop.

Signed-off-by: Jussi Hallila <jussi@hallila.com>

* Bump search-common devp to latest

Signed-off-by: Jussi Hallila <jussi@hallila.com>

* Rename types file to not conflict with hardcoded types.d.ts.

Signed-off-by: Jussi Hallila <jussi@hallila.com>
This commit is contained in:
Jussi Hallila
2021-08-27 12:21:48 +02:00
committed by GitHub
parent c5cdf03063
commit 30ed662a35
18 changed files with 468 additions and 50 deletions
+9
View File
@@ -0,0 +1,9 @@
---
'@backstage/plugin-techdocs': patch
'@backstage/plugin-techdocs-backend': patch
'@backstage/techdocs-common': patch
---
Adding in-context search to TechDocs Reader component. Using existing search-backend to query for indexed search results scoped into a specific entity's techdocs. Needs TechDocsCollator enabled on the backend to work.
Adding extra information to indexed tech docs documents for search.
+19
View File
@@ -10,6 +10,7 @@ import { ContainerRunner } from '@backstage/backend-common';
import { Entity } from '@backstage/catalog-model';
import { EntityName } from '@backstage/catalog-model';
import express from 'express';
import { IndexableDocument } from '@backstage/search-common';
import { Logger as Logger_2 } from 'winston';
import { PluginEndpointDiscovery } from '@backstage/backend-common';
import { ScmIntegrationRegistry } from '@backstage/integration';
@@ -222,6 +223,24 @@ export type PublisherType =
// @public (undocumented)
export type RemoteProtocol = 'url' | 'dir';
// Warning: (ae-missing-release-tag) "TechDocsDocument" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal)
//
// @public (undocumented)
export interface TechDocsDocument extends IndexableDocument {
// (undocumented)
kind: string;
// (undocumented)
lifecycle: string;
// (undocumented)
name: string;
// (undocumented)
namespace: string;
// (undocumented)
owner: string;
// (undocumented)
path: string;
}
// Warning: (ae-missing-release-tag) "TechdocsGenerator" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal)
//
// @public (undocumented)
+1
View File
@@ -42,6 +42,7 @@
"@backstage/catalog-model": "^0.9.0",
"@backstage/config": "^0.1.8",
"@backstage/errors": "^0.1.1",
"@backstage/search-common": "^0.1.3",
"@backstage/integration": "^0.6.2",
"@google-cloud/storage": "^5.6.0",
"@trendyol-js/openstack-swift-sdk": "^0.0.4",
+1
View File
@@ -15,3 +15,4 @@
*/
export * from './stages';
export * from './helpers';
export * from './techdocsTypes';
@@ -0,0 +1,26 @@
/*
* 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 { IndexableDocument } from '@backstage/search-common';
export interface TechDocsDocument extends IndexableDocument {
kind: string;
namespace: string;
name: string;
lifecycle: string;
owner: string;
path: string;
}
+2 -16
View File
@@ -8,12 +8,12 @@ import { Config } from '@backstage/config';
import { DocumentCollator } from '@backstage/search-common';
import express from 'express';
import { GeneratorBuilder } from '@backstage/techdocs-common';
import { IndexableDocument } from '@backstage/search-common';
import { Knex } from 'knex';
import { Logger as Logger_2 } from 'winston';
import { PluginEndpointDiscovery } from '@backstage/backend-common';
import { PreparerBuilder } from '@backstage/techdocs-common';
import { PublisherBase } from '@backstage/techdocs-common';
import { TechDocsDocument } from '@backstage/techdocs-common';
// Warning: (ae-forgotten-export) The symbol "RouterOptions" needs to be exported by the entry point index.d.ts
// Warning: (ae-missing-release-tag) "createRouter" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal)
@@ -53,21 +53,7 @@ export class DefaultTechDocsCollator implements DocumentCollator {
readonly type: string;
}
// Warning: (ae-missing-release-tag) "TechDocsDocument" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal)
//
// @public (undocumented)
export interface TechDocsDocument extends IndexableDocument {
// (undocumented)
kind: string;
// (undocumented)
lifecycle: string;
// (undocumented)
name: string;
// (undocumented)
namespace: string;
// (undocumented)
owner: string;
}
export { TechDocsDocument };
export * from '@backstage/techdocs-common';
@@ -16,12 +16,13 @@
import { PluginEndpointDiscovery } from '@backstage/backend-common';
import { Entity, RELATION_OWNED_BY } from '@backstage/catalog-model';
import { IndexableDocument, DocumentCollator } from '@backstage/search-common';
import { DocumentCollator } from '@backstage/search-common';
import fetch from 'cross-fetch';
import unescape from 'lodash/unescape';
import { Logger } from 'winston';
import pLimit from 'p-limit';
import { CatalogApi, CatalogClient } from '@backstage/catalog-client';
import { TechDocsDocument } from '@backstage/techdocs-common';
interface MkSearchIndexDoc {
title: string;
@@ -29,14 +30,6 @@ interface MkSearchIndexDoc {
location: string;
}
export interface TechDocsDocument extends IndexableDocument {
kind: string;
namespace: string;
name: string;
lifecycle: string;
owner: string;
}
export class DefaultTechDocsCollator implements DocumentCollator {
protected discovery: PluginEndpointDiscovery;
protected locationTemplate: string;
@@ -108,6 +101,7 @@ export class DefaultTechDocsCollator implements DocumentCollator {
...entityInfo,
path: doc.location,
}),
path: doc.location,
...entityInfo,
componentType: entity.spec?.type?.toString() || 'other',
lifecycle: (entity.spec?.lifecycle as string) || '',
+5 -1
View File
@@ -14,4 +14,8 @@
* limitations under the License.
*/
export { DefaultTechDocsCollator } from './DefaultTechDocsCollator';
export type { TechDocsDocument } from './DefaultTechDocsCollator';
/**
* @deprecated Use directly from @backstage/techdocs-common
*/
export type { TechDocsDocument } from '@backstage/techdocs-common';
+11 -1
View File
@@ -90,9 +90,15 @@ export const DocsCardGrid: ({
export const DocsResultListItem: ({
result,
lineClamp,
asListItem,
asLink,
title,
}: {
result: any;
lineClamp?: number | undefined;
asListItem?: boolean | undefined;
asLink?: boolean | undefined;
title?: string | undefined;
}) => JSX.Element;
// Warning: (ae-missing-release-tag) "DocsTable" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal)
@@ -170,7 +176,11 @@ export type PanelType = 'DocsCardGrid' | 'DocsTable';
// Warning: (ae-missing-release-tag) "Reader" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal)
//
// @public (undocumented)
export const Reader: ({ entityId, onReady }: Props_3) => JSX.Element;
export const Reader: ({
entityId,
onReady,
withSearch,
}: Props_3) => JSX.Element;
// Warning: (ae-missing-release-tag) "Router" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal)
//
+1
View File
@@ -36,6 +36,7 @@
"@backstage/core-components": "^0.3.3",
"@backstage/core-plugin-api": "^0.1.6",
"@backstage/errors": "^0.1.1",
"@backstage/plugin-search": "^0.4.5",
"@backstage/integration": "^0.6.2",
"@backstage/integration-react": "^0.1.7",
"@backstage/plugin-catalog": "^0.6.13",
+1
View File
@@ -21,6 +21,7 @@ import { Reader } from './reader';
export const EntityPageDocs = ({ entity }: { entity: Entity }) => {
return (
<Reader
withSearch={false}
entityId={{
kind: entity.kind,
namespace: entity.metadata.namespace ?? 'default',
@@ -46,4 +46,17 @@ describe('DocsResultListItem test', () => {
),
).toBeInTheDocument();
});
it('should use title if defined', async () => {
const { findByText } = render(
<DocsResultListItem result={validResult} title="Count Dookumentation" />,
);
expect(await findByText('Count Dookumentation')).toBeInTheDocument();
expect(
await findByText(
'Backstage is an open-source developer portal that puts the developer experience first.',
),
).toBeInTheDocument();
});
});
@@ -14,7 +14,7 @@
* limitations under the License.
*/
import React from 'react';
import React, { PropsWithChildren } from 'react';
import { Divider, ListItem, ListItemText, makeStyles } from '@material-ui/core';
import { Link } from '@backstage/core-components';
import TextTruncate from 'react-text-truncate';
@@ -32,29 +32,53 @@ const useStyles = makeStyles({
export const DocsResultListItem = ({
result,
lineClamp = 5,
asListItem = true,
asLink = true,
title,
}: {
result: any;
lineClamp?: number;
asListItem?: boolean;
asLink?: boolean;
title?: string;
}) => {
const classes = useStyles();
return (
<Link to={result.location}>
<ListItem alignItems="flex-start" className={classes.flexContainer}>
<ListItemText
className={classes.itemText}
primaryTypographyProps={{ variant: 'h6' }}
primary={`${result.title} | ${result.name} docs `}
secondary={
<TextTruncate
line={lineClamp}
truncateText=""
text={result.text}
element="span"
/>
}
const TextItem = () => (
<ListItemText
className={classes.itemText}
primaryTypographyProps={{ variant: 'h6' }}
primary={title ? title : `${result.title} | ${result.name} docs`}
secondary={
<TextTruncate
line={lineClamp}
truncateText="…"
text={result.text}
element="span"
/>
</ListItem>
<Divider component="li" />
</Link>
}
/>
);
const LinkWrapper = ({ children }: PropsWithChildren<{}>) =>
asLink ? <Link to={result.location}>{children}</Link> : <>{children}</>;
const ListItemWrapper = ({ children }: PropsWithChildren<{}>) =>
asListItem ? (
<>
<ListItem alignItems="flex-start" className={classes.flexContainer}>
{children}
</ListItem>
<Divider component="li" />
</>
) : (
<>{children}</>
);
return (
<LinkWrapper>
<ListItemWrapper>
<TextItem />
</ListItemWrapper>
</LinkWrapper>
);
};
@@ -25,6 +25,7 @@ import React from 'react';
import { TechDocsStorageApi, techdocsStorageApiRef } from '../../api';
import { Reader } from './Reader';
import { ApiProvider, ApiRegistry } from '@backstage/core-app-api';
import { searchApiRef } from '@backstage/plugin-search';
jest.mock('react-router-dom', () => {
const actual = jest.requireActual('react-router-dom');
@@ -50,10 +51,16 @@ describe('<Reader />', () => {
}),
);
const techdocsStorageApi: Partial<TechDocsStorageApi> = {};
const searchApi = {
query: () =>
Promise.resolve({
results: [],
}),
};
const apiRegistry = ApiRegistry.from([
[scmIntegrationsApiRef, scmIntegrationsApi],
[techdocsStorageApiRef, techdocsStorageApi],
[searchApiRef, searchApi],
]);
await act(async () => {
@@ -22,6 +22,7 @@ import { BackstageTheme } from '@backstage/theme';
import {
Button,
CircularProgress,
Grid,
makeStyles,
useTheme,
} from '@material-ui/core';
@@ -43,23 +44,30 @@ import {
} from '../transformers';
import { TechDocsBuildLogs } from './TechDocsBuildLogs';
import { TechDocsNotFound } from './TechDocsNotFound';
import { TechDocsSearch } from './TechDocsSearch';
import { useReaderState } from './useReaderState';
type Props = {
entityId: EntityName;
onReady?: () => void;
withSearch?: boolean;
};
const useStyles = makeStyles<BackstageTheme>(() => ({
const useStyles = makeStyles<BackstageTheme>(theme => ({
message: {
// `word-break: break-word` is deprecated, but gives legacy support to browsers not supporting `overflow-wrap` yet
// https://developer.mozilla.org/en-US/docs/Web/CSS/word-break
wordBreak: 'break-word',
overflowWrap: 'anywhere',
},
searchBar: {
marginLeft: '20rem',
maxWidth: 'calc(100% - 20rem * 2 - 3rem)',
marginTop: theme.spacing(1),
},
}));
export const Reader = ({ entityId, onReady }: Props) => {
export const Reader = ({ entityId, onReady, withSearch = true }: Props) => {
const { kind, namespace, name } = entityId;
const theme = useTheme<BackstageTheme>();
const classes = useStyles();
@@ -433,6 +441,12 @@ export const Reader = ({ entityId, onReady }: Props) => {
<TechDocsNotFound errorMessage={contentErrorMessage} />
</>
)}
{withSearch && shadowDomRef?.current?.shadowRoot?.innerHTML && (
<Grid container className={classes.searchBar}>
<TechDocsSearch entityId={entityId} />
</Grid>
)}
<div data-testid="techdocs-content-shadowroot" ref={shadowDomRef} />
</>
);
@@ -29,6 +29,7 @@ import {
TechDocsStorageApi,
} from '../../api';
import { ApiRegistry, ApiProvider } from '@backstage/core-app-api';
import { searchApiRef } from '@backstage/plugin-search';
jest.mock('react-router-dom', () => {
const actual = jest.requireActual('react-router-dom');
@@ -47,6 +48,7 @@ jest.mock('./TechDocsPageHeader', () => {
const { useParams }: { useParams: jest.Mock } =
jest.requireMock('react-router-dom');
global.scroll = jest.fn();
describe('<TechDocsPage />', () => {
it('should render techdocs page', async () => {
@@ -75,16 +77,23 @@ describe('<TechDocsPage />', () => {
site_description: 'string',
}),
};
const techdocsStorageApi: Partial<TechDocsStorageApi> = {
getEntityDocs: (): Promise<string> => Promise.resolve('String'),
getBaseUrl: (): Promise<string> => Promise.resolve('String'),
getApiOrigin: (): Promise<string> => Promise.resolve('String'),
};
const searchApi = {
query: () =>
Promise.resolve({
results: [],
}),
};
const apiRegistry = ApiRegistry.from([
[scmIntegrationsApiRef, scmIntegrationsApi],
[techdocsApiRef, techdocsApi],
[techdocsStorageApiRef, techdocsStorageApi],
[searchApiRef, searchApi],
]);
await act(async () => {
@@ -0,0 +1,122 @@
/*
* Copyright 2020 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 { TechDocsSearch } from './TechDocsSearch';
import {
act,
fireEvent,
render,
waitFor,
within,
} from '@testing-library/react';
import { wrapInTestApp } from '@backstage/test-utils';
import { ApiProvider, ApiRegistry } from '@backstage/core-app-api';
import { searchApiRef } from '@backstage/plugin-search';
const entityId = {
name: 'test',
namespace: 'testspace',
kind: 'Testable',
};
const emptyResults = Promise.resolve({
results: [],
});
const singleResult = Promise.resolve({
results: [
{
type: 'testsearchresult',
document: {
text: 'text',
title: 'title',
location: '/something/something',
},
},
],
});
describe('<TechDocsPage />', () => {
it('should render techdocs search bar', async () => {
const query = () => emptyResults;
const querySpy = jest.fn(query);
const searchApi = { query: querySpy };
const apiRegistry = ApiRegistry.from([[searchApiRef, searchApi]]);
await act(async () => {
const rendered = render(
wrapInTestApp(
<ApiProvider apis={apiRegistry}>
<TechDocsSearch entityId={entityId} />
</ApiProvider>,
),
);
await emptyResults;
expect(querySpy).toBeCalled();
expect(rendered.getByTestId('techdocs-search-bar')).toBeInTheDocument();
});
});
it('should trigger query when autocomplete input changed', async () => {
const query = () => singleResult;
const querySpy = jest.fn(query);
const searchApi = { query: querySpy };
const apiRegistry = ApiRegistry.from([[searchApiRef, searchApi]]);
await act(async () => {
const rendered = render(
wrapInTestApp(
<ApiProvider apis={apiRegistry}>
<TechDocsSearch entityId={entityId} debounceTime={0} />
</ApiProvider>,
),
);
await singleResult;
expect(querySpy).toBeCalledWith({
filters: {
kind: 'Testable',
name: 'test',
namespace: 'testspace',
},
pageCursor: '',
term: '',
types: ['techdocs'],
});
const autocomplete = rendered.getByTestId('techdocs-search-bar');
const input = within(autocomplete).getByRole('textbox');
autocomplete.click();
autocomplete.focus();
fireEvent.change(input, { target: { value: 'asdf' } });
await singleResult;
await waitFor(() =>
expect(querySpy).toBeCalledWith({
filters: {
kind: 'Testable',
name: 'test',
namespace: 'testspace',
},
pageCursor: '',
term: 'asdf',
types: ['techdocs'],
}),
);
});
});
});
@@ -0,0 +1,177 @@
/*
* 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, { ChangeEvent, useEffect, useState } from 'react';
import {
Grid,
IconButton,
InputAdornment,
TextField,
CircularProgress,
} from '@material-ui/core';
import Autocomplete from '@material-ui/lab/Autocomplete';
import { SearchContextProvider, useSearch } from '@backstage/plugin-search';
import { DocsResultListItem } from '../../components/DocsResultListItem';
import SearchIcon from '@material-ui/icons/Search';
import { useDebounce } from 'react-use';
import { useNavigate } from 'react-router';
type TechDocsSearchProps = {
entityId: {
name: string;
namespace: string;
kind: string;
};
debounceTime?: number;
};
type TechDocsDoc = {
namespace: string;
kind: string;
name: string;
path: string;
location: string;
title: string;
};
type TechDocsSearchResult = {
type: string;
document: TechDocsDoc;
};
const TechDocsSearchBar = ({
entityId,
debounceTime = 150,
}: TechDocsSearchProps) => {
const [open, setOpen] = useState(false);
const navigate = useNavigate();
const {
term,
setTerm,
result: { loading, value: searchVal },
} = useSearch();
const [options, setOptions] = useState<any[]>([]);
useEffect(() => {
let mounted = true;
if (mounted && searchVal) {
// TODO: Change this into getting only subset of search results from the BE in the first place
// once pagination is implemented for search engines
// See: https://github.com/backstage/backstage/issues/6062
const searchResults = searchVal.results.slice(0, 10);
setOptions(searchResults);
}
return () => {
mounted = false;
};
}, [loading, searchVal]);
const [value, setValue] = useState<string>(term);
useDebounce(() => setTerm(value), debounceTime, [value]);
const handleQuery = (e: ChangeEvent<HTMLInputElement>) => {
if (!open) {
setOpen(true);
}
setValue(e.target.value);
};
const handleSelection = (_: any, selection: TechDocsSearchResult | null) => {
if (selection?.document) {
const { location } = selection.document;
navigate(location);
}
};
return (
<Grid item xs={12}>
<Autocomplete
data-testid="techdocs-search-bar"
size="small"
open={open}
getOptionLabel={() => ''}
filterOptions={x => {
return x; // This is needed to get renderOption to be called after options change. Bug in material-ui?
}}
onClose={() => {
setOpen(false);
}}
onFocus={() => {
setOpen(true);
}}
onChange={handleSelection}
blurOnSelect
noOptionsText="No results found"
value={null}
options={options}
renderOption={({ document }) => (
<DocsResultListItem
result={document}
lineClamp={3}
asListItem={false}
asLink={false}
title={document.title}
/>
)}
loading={loading}
renderInput={params => (
<TextField
{...params}
data-testid="techdocs-search-bar-input"
variant="outlined"
fullWidth
placeholder={`Search ${entityId.name} docs`}
value={value}
onChange={handleQuery}
InputProps={{
...params.InputProps,
startAdornment: (
<InputAdornment position="start">
<IconButton aria-label="Query term" disabled>
<SearchIcon />
</IconButton>
</InputAdornment>
),
endAdornment: (
<React.Fragment>
{loading ? (
<CircularProgress color="inherit" size={20} />
) : null}
{params.InputProps.endAdornment}
</React.Fragment>
),
}}
/>
)}
/>
</Grid>
);
};
const TechDocsSearch = (props: TechDocsSearchProps) => {
const initialState = {
term: '',
types: ['techdocs'],
pageCursor: '',
filters: props.entityId,
};
return (
<SearchContextProvider initialState={initialState}>
<TechDocsSearchBar {...props} />
</SearchContextProvider>
);
};
export { TechDocsSearch };