diff --git a/.changeset/fluffy-peas-care.md b/.changeset/fluffy-peas-care.md new file mode 100644 index 0000000000..15c0b44ca5 --- /dev/null +++ b/.changeset/fluffy-peas-care.md @@ -0,0 +1,6 @@ +--- +'@backstage/plugin-techdocs-backend': patch +'@backstage/plugin-techdocs': patch +--- + +Modify TechDocsCollator to be aware of new TechDocs URL pattern. Modify tech docs in context search to use correct casing when creating initial filter. diff --git a/packages/backend/src/plugins/search.ts b/packages/backend/src/plugins/search.ts index 2da108608e..4cf9c10021 100644 --- a/packages/backend/src/plugins/search.ts +++ b/packages/backend/src/plugins/search.ts @@ -73,7 +73,10 @@ export default async function createPlugin({ indexBuilder.addCollator({ defaultRefreshIntervalSeconds: 600, - collator: new DefaultTechDocsCollator({ discovery, logger }), + collator: DefaultTechDocsCollator.fromConfig(config, { + discovery, + logger, + }), }); // The scheduler controls when documents are gathered from collators and sent diff --git a/plugins/techdocs-backend/api-report.md b/plugins/techdocs-backend/api-report.md index 4190b3e67c..466b02a307 100644 --- a/plugins/techdocs-backend/api-report.md +++ b/plugins/techdocs-backend/api-report.md @@ -25,19 +25,15 @@ export function createRouter(options: RouterOptions): Promise; // // @public (undocumented) export class DefaultTechDocsCollator implements DocumentCollator { + // @deprecated constructor({ discovery, locationTemplate, logger, catalogClient, parallelismLimit, - }: { - discovery: PluginEndpointDiscovery; - logger: Logger_2; - locationTemplate?: string; - catalogClient?: CatalogApi; - parallelismLimit?: number; - }); + legacyPathCasing, + }: TechDocsCollatorOptions); // (undocumented) protected applyArgsToFormat( format: string, @@ -48,11 +44,28 @@ export class DefaultTechDocsCollator implements DocumentCollator { // (undocumented) execute(): Promise; // (undocumented) + static fromConfig( + config: Config, + options: TechDocsCollatorOptions, + ): DefaultTechDocsCollator; + // (undocumented) protected locationTemplate: string; // (undocumented) readonly type: string; } +// Warning: (ae-missing-release-tag) "TechDocsCollatorOptions" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// +// @public (undocumented) +export type TechDocsCollatorOptions = { + discovery: PluginEndpointDiscovery; + logger: Logger_2; + locationTemplate?: string; + catalogClient?: CatalogApi; + parallelismLimit?: number; + legacyPathCasing?: boolean; +}; + export { TechDocsDocument }; 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 index 618bb54d60..0df22bee53 100644 --- a/plugins/techdocs-backend/src/search/DefaultTechDocsCollator.test.ts +++ b/plugins/techdocs-backend/src/search/DefaultTechDocsCollator.test.ts @@ -23,6 +23,7 @@ import { DefaultTechDocsCollator } from './DefaultTechDocsCollator'; import { msw } from '@backstage/test-utils'; import { setupServer } from 'msw/node'; import { rest } from 'msw'; +import { ConfigReader } from '@backstage/config'; const logger = getVoidLogger(); @@ -83,7 +84,7 @@ const expectedEntities: Entity[] = [ }, ]; -describe('DefaultTechDocsCollator', () => { +describe('DefaultTechDocsCollator with legacyPathCasing configuration', () => { let mockDiscoveryApi: jest.Mocked; let collator: DefaultTechDocsCollator; @@ -94,9 +95,15 @@ describe('DefaultTechDocsCollator', () => { getBaseUrl: jest.fn().mockResolvedValue('http://test-backend'), getExternalBaseUrl: jest.fn(), }; - collator = new DefaultTechDocsCollator({ + const mockConfig = new ConfigReader({ + techdocs: { + legacyUseCaseSensitiveTripletPaths: true, + }, + }); + collator = DefaultTechDocsCollator.fromConfig(mockConfig, { discovery: mockDiscoveryApi, logger, + legacyPathCasing: true, }); worker.use( @@ -132,6 +139,50 @@ describe('DefaultTechDocsCollator', () => { }); }); }); +}); + +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 = DefaultTechDocsCollator.fromConfig(new ConfigReader({}), { + 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('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. diff --git a/plugins/techdocs-backend/src/search/DefaultTechDocsCollator.ts b/plugins/techdocs-backend/src/search/DefaultTechDocsCollator.ts index 850be2823c..cb7d40206b 100644 --- a/plugins/techdocs-backend/src/search/DefaultTechDocsCollator.ts +++ b/plugins/techdocs-backend/src/search/DefaultTechDocsCollator.ts @@ -21,6 +21,7 @@ import fetch from 'cross-fetch'; import unescape from 'lodash/unescape'; import { Logger } from 'winston'; import pLimit from 'p-limit'; +import { Config } from '@backstage/config'; import { CatalogApi, CatalogClient } from '@backstage/catalog-client'; import { TechDocsDocument } from '@backstage/techdocs-common'; @@ -30,27 +31,41 @@ interface MkSearchIndexDoc { location: string; } +export type TechDocsCollatorOptions = { + discovery: PluginEndpointDiscovery; + logger: Logger; + locationTemplate?: string; + catalogClient?: CatalogApi; + parallelismLimit?: number; + legacyPathCasing?: boolean; +}; + +type EntityInfo = { + name: string; + namespace: string; + kind: string; +}; + export class DefaultTechDocsCollator implements DocumentCollator { protected discovery: PluginEndpointDiscovery; protected locationTemplate: string; private readonly logger: Logger; private readonly catalogClient: CatalogApi; private readonly parallelismLimit: number; + private readonly legacyPathCasing: boolean; public readonly type: string = 'techdocs'; + /** + * @deprecated use static fromConfig method instead. + */ constructor({ discovery, locationTemplate, logger, catalogClient, parallelismLimit = 10, - }: { - discovery: PluginEndpointDiscovery; - logger: Logger; - locationTemplate?: string; - catalogClient?: CatalogApi; - parallelismLimit?: number; - }) { + legacyPathCasing = false, + }: TechDocsCollatorOptions) { this.discovery = discovery; this.locationTemplate = locationTemplate || '/docs/:namespace/:kind/:name/:path'; @@ -58,6 +73,15 @@ export class DefaultTechDocsCollator implements DocumentCollator { this.catalogClient = catalogClient || new CatalogClient({ discoveryApi: discovery }); this.parallelismLimit = parallelismLimit; + this.legacyPathCasing = legacyPathCasing; + } + + static fromConfig(config: Config, options: TechDocsCollatorOptions) { + const legacyPathCasing = + config.getOptionalBoolean( + 'techdocs.legacyUseCaseSensitiveTripletPaths', + ) || false; + return new DefaultTechDocsCollator({ ...options, legacyPathCasing }); } async execute() { @@ -79,11 +103,14 @@ export class DefaultTechDocsCollator implements DocumentCollator { .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, - }; + const entityInfo = DefaultTechDocsCollator.handleEntityInfoCasing( + this.legacyPathCasing, + { + kind: entity.kind, + namespace: entity.metadata.namespace || 'default', + name: entity.metadata.name, + }, + ); try { const searchIndexResponse = await fetch( @@ -138,4 +165,15 @@ export class DefaultTechDocsCollator implements DocumentCollator { ) { return `${techDocsBaseUrl}/static/docs/${entityInfo.namespace}/${entityInfo.kind}/${entityInfo.name}/search/search_index.json`; } + + private static handleEntityInfoCasing( + legacyPaths: boolean, + entityInfo: EntityInfo, + ): EntityInfo { + return legacyPaths + ? entityInfo + : Object.entries(entityInfo).reduce((acc, [key, value]) => { + return { ...acc, [key]: value.toLocaleLowerCase('en-US') }; + }, {} as EntityInfo); + } } diff --git a/plugins/techdocs-backend/src/search/index.ts b/plugins/techdocs-backend/src/search/index.ts index fcb970bcee..53e0934789 100644 --- a/plugins/techdocs-backend/src/search/index.ts +++ b/plugins/techdocs-backend/src/search/index.ts @@ -15,6 +15,8 @@ */ export { DefaultTechDocsCollator } from './DefaultTechDocsCollator'; +export type { TechDocsCollatorOptions } from './DefaultTechDocsCollator'; + /** * @deprecated Use directly from @backstage/techdocs-common */ diff --git a/plugins/techdocs/src/reader/components/TechDocsSearch.test.tsx b/plugins/techdocs/src/reader/components/TechDocsSearch.test.tsx index 564a8098a5..c6f017e92d 100644 --- a/plugins/techdocs/src/reader/components/TechDocsSearch.test.tsx +++ b/plugins/techdocs/src/reader/components/TechDocsSearch.test.tsx @@ -14,7 +14,7 @@ * limitations under the License. */ import React from 'react'; -import { TechDocsSearch } from './TechDocsSearch'; +import { buildInitialFilters, TechDocsSearch } from './TechDocsSearch'; import { act, fireEvent, @@ -89,7 +89,7 @@ describe('', () => { await singleResult; expect(querySpy).toBeCalledWith({ filters: { - kind: 'Testable', + kind: 'testable', name: 'test', namespace: 'testspace', }, @@ -108,7 +108,7 @@ describe('', () => { await waitFor(() => expect(querySpy).toBeCalledWith({ filters: { - kind: 'Testable', + kind: 'testable', name: 'test', namespace: 'testspace', }, @@ -120,3 +120,23 @@ describe('', () => { }); }); }); + +describe('buildInitialFilters', () => { + const filterEnt = { + name: 'Test', + kind: 'TestKind', + namespace: 'TeStNaMeSpAcE', + }; + it('should use filters as is when legacy path', () => { + const filters = buildInitialFilters(true, filterEnt); + expect(filters).toStrictEqual(filterEnt); + }); + it('should lowercase all filters for new approach', () => { + const filters = buildInitialFilters(false, filterEnt); + expect(filters).toStrictEqual({ + name: 'test', + kind: 'testkind', + namespace: 'testnamespace', + }); + }); +}); diff --git a/plugins/techdocs/src/reader/components/TechDocsSearch.tsx b/plugins/techdocs/src/reader/components/TechDocsSearch.tsx index 6f2df2cc55..5d330100ce 100644 --- a/plugins/techdocs/src/reader/components/TechDocsSearch.tsx +++ b/plugins/techdocs/src/reader/components/TechDocsSearch.tsx @@ -16,11 +16,11 @@ import React, { ChangeEvent, useEffect, useState } from 'react'; import { + CircularProgress, Grid, IconButton, InputAdornment, TextField, - CircularProgress, } from '@material-ui/core'; import Autocomplete from '@material-ui/lab/Autocomplete'; import { SearchContextProvider, useSearch } from '@backstage/plugin-search'; @@ -28,13 +28,15 @@ import { DocsResultListItem } from '../../components/DocsResultListItem'; import SearchIcon from '@material-ui/icons/Search'; import { useDebounce } from 'react-use'; import { useNavigate } from 'react-router'; +import { configApiRef, useApi } from '@backstage/core-plugin-api'; +type EntityId = { + name: string; + namespace: string; + kind: string; +}; type TechDocsSearchProps = { - entityId: { - name: string; - namespace: string; - kind: string; - }; + entityId: EntityId; debounceTime?: number; }; @@ -52,6 +54,17 @@ type TechDocsSearchResult = { document: TechDocsDoc; }; +export const buildInitialFilters = ( + legacyPaths: boolean, + entityId: EntityId, +) => { + return legacyPaths + ? entityId + : Object.entries(entityId).reduce((acc, [key, value]) => { + return { ...acc, [key]: value.toLocaleLowerCase('en-US') }; + }, {}); +}; + const TechDocsSearchBar = ({ entityId, debounceTime = 150, @@ -161,12 +174,17 @@ const TechDocsSearchBar = ({ ); }; + const TechDocsSearch = (props: TechDocsSearchProps) => { + const configApi = useApi(configApiRef); + const legacyPaths = configApi.getOptionalBoolean( + 'techdocs.legacyUseCaseSensitiveTripletPaths', + ); const initialState = { term: '', types: ['techdocs'], pageCursor: '', - filters: props.entityId, + filters: buildInitialFilters(legacyPaths || false, props.entityId), }; return (