diff --git a/.changeset/forty-terms-join.md b/.changeset/forty-terms-join.md new file mode 100644 index 0000000000..1476ebff9c --- /dev/null +++ b/.changeset/forty-terms-join.md @@ -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. diff --git a/packages/techdocs-common/api-report.md b/packages/techdocs-common/api-report.md index 0c7e5b03c7..22613f71a2 100644 --- a/packages/techdocs-common/api-report.md +++ b/packages/techdocs-common/api-report.md @@ -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) diff --git a/packages/techdocs-common/package.json b/packages/techdocs-common/package.json index f529d93ca6..02a0622244 100644 --- a/packages/techdocs-common/package.json +++ b/packages/techdocs-common/package.json @@ -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", diff --git a/packages/techdocs-common/src/index.ts b/packages/techdocs-common/src/index.ts index a9a254a1ee..faf60440c0 100644 --- a/packages/techdocs-common/src/index.ts +++ b/packages/techdocs-common/src/index.ts @@ -15,3 +15,4 @@ */ export * from './stages'; export * from './helpers'; +export * from './techdocsTypes'; diff --git a/packages/techdocs-common/src/techdocsTypes.ts b/packages/techdocs-common/src/techdocsTypes.ts new file mode 100644 index 0000000000..4aa3972b31 --- /dev/null +++ b/packages/techdocs-common/src/techdocsTypes.ts @@ -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; +} diff --git a/plugins/techdocs-backend/api-report.md b/plugins/techdocs-backend/api-report.md index f617b796aa..4190b3e67c 100644 --- a/plugins/techdocs-backend/api-report.md +++ b/plugins/techdocs-backend/api-report.md @@ -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'; diff --git a/plugins/techdocs-backend/src/search/DefaultTechDocsCollator.ts b/plugins/techdocs-backend/src/search/DefaultTechDocsCollator.ts index 0368553ea4..f525d7af7e 100644 --- a/plugins/techdocs-backend/src/search/DefaultTechDocsCollator.ts +++ b/plugins/techdocs-backend/src/search/DefaultTechDocsCollator.ts @@ -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) || '', diff --git a/plugins/techdocs-backend/src/search/index.ts b/plugins/techdocs-backend/src/search/index.ts index 74f348d769..fcb970bcee 100644 --- a/plugins/techdocs-backend/src/search/index.ts +++ b/plugins/techdocs-backend/src/search/index.ts @@ -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'; diff --git a/plugins/techdocs/api-report.md b/plugins/techdocs/api-report.md index 1533f9de49..b9e776184a 100644 --- a/plugins/techdocs/api-report.md +++ b/plugins/techdocs/api-report.md @@ -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) // diff --git a/plugins/techdocs/package.json b/plugins/techdocs/package.json index 4c70edc533..20b23fbfa2 100644 --- a/plugins/techdocs/package.json +++ b/plugins/techdocs/package.json @@ -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", diff --git a/plugins/techdocs/src/EntityPageDocs.tsx b/plugins/techdocs/src/EntityPageDocs.tsx index 1e4957ab8e..f3d3e2cc88 100644 --- a/plugins/techdocs/src/EntityPageDocs.tsx +++ b/plugins/techdocs/src/EntityPageDocs.tsx @@ -21,6 +21,7 @@ import { Reader } from './reader'; export const EntityPageDocs = ({ entity }: { entity: Entity }) => { return ( { ), ).toBeInTheDocument(); }); + + it('should use title if defined', async () => { + const { findByText } = render( + , + ); + + expect(await findByText('Count Dookumentation')).toBeInTheDocument(); + expect( + await findByText( + 'Backstage is an open-source developer portal that puts the developer experience first.', + ), + ).toBeInTheDocument(); + }); }); diff --git a/plugins/techdocs/src/components/DocsResultListItem/DocsResultListItem.tsx b/plugins/techdocs/src/components/DocsResultListItem/DocsResultListItem.tsx index 52aa45a97e..54e6110b88 100644 --- a/plugins/techdocs/src/components/DocsResultListItem/DocsResultListItem.tsx +++ b/plugins/techdocs/src/components/DocsResultListItem/DocsResultListItem.tsx @@ -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 ( - - - - } + const TextItem = () => ( + - - - + } + /> + ); + + const LinkWrapper = ({ children }: PropsWithChildren<{}>) => + asLink ? {children} : <>{children}; + + const ListItemWrapper = ({ children }: PropsWithChildren<{}>) => + asListItem ? ( + <> + + {children} + + + + ) : ( + <>{children} + ); + + return ( + + + + + ); }; diff --git a/plugins/techdocs/src/reader/components/Reader.test.tsx b/plugins/techdocs/src/reader/components/Reader.test.tsx index 2dc2773048..91e5693c43 100644 --- a/plugins/techdocs/src/reader/components/Reader.test.tsx +++ b/plugins/techdocs/src/reader/components/Reader.test.tsx @@ -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('', () => { }), ); const techdocsStorageApi: Partial = {}; - + const searchApi = { + query: () => + Promise.resolve({ + results: [], + }), + }; const apiRegistry = ApiRegistry.from([ [scmIntegrationsApiRef, scmIntegrationsApi], [techdocsStorageApiRef, techdocsStorageApi], + [searchApiRef, searchApi], ]); await act(async () => { diff --git a/plugins/techdocs/src/reader/components/Reader.tsx b/plugins/techdocs/src/reader/components/Reader.tsx index ce72da4bf6..5fe395e131 100644 --- a/plugins/techdocs/src/reader/components/Reader.tsx +++ b/plugins/techdocs/src/reader/components/Reader.tsx @@ -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(() => ({ +const useStyles = makeStyles(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(); const classes = useStyles(); @@ -433,6 +441,12 @@ export const Reader = ({ entityId, onReady }: Props) => { )} + + {withSearch && shadowDomRef?.current?.shadowRoot?.innerHTML && ( + + + + )}
); diff --git a/plugins/techdocs/src/reader/components/TechDocsPage.test.tsx b/plugins/techdocs/src/reader/components/TechDocsPage.test.tsx index 53b8590eb5..584ce534fa 100644 --- a/plugins/techdocs/src/reader/components/TechDocsPage.test.tsx +++ b/plugins/techdocs/src/reader/components/TechDocsPage.test.tsx @@ -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('', () => { it('should render techdocs page', async () => { @@ -75,16 +77,23 @@ describe('', () => { site_description: 'string', }), }; + const techdocsStorageApi: Partial = { getEntityDocs: (): Promise => Promise.resolve('String'), getBaseUrl: (): Promise => Promise.resolve('String'), getApiOrigin: (): Promise => Promise.resolve('String'), }; - + const searchApi = { + query: () => + Promise.resolve({ + results: [], + }), + }; const apiRegistry = ApiRegistry.from([ [scmIntegrationsApiRef, scmIntegrationsApi], [techdocsApiRef, techdocsApi], [techdocsStorageApiRef, techdocsStorageApi], + [searchApiRef, searchApi], ]); await act(async () => { diff --git a/plugins/techdocs/src/reader/components/TechDocsSearch.test.tsx b/plugins/techdocs/src/reader/components/TechDocsSearch.test.tsx new file mode 100644 index 0000000000..564a8098a5 --- /dev/null +++ b/plugins/techdocs/src/reader/components/TechDocsSearch.test.tsx @@ -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('', () => { + 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( + + + , + ), + ); + + 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( + + + , + ), + ); + + 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'], + }), + ); + }); + }); +}); diff --git a/plugins/techdocs/src/reader/components/TechDocsSearch.tsx b/plugins/techdocs/src/reader/components/TechDocsSearch.tsx new file mode 100644 index 0000000000..0f3ca00c0a --- /dev/null +++ b/plugins/techdocs/src/reader/components/TechDocsSearch.tsx @@ -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([]); + 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(term); + + useDebounce(() => setTerm(value), debounceTime, [value]); + + const handleQuery = (e: ChangeEvent) => { + if (!open) { + setOpen(true); + } + setValue(e.target.value); + }; + + const handleSelection = (_: any, selection: TechDocsSearchResult | null) => { + if (selection?.document) { + const { location } = selection.document; + navigate(location); + } + }; + + return ( + + ''} + 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 }) => ( + + )} + loading={loading} + renderInput={params => ( + + + + + + ), + endAdornment: ( + + {loading ? ( + + ) : null} + {params.InputProps.endAdornment} + + ), + }} + /> + )} + /> + + ); +}; +const TechDocsSearch = (props: TechDocsSearchProps) => { + const initialState = { + term: '', + types: ['techdocs'], + pageCursor: '', + filters: props.entityId, + }; + return ( + + + + ); +}; +export { TechDocsSearch };