diff --git a/.changeset/funny-dragons-sneeze.md b/.changeset/funny-dragons-sneeze.md new file mode 100644 index 0000000000..758603e811 --- /dev/null +++ b/.changeset/funny-dragons-sneeze.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-techdocs': patch +--- + +Add search list item to display tech docs search results diff --git a/.changeset/great-tips-hammer.md b/.changeset/great-tips-hammer.md new file mode 100644 index 0000000000..2f31b59568 --- /dev/null +++ b/.changeset/great-tips-hammer.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-techdocs-backend': patch +--- + +Implements tech docs collator to retrieve and expose search indexes for entities that have tech docs configured diff --git a/.changeset/neat-wolves-cross.md b/.changeset/neat-wolves-cross.md new file mode 100644 index 0000000000..555245d42d --- /dev/null +++ b/.changeset/neat-wolves-cross.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-search': patch +--- + +Adding a type filter to new search diff --git a/packages/app/package.json b/packages/app/package.json index d86e0f27e9..cc38248a55 100644 --- a/packages/app/package.json +++ b/packages/app/package.json @@ -39,9 +39,11 @@ "@backstage/plugin-techdocs": "^0.10.0", "@backstage/plugin-todo": "^0.1.5", "@backstage/plugin-user-settings": "^0.3.0", + "@backstage/search-common": "^0.1.2", "@backstage/theme": "^0.2.8", "@material-ui/core": "^4.11.0", "@material-ui/icons": "^4.9.1", + "@material-ui/lab": "4.0.0-alpha.45", "@octokit/rest": "^18.5.3", "@roadiehq/backstage-plugin-buildkite": "^1.0.4", "@roadiehq/backstage-plugin-github-insights": "^1.1.15", diff --git a/packages/app/src/components/search/SearchPage.tsx b/packages/app/src/components/search/SearchPage.tsx index 35c4a4ac1c..6fe02de837 100644 --- a/packages/app/src/components/search/SearchPage.tsx +++ b/packages/app/src/components/search/SearchPage.tsx @@ -14,17 +14,20 @@ * limitations under the License. */ -import React from 'react'; +import React, { useState } from 'react'; import { makeStyles, Theme, Grid, List, Paper } from '@material-ui/core'; - +import Pagination from '@material-ui/lab/Pagination'; import { CatalogResultListItem } from '@backstage/plugin-catalog'; import { SearchBar, SearchFilter, SearchResult, + SearchType, DefaultResultListItem, } from '@backstage/plugin-search'; import { Content, Header, Lifecycle, Page } from '@backstage/core-components'; +import { DocsResultListItem } from '@backstage/plugin-techdocs'; +import { SearchResultSet } from '@backstage/search-common'; const useStyles = makeStyles((theme: Theme) => ({ bar: { @@ -34,15 +37,63 @@ const useStyles = makeStyles((theme: Theme) => ({ padding: theme.spacing(2), }, filter: { - '& + &': { - marginTop: theme.spacing(2.5), - }, + marginTop: theme.spacing(2.5), }, })); +// TODO: Move this into the search plugin once pagination is natively supported. +// See: https://github.com/backstage/backstage/issues/6062 +const SearchResultList = ({ results }: SearchResultSet) => { + const pageSize = 10; + const [page, setPage] = useState(1); + const changePage = (_: any, pageIndex: number) => { + setPage(pageIndex); + }; + const pageAmount = Math.ceil((results.length || 0) / pageSize); + return ( + <> + + {results + .slice(pageSize * (page - 1), pageSize * page) + .map(({ type, document }) => { + switch (type) { + case 'software-catalog': + return ( + + ); + case 'techdocs': + return ( + + ); + default: + return ( + + ); + } + })} + + + > + ); +}; + const SearchPage = () => { const classes = useStyles(); - return ( } /> @@ -55,6 +106,11 @@ const SearchPage = () => { + { - {({ results }) => ( - - {results.map(({ type, document }) => { - switch (type) { - case 'software-catalog': - return ( - - ); - default: - return ( - - ); - } - })} - - )} + {({ results }) => } diff --git a/packages/backend/src/plugins/search.ts b/packages/backend/src/plugins/search.ts index 4a1e415c74..7e7f6ae400 100644 --- a/packages/backend/src/plugins/search.ts +++ b/packages/backend/src/plugins/search.ts @@ -21,6 +21,7 @@ import { } from '@backstage/plugin-search-backend-node'; import { PluginEnvironment } from '../types'; import { DefaultCatalogCollator } from '@backstage/plugin-catalog-backend'; +import { DefaultTechDocsCollator } from '@backstage/plugin-techdocs-backend'; export default async function createPlugin({ logger, @@ -37,6 +38,11 @@ export default async function createPlugin({ collator: new DefaultCatalogCollator({ discovery }), }); + indexBuilder.addCollator({ + defaultRefreshIntervalSeconds: 600, + collator: new DefaultTechDocsCollator({ discovery, logger }), + }); + // The scheduler controls when documents are gathered from collators and sent // to the search engine for indexing. const { scheduler } = await indexBuilder.build(); diff --git a/plugins/search/api-report.md b/plugins/search/api-report.md index 505a8989b6..525136713d 100644 --- a/plugins/search/api-report.md +++ b/plugins/search/api-report.md @@ -143,6 +143,17 @@ export const SearchResult: ({ children: (results: { results: SearchResult_2[] }) => JSX.Element; }) => JSX.Element; +// Warning: (ae-forgotten-export) The symbol "SearchTypeProps" needs to be exported by the entry point index.d.ts +// Warning: (ae-missing-release-tag) "SearchType" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// +// @public (undocumented) +export const SearchType: ({ + values, + className, + name, + defaultValue, +}: SearchTypeProps) => JSX.Element; + // Warning: (ae-missing-release-tag) "SidebarSearch" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) // // @public (undocumented) diff --git a/plugins/search/src/components/SearchType/SearchType.test.tsx b/plugins/search/src/components/SearchType/SearchType.test.tsx new file mode 100644 index 0000000000..1ca032e41d --- /dev/null +++ b/plugins/search/src/components/SearchType/SearchType.test.tsx @@ -0,0 +1,231 @@ +/* + * 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 from 'react'; +import { screen, render, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { SearchType } from './SearchType'; + +import { SearchContextProvider } from '../SearchContext'; +import { useApi } from '@backstage/core-plugin-api'; + +jest.mock('@backstage/core-plugin-api', () => ({ + ...jest.requireActual('@backstage/core-plugin-api'), + useApi: jest.fn().mockReturnValue({}), +})); + +describe('SearchType', () => { + const initialState = { + term: '', + filters: {}, + types: [], + pageCursor: '', + }; + + const name = 'field'; + const values = ['value1', 'value2']; + const typeValues = ['preselected']; + + const query = jest.fn().mockResolvedValue({}); + (useApi as jest.Mock).mockReturnValue({ query: query }); + + afterAll(() => { + jest.resetAllMocks(); + }); + + describe('Type Filter', () => { + it('Renders field name and values when provided as props', async () => { + render( + + + , + ); + + await waitFor(() => { + expect(screen.getByText(name)).toBeInTheDocument(); + }); + + userEvent.click(screen.getByRole('button')); + + await waitFor(() => { + expect(screen.getByRole('listbox')).toBeInTheDocument(); + }); + + expect( + screen.getByRole('option', { name: values[0] }), + ).toBeInTheDocument(); + expect( + screen.getByRole('option', { name: values[1] }), + ).toBeInTheDocument(); + }); + + it('Renders correctly based on type filter state', async () => { + render( + + + , + ); + + await waitFor(() => { + expect(screen.getByText(name)).toBeInTheDocument(); + }); + + userEvent.click(screen.getByRole('button')); + + await waitFor(() => { + expect(screen.getByRole('listbox')).toBeInTheDocument(); + }); + + expect(screen.getByRole('option', { name: values[0] })).toHaveAttribute( + 'aria-selected', + 'true', + ); + expect( + screen.getByRole('option', { name: values[1] }), + ).not.toHaveAttribute('aria-selected'); + expect(screen.getByRole('option', { name: 'All' })).not.toHaveAttribute( + 'aria-selected', + ); + }); + + it('Renders correctly based on type filter defaultValue', async () => { + render( + + + , + ); + + await waitFor(() => { + expect(screen.getByText(name)).toBeInTheDocument(); + }); + + userEvent.click(screen.getByRole('button')); + + await waitFor(() => { + expect(screen.getByRole('listbox')).toBeInTheDocument(); + }); + + expect(screen.getByRole('option', { name: values[0] })).toHaveAttribute( + 'aria-selected', + 'true', + ); + expect( + screen.getByRole('option', { name: values[1] }), + ).not.toHaveAttribute('aria-selected'); + expect(screen.getByRole('option', { name: 'All' })).not.toHaveAttribute( + 'aria-selected', + ); + }); + + it('Selecting a value sets type filter state', async () => { + render( + + + , + ); + + await waitFor(() => { + expect(screen.getByText(name)).toBeInTheDocument(); + }); + + const button = screen.getByRole('button'); + + userEvent.click(button); + + await waitFor(() => { + expect(screen.getByRole('listbox')).toBeInTheDocument(); + }); + + userEvent.click(screen.getByRole('option', { name: values[0] })); + + await waitFor(() => { + expect(query).toHaveBeenLastCalledWith( + expect.objectContaining({ + types: [values[0]], + }), + ); + }); + + userEvent.click(button); + + await waitFor(() => { + expect(screen.getByRole('listbox')).toBeInTheDocument(); + }); + + userEvent.click(screen.getByRole('option', { name: 'All' })); + + await waitFor(() => { + expect(query).toHaveBeenLastCalledWith( + expect.objectContaining({ + types: [], + }), + ); + }); + }); + + it('Selecting a value maintains unrelated filter state, selecting All defaults to default empty state', async () => { + render( + + + , + ); + + await waitFor(() => { + expect(screen.getByText(name)).toBeInTheDocument(); + }); + + const button = screen.getByRole('button'); + + userEvent.click(button); + + await waitFor(() => { + expect(screen.getByRole('listbox')).toBeInTheDocument(); + }); + + userEvent.click(screen.getByRole('option', { name: values[0] })); + + await waitFor(() => { + expect(query).toHaveBeenLastCalledWith( + expect.objectContaining({ + types: [...typeValues, values[0]], + }), + ); + }); + + userEvent.click(button); + + await waitFor(() => { + expect(screen.getByRole('listbox')).toBeInTheDocument(); + }); + + userEvent.click(screen.getByRole('option', { name: 'All' })); + + await waitFor(() => { + expect(query).toHaveBeenLastCalledWith(expect.objectContaining([])); + }); + }); + }); +}); diff --git a/plugins/search/src/components/SearchType/SearchType.tsx b/plugins/search/src/components/SearchType/SearchType.tsx new file mode 100644 index 0000000000..6e57879064 --- /dev/null +++ b/plugins/search/src/components/SearchType/SearchType.tsx @@ -0,0 +1,110 @@ +/* + * 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 { useSearch } from '../SearchContext'; +import { useEffectOnce } from 'react-use'; +import React, { ChangeEvent } from 'react'; +import { + Chip, + FormControl, + InputLabel, + makeStyles, + MenuItem, + Select, +} from '@material-ui/core'; + +const useStyles = makeStyles({ + label: { + textTransform: 'capitalize', + }, + chips: { + display: 'flex', + flexWrap: 'wrap', + }, + chip: { + margin: 2, + }, +}); + +export type SearchTypeProps = { + className?: string; + name: string; + values?: string[]; + defaultValue?: string[] | string | null; +}; + +const SearchType = ({ + values = [], + className, + name, + defaultValue, +}: SearchTypeProps) => { + const classes = useStyles(); + const { types, setTypes } = useSearch(); + + useEffectOnce(() => { + if (defaultValue && Array.isArray(defaultValue)) { + setTypes(defaultValue); + } else if (defaultValue) { + setTypes([defaultValue]); + } + }); + + const handleChange = (e: ChangeEvent<{ value: unknown }>) => { + const value = e.target.value as string[]; + if (!value || value.includes('*')) { + setTypes([]); + } else { + setTypes(value.filter(it => it !== 'All')); + } + }; + + return ( + + + {name} + + ( + + {(selected as string[]).map(value => ( + + ))} + + )} + > + + All + + {values.map((value: string) => ( + + {value} + + ))} + + + ); +}; + +export { SearchType }; diff --git a/plugins/search/src/components/SearchType/index.ts b/plugins/search/src/components/SearchType/index.ts new file mode 100644 index 0000000000..400f3d2a46 --- /dev/null +++ b/plugins/search/src/components/SearchType/index.ts @@ -0,0 +1,17 @@ +/* + * 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. + */ + +export { SearchType } from './SearchType'; diff --git a/plugins/search/src/components/index.tsx b/plugins/search/src/components/index.tsx index 245c31598d..889eb36334 100644 --- a/plugins/search/src/components/index.tsx +++ b/plugins/search/src/components/index.tsx @@ -16,6 +16,7 @@ export * from './Filters'; export * from './SearchFilter'; +export * from './SearchType'; export * from './SearchBar'; export * from './SearchPage'; export * from './SearchResult'; diff --git a/plugins/search/src/index.ts b/plugins/search/src/index.ts index ecd6015b1c..f09ba739bd 100644 --- a/plugins/search/src/index.ts +++ b/plugins/search/src/index.ts @@ -32,6 +32,7 @@ export { useSearch, SearchPage as Router, SearchFilter, + SearchType, SearchFilterNext, SidebarSearch, } from './components'; diff --git a/plugins/techdocs-backend/api-report.md b/plugins/techdocs-backend/api-report.md index 3d1738cb12..f617b796aa 100644 --- a/plugins/techdocs-backend/api-report.md +++ b/plugins/techdocs-backend/api-report.md @@ -3,9 +3,12 @@ > Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/). ```ts +import { CatalogApi } from '@backstage/catalog-client'; 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'; @@ -18,6 +21,54 @@ import { PublisherBase } from '@backstage/techdocs-common'; // @public (undocumented) export function createRouter(options: RouterOptions): Promise; +// Warning: (ae-missing-release-tag) "DefaultTechDocsCollator" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// +// @public (undocumented) +export class DefaultTechDocsCollator implements DocumentCollator { + constructor({ + discovery, + locationTemplate, + logger, + catalogClient, + parallelismLimit, + }: { + discovery: PluginEndpointDiscovery; + logger: Logger_2; + locationTemplate?: string; + catalogClient?: CatalogApi; + parallelismLimit?: number; + }); + // (undocumented) + protected applyArgsToFormat( + format: string, + args: Record, + ): string; + // (undocumented) + protected discovery: PluginEndpointDiscovery; + // (undocumented) + execute(): Promise; + // (undocumented) + protected locationTemplate: string; + // (undocumented) + 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 * from '@backstage/techdocs-common'; // (No @packageDocumentation comment for this package) diff --git a/plugins/techdocs-backend/package.json b/plugins/techdocs-backend/package.json index 76aadc79cf..24f3f754ca 100644 --- a/plugins/techdocs-backend/package.json +++ b/plugins/techdocs-backend/package.json @@ -35,6 +35,7 @@ "@backstage/catalog-model": "^0.9.0", "@backstage/config": "^0.1.5", "@backstage/errors": "^0.1.1", + "@backstage/search-common": "^0.1.2", "@backstage/techdocs-common": "^0.6.8", "@types/express": "^4.17.6", "cross-fetch": "^3.0.6", @@ -43,10 +44,13 @@ "express-promise-router": "^4.1.0", "fs-extra": "9.1.0", "knex": "^0.95.1", + "lodash": "^4.17.21", + "p-limit": "^3.1.0", "winston": "^3.2.1" }, "devDependencies": { "@backstage/cli": "^0.7.4", + "@backstage/test-utils": "^0.1.14", "@types/dockerode": "^3.2.1", "msw": "^0.29.0", "supertest": "^6.1.3" diff --git a/plugins/techdocs-backend/src/index.ts b/plugins/techdocs-backend/src/index.ts index 6d1e551629..e2a89237db 100644 --- a/plugins/techdocs-backend/src/index.ts +++ b/plugins/techdocs-backend/src/index.ts @@ -15,4 +15,5 @@ */ export { createRouter } from './service/router'; +export * from './search'; export * from '@backstage/techdocs-common'; diff --git a/plugins/techdocs-backend/src/search/DefaultTechDocsCollator.test.ts b/plugins/techdocs-backend/src/search/DefaultTechDocsCollator.test.ts new file mode 100644 index 0000000000..618bb54d60 --- /dev/null +++ b/plugins/techdocs-backend/src/search/DefaultTechDocsCollator.test.ts @@ -0,0 +1,149 @@ +/* + * 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 { + PluginEndpointDiscovery, + getVoidLogger, +} from '@backstage/backend-common'; +import { Entity } from '@backstage/catalog-model'; +import { DefaultTechDocsCollator } from './DefaultTechDocsCollator'; +import { msw } from '@backstage/test-utils'; +import { setupServer } from 'msw/node'; +import { rest } from 'msw'; + +const logger = getVoidLogger(); + +const mockSearchDocIndex = { + config: { + lang: ['en'], + min_search_length: 3, + prebuild_index: false, + separator: '[\\s\\-]+', + }, + docs: [ + { + location: '', + text: 'docs docs docs', + title: 'Home', + }, + { + location: 'local-development/', + text: 'Docs for first subtitle', + title: 'Local development', + }, + { + location: 'local-development/#development', + text: 'Docs for sub-subtitle', + title: 'Development', + }, + ], +}; + +const expectedEntities: Entity[] = [ + { + apiVersion: 'backstage.io/v1alpha1', + kind: 'Component', + metadata: { + name: 'test-entity-with-docs', + description: 'Documented description', + annotations: { + 'backstage.io/techdocs-ref': './', + }, + }, + spec: { + type: 'dog', + lifecycle: 'experimental', + owner: 'someone', + }, + }, + { + apiVersion: 'backstage.io/v1alpha1', + kind: 'Component', + metadata: { + name: 'test-entity', + description: 'The expected description', + }, + spec: { + type: 'some-type', + lifecycle: 'experimental', + }, + }, +]; + +describe('DefaultTechDocsCollator', () => { + let mockDiscoveryApi: jest.Mocked; + let collator: DefaultTechDocsCollator; + + const worker = setupServer(); + msw.setupDefaultHandlers(worker); + beforeEach(() => { + mockDiscoveryApi = { + getBaseUrl: jest.fn().mockResolvedValue('http://test-backend'), + getExternalBaseUrl: jest.fn(), + }; + collator = new DefaultTechDocsCollator({ + discovery: mockDiscoveryApi, + logger, + }); + + worker.use( + rest.get( + 'http://test-backend/static/docs/default/Component/test-entity-with-docs/search/search_index.json', + (_, res, ctx) => res(ctx.status(200), ctx.json(mockSearchDocIndex)), + ), + rest.get('http://test-backend/entities', (_, res, ctx) => + res(ctx.status(200), ctx.json(expectedEntities)), + ), + ); + }); + + it('fetches from the configured catalog and tech docs services', async () => { + const documents = await collator.execute(); + expect(mockDiscoveryApi.getBaseUrl).toHaveBeenCalledWith('catalog'); + expect(mockDiscoveryApi.getBaseUrl).toHaveBeenCalledWith('techdocs'); + expect(documents).toHaveLength(mockSearchDocIndex.docs.length); + }); + + it('should create documents for each tech docs search index', async () => { + const documents = await collator.execute(); + const entity = expectedEntities[0]; + documents.forEach((document, idx) => { + expect(document).toMatchObject({ + title: mockSearchDocIndex.docs[idx].title, + location: `/docs/default/Component/${entity.metadata.name}/${mockSearchDocIndex.docs[idx].location}`, + text: mockSearchDocIndex.docs[idx].text, + namespace: 'default', + componentType: entity!.spec!.type, + lifecycle: entity!.spec!.lifecycle, + owner: '', + }); + }); + }); + + it('maps a returned entity with a custom locationTemplate', async () => { + // Provide an alternate location template. + collator = new DefaultTechDocsCollator({ + discovery: mockDiscoveryApi, + locationTemplate: '/software/:name', + logger, + }); + + const documents = await collator.execute(); + expect(documents[0]).toMatchObject({ + location: '/software/test-entity-with-docs', + }); + }); +}); diff --git a/plugins/techdocs-backend/src/search/DefaultTechDocsCollator.ts b/plugins/techdocs-backend/src/search/DefaultTechDocsCollator.ts new file mode 100644 index 0000000000..7c176da9fb --- /dev/null +++ b/plugins/techdocs-backend/src/search/DefaultTechDocsCollator.ts @@ -0,0 +1,149 @@ +/* + * 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 { PluginEndpointDiscovery } from '@backstage/backend-common'; +import { Entity, RELATION_OWNED_BY } from '@backstage/catalog-model'; +import { IndexableDocument, 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'; + +interface MkSearchIndexDoc { + title: string; + text: string; + 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; + private readonly logger: Logger; + private readonly catalogClient: CatalogApi; + private readonly parallelismLimit: number; + public readonly type: string = 'techdocs'; + + constructor({ + discovery, + locationTemplate, + logger, + catalogClient, + parallelismLimit = 10, + }: { + discovery: PluginEndpointDiscovery; + logger: Logger; + locationTemplate?: string; + catalogClient?: CatalogApi; + parallelismLimit?: number; + }) { + this.discovery = discovery; + this.locationTemplate = + locationTemplate || '/docs/:namespace/:kind/:name/:path'; + this.logger = logger; + this.catalogClient = + catalogClient || new CatalogClient({ discoveryApi: discovery }); + this.parallelismLimit = parallelismLimit; + } + + async execute() { + const limit = pLimit(this.parallelismLimit); + const techDocsBaseUrl = await this.discovery.getBaseUrl('techdocs'); + const entities = await this.catalogClient.getEntities({ + fields: [ + 'kind', + 'namespace', + 'metadata.annotations', + 'metadata.name', + 'metadata.namespace', + 'spec.type', + 'spec.lifecycle', + 'relations', + ], + }); + const docPromises = entities.items + .filter(it => it.metadata?.annotations?.['backstage.io/techdocs-ref']) + .map((entity: Entity) => + limit( + async (): Promise => { + const entityInfo = { + kind: entity.kind, + namespace: entity.metadata.namespace || 'default', + name: entity.metadata.name, + }; + + try { + const searchIndexResponse = await fetch( + DefaultTechDocsCollator.constructDocsIndexUrl( + techDocsBaseUrl, + entityInfo, + ), + ); + const searchIndex = await searchIndexResponse.json(); + + return searchIndex.docs.map((doc: MkSearchIndexDoc) => ({ + title: unescape(doc.title), + text: unescape(doc.text || ''), + location: this.applyArgsToFormat(this.locationTemplate, { + ...entityInfo, + path: doc.location, + }), + ...entityInfo, + componentType: entity.spec?.type?.toString() || 'other', + lifecycle: (entity.spec?.lifecycle as string) || '', + owner: + entity.relations?.find(r => r.type === RELATION_OWNED_BY) + ?.target?.name || '', + })); + } catch (e) { + this.logger.warn( + `Failed to retrieve tech docs search index for entity ${entityInfo.namespace}/${entityInfo.kind}/${entityInfo.name}`, + e, + ); + return []; + } + }, + ), + ); + return (await Promise.all(docPromises)).flat(); + } + + protected applyArgsToFormat( + format: string, + args: Record, + ): string { + let formatted = format; + for (const [key, value] of Object.entries(args)) { + formatted = formatted.replace(`:${key}`, value); + } + return formatted; + } + + private static constructDocsIndexUrl( + techDocsBaseUrl: string, + entityInfo: { kind: string; namespace: string; name: string }, + ) { + return `${techDocsBaseUrl}/static/docs/${entityInfo.namespace}/${entityInfo.kind}/${entityInfo.name}/search/search_index.json`; + } +} diff --git a/plugins/techdocs-backend/src/search/index.ts b/plugins/techdocs-backend/src/search/index.ts new file mode 100644 index 0000000000..74f348d769 --- /dev/null +++ b/plugins/techdocs-backend/src/search/index.ts @@ -0,0 +1,17 @@ +/* + * 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. + */ +export { DefaultTechDocsCollator } from './DefaultTechDocsCollator'; +export type { TechDocsDocument } from './DefaultTechDocsCollator'; diff --git a/plugins/techdocs/api-report.md b/plugins/techdocs/api-report.md index d63303c134..fdac1a8aab 100644 --- a/plugins/techdocs/api-report.md +++ b/plugins/techdocs/api-report.md @@ -25,6 +25,17 @@ export const DocsCardGrid: ({ entities: Entity[] | undefined; }) => JSX.Element | null; +// Warning: (ae-missing-release-tag) "DocsResultListItem" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// +// @public (undocumented) +export const DocsResultListItem: ({ + result, + lineClamp, +}: { + result: any; + lineClamp?: number | 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) // // @public (undocumented) diff --git a/plugins/techdocs/package.json b/plugins/techdocs/package.json index 140be7d886..653fc99146 100644 --- a/plugins/techdocs/package.json +++ b/plugins/techdocs/package.json @@ -51,6 +51,7 @@ "react-router": "6.0.0-beta.0", "react-router-dom": "6.0.0-beta.0", "react-use": "^17.2.4", + "react-text-truncate": "^0.16.0", "sanitize-html": "^2.3.2" }, "devDependencies": { diff --git a/plugins/techdocs/src/components/DocsResultListItem/DocsResultListItem.test.tsx b/plugins/techdocs/src/components/DocsResultListItem/DocsResultListItem.test.tsx new file mode 100644 index 0000000000..41de2fefe4 --- /dev/null +++ b/plugins/techdocs/src/components/DocsResultListItem/DocsResultListItem.test.tsx @@ -0,0 +1,50 @@ +/* + * 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 from 'react'; +import { render } from '@testing-library/react'; +import { DocsResultListItem } from './DocsResultListItem'; + +// Using canvas to render text.. +jest.mock('react-text-truncate', () => { + return ({ text }: { text: string }) => {text}; +}); + +const validResult = { + location: 'https://backstage.io/docs', + title: 'Documentation', + text: + 'Backstage is an open-source developer portal that puts the developer experience first.', + kind: 'library', + namespace: '', + name: 'Backstage', + lifecycle: 'production', +}; + +describe('DocsResultListItem test', () => { + it('should render search doc passed in', async () => { + const { findByText } = render(); + + expect( + await findByText('Documentation | Backstage docs'), + ).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 new file mode 100644 index 0000000000..52aa45a97e --- /dev/null +++ b/plugins/techdocs/src/components/DocsResultListItem/DocsResultListItem.tsx @@ -0,0 +1,60 @@ +/* + * 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 from 'react'; +import { Divider, ListItem, ListItemText, makeStyles } from '@material-ui/core'; +import { Link } from '@backstage/core-components'; +import TextTruncate from 'react-text-truncate'; + +const useStyles = makeStyles({ + flexContainer: { + flexWrap: 'wrap', + }, + itemText: { + width: '100%', + marginBottom: '1rem', + }, +}); + +export const DocsResultListItem = ({ + result, + lineClamp = 5, +}: { + result: any; + lineClamp?: number; +}) => { + const classes = useStyles(); + return ( + + + + } + /> + + + + ); +}; diff --git a/plugins/techdocs/src/components/DocsResultListItem/index.ts b/plugins/techdocs/src/components/DocsResultListItem/index.ts new file mode 100644 index 0000000000..4e1e511ea1 --- /dev/null +++ b/plugins/techdocs/src/components/DocsResultListItem/index.ts @@ -0,0 +1,17 @@ +/* + * 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. + */ + +export { DocsResultListItem } from './DocsResultListItem'; diff --git a/plugins/techdocs/src/index.ts b/plugins/techdocs/src/index.ts index d645d128f7..66502a4585 100644 --- a/plugins/techdocs/src/index.ts +++ b/plugins/techdocs/src/index.ts @@ -19,6 +19,7 @@ export { techdocsApiRef, techdocsStorageApiRef } from './api'; export type { TechDocsApi, TechDocsStorageApi } from './api'; export { TechDocsClient, TechDocsStorageClient } from './client'; export type { PanelType } from './home/components/TechDocsCustomHome'; +export * from './components/DocsResultListItem'; export { DocsCardGrid, DocsTable,