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:
@@ -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.
|
||||
@@ -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)
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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) || '',
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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)
|
||||
//
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 };
|
||||
Reference in New Issue
Block a user