diff --git a/.changeset/fuzzy-mangos-design.md b/.changeset/fuzzy-mangos-design.md new file mode 100644 index 0000000000..be4a33566f --- /dev/null +++ b/.changeset/fuzzy-mangos-design.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-techdocs': patch +--- + +Migrated the TechDocs alpha plugin pages to use BUI components. The index page and reader page now use BUI `Header` and `Container` instead of legacy `Page`/`Content`/`ContentHeader` wrappers. Added a `SupportButton` as a plugin header action. Changed plugin title to "Documentation" and icon to `RiArticleLine`. diff --git a/plugins/techdocs/package.json b/plugins/techdocs/package.json index 4b40c3f72d..b2de90dc85 100644 --- a/plugins/techdocs/package.json +++ b/plugins/techdocs/package.json @@ -75,11 +75,13 @@ "@backstage/plugin-techdocs-common": "workspace:^", "@backstage/plugin-techdocs-react": "workspace:^", "@backstage/theme": "workspace:^", + "@backstage/ui": "workspace:^", "@material-ui/core": "^4.12.2", "@material-ui/icons": "^4.9.1", "@material-ui/lab": "4.0.0-alpha.61", "@material-ui/styles": "^4.10.0", "@microsoft/fetch-event-source": "^2.0.1", + "@remixicon/react": "^4.6.0", "dompurify": "^3.3.2", "git-url-parse": "^15.0.0", "lodash": "^4.17.21", diff --git a/plugins/techdocs/report-alpha.api.md b/plugins/techdocs/report-alpha.api.md index aa14098288..47c10f5441 100644 --- a/plugins/techdocs/report-alpha.api.md +++ b/plugins/techdocs/report-alpha.api.md @@ -448,6 +448,19 @@ const _default: OverridableFrontendPlugin< noHeader?: boolean; }; }>; + 'plugin-header-action:techdocs': OverridableExtensionDefinition<{ + kind: 'plugin-header-action'; + name: undefined; + config: {}; + configInput: {}; + output: ExtensionDataRef; + inputs: {}; + params: (params: { + loader: () => Promise; + }) => ExtensionBlueprintParams<{ + loader: () => Promise; + }>; + }>; 'search-result-list-item:techdocs': OverridableExtensionDefinition<{ config: { title: string | undefined; diff --git a/plugins/techdocs/src/alpha/components/TechDocsIndexPageContent.test.tsx b/plugins/techdocs/src/alpha/components/TechDocsIndexPageContent.test.tsx new file mode 100644 index 0000000000..579d624a7f --- /dev/null +++ b/plugins/techdocs/src/alpha/components/TechDocsIndexPageContent.test.tsx @@ -0,0 +1,75 @@ +/* + * Copyright 2026 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 { ApiProvider } from '@backstage/core-app-api'; +import { configApiRef, storageApiRef } from '@backstage/core-plugin-api'; +import { + MockStarredEntitiesApi, + catalogApiRef, + starredEntitiesApiRef, +} from '@backstage/plugin-catalog-react'; +import { catalogApiMock } from '@backstage/plugin-catalog-react/testUtils'; +import { + TestApiRegistry, + mockApis, + renderInTestApp, +} from '@backstage/test-utils'; +import { screen } from '@testing-library/react'; +import { rootDocsRouteRef } from '../../routes'; +import { TechDocsIndexPageContent } from './TechDocsIndexPageContent'; + +const mockCatalogApi = catalogApiMock({ + entities: [ + { + apiVersion: 'version', + kind: 'User', + metadata: { + name: 'owned', + namespace: 'default', + }, + }, + ], +}); + +describe('', () => { + const configApi = mockApis.config({ + data: { organization: { name: 'My Company' } }, + }); + + const apiRegistry = TestApiRegistry.from( + [catalogApiRef, mockCatalogApi], + [configApiRef, configApi], + [storageApiRef, mockApis.storage()], + [starredEntitiesApiRef, new MockStarredEntitiesApi()], + ); + + it('should render the filter layout with pickers', async () => { + await renderInTestApp( + + + , + { + mountedRoutes: { + '/docs/:namespace/:kind/:name/*': rootDocsRouteRef, + }, + }, + ); + + // The UserListPicker renders filter options + expect(await screen.findByTestId('user-picker-owned')).toBeInTheDocument(); + expect(screen.getByTestId('user-picker-all')).toBeInTheDocument(); + }); +}); diff --git a/plugins/techdocs/src/alpha/components/TechDocsIndexPageContent.tsx b/plugins/techdocs/src/alpha/components/TechDocsIndexPageContent.tsx new file mode 100644 index 0000000000..16b866b0c0 --- /dev/null +++ b/plugins/techdocs/src/alpha/components/TechDocsIndexPageContent.tsx @@ -0,0 +1,60 @@ +/* + * Copyright 2026 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 { Container } from '@backstage/ui'; +import { + CatalogFilterLayout, + EntityListProvider, + EntityOwnerPicker, + EntityTagPicker, + UserListPicker, +} from '@backstage/plugin-catalog-react'; +import { TechDocsPicker } from '../../home/components/TechDocsPicker'; +import { EntityListDocsTable } from '../../home/components/Tables'; +import type { TechDocsIndexPageProps } from '../../home/components/TechDocsIndexPage'; + +export const TechDocsIndexPageContent = (props: TechDocsIndexPageProps) => { + const { + initialFilter = 'owned', + columns, + actions, + ownerPickerMode, + pagination, + options, + } = props; + + return ( + + + + + + + + + + + + + + + + ); +}; diff --git a/plugins/techdocs/src/alpha/components/TechDocsReaderContent.test.tsx b/plugins/techdocs/src/alpha/components/TechDocsReaderContent.test.tsx new file mode 100644 index 0000000000..cb443b3533 --- /dev/null +++ b/plugins/techdocs/src/alpha/components/TechDocsReaderContent.test.tsx @@ -0,0 +1,238 @@ +/* + * Copyright 2026 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 { ReactNode } from 'react'; +import { waitFor } from '@testing-library/react'; + +import { + CompoundEntityRef, + getCompoundEntityRef, +} from '@backstage/catalog-model'; +import { + techdocsApiRef, + TechDocsReaderPageProvider, + useShadowRootElements, +} from '@backstage/plugin-techdocs-react'; +import { + renderInTestApp, + TestApiProvider, + mockApis, +} from '@backstage/test-utils'; +import { configApiRef } from '@backstage/core-plugin-api'; + +const useTechDocsReaderDom = jest.fn(); +jest.mock('../../reader/components/TechDocsReaderPageContent/dom', () => ({ + ...jest.requireActual( + '../../reader/components/TechDocsReaderPageContent/dom', + ), + useTechDocsReaderDom: (...args: any[]) => useTechDocsReaderDom(...args), +})); +const useReaderState = jest.fn(); +jest.mock('../../reader/components/useReaderState', () => ({ + ...jest.requireActual('../../reader/components/useReaderState'), + useReaderState: (...args: any[]) => useReaderState(...args), +})); +const useShadowDomStylesLoading = jest.fn().mockReturnValue(false); +jest.mock('@backstage/plugin-techdocs-react', () => ({ + ...jest.requireActual('@backstage/plugin-techdocs-react'), + useShadowDomStylesLoading: (...args: any[]) => + useShadowDomStylesLoading(...args), + useShadowRootElements: jest.fn(), +})); + +import { TechDocsReaderContent } from './TechDocsReaderContent'; + +const mockEntityMetadata = { + locationMetadata: { + type: 'github', + target: 'https://example.com/', + }, + apiVersion: 'v1', + kind: 'test', + metadata: { + name: 'test-name', + namespace: 'test-namespace', + }, + spec: { + owner: 'test', + }, +}; + +const mockTechDocsMetadata = { + site_name: 'test-site-name', + site_description: 'test-site-desc', +}; + +const getEntityMetadata = jest.fn(); +const getTechDocsMetadata = jest.fn(); + +const techdocsApiMock = { + getEntityMetadata, + getTechDocsMetadata, +}; + +const Wrapper = ({ + entityRef = { + kind: mockEntityMetadata.kind, + name: mockEntityMetadata.metadata.name, + namespace: mockEntityMetadata.metadata.namespace!!, + }, + children, +}: { + entityRef?: CompoundEntityRef; + children: ReactNode; +}) => ( + + + {children} + + +); + +describe('', () => { + const useShadowRootElementsMock = useShadowRootElements as jest.Mock; + + beforeEach(() => { + useShadowRootElementsMock.mockReturnValue([]); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + it('should render techdocs content', async () => { + getEntityMetadata.mockResolvedValue(mockEntityMetadata); + getTechDocsMetadata.mockResolvedValue(mockTechDocsMetadata); + useTechDocsReaderDom.mockReturnValue(document.createElement('html')); + useReaderState.mockReturnValue({ state: 'cached' }); + + const rendered = await renderInTestApp( + + + , + ); + + await waitFor(() => { + expect( + rendered.getByTestId('techdocs-native-shadowroot'), + ).toBeInTheDocument(); + }); + }); + + it('should render with default path', async () => { + getEntityMetadata.mockResolvedValue(mockEntityMetadata); + getTechDocsMetadata.mockResolvedValue(mockTechDocsMetadata); + useTechDocsReaderDom.mockReturnValue(document.createElement('html')); + useReaderState.mockReturnValue({ state: 'cached' }); + + const defaultPath = '/some/path'; + + const rendered = await renderInTestApp( + + + , + ); + + await waitFor(() => { + expect( + rendered.getByTestId('techdocs-native-shadowroot'), + ).toBeInTheDocument(); + }); + + const entityRef = getCompoundEntityRef(mockEntityMetadata); + expect(useTechDocsReaderDom).toHaveBeenCalledWith(entityRef, defaultPath); + }); + + it('should not render content if entity metadata is missing', async () => { + getEntityMetadata.mockResolvedValue(undefined); + useTechDocsReaderDom.mockReturnValue(document.createElement('html')); + useReaderState.mockReturnValue({ state: 'cached' }); + + await expect( + renderInTestApp( + + + , + ), + ).rejects.toThrow('Reached NotFound Page'); + + const shadowRoot = document.querySelector( + '[data-testid="techdocs-native-shadowroot"]', + ); + expect(shadowRoot).not.toBeInTheDocument(); + }); + + it('should render 404 if there is no dom and reader state is not found', async () => { + getEntityMetadata.mockResolvedValue(mockEntityMetadata); + getTechDocsMetadata.mockResolvedValue(mockTechDocsMetadata); + useTechDocsReaderDom.mockReturnValue(undefined); + useReaderState.mockReturnValue({ state: 'CONTENT_NOT_FOUND' }); + + const rendered = await renderInTestApp( + + + , + ); + + await waitFor(() => { + expect( + rendered.queryByTestId('techdocs-native-shadowroot'), + ).not.toBeInTheDocument(); + expect( + rendered.getByText('ERROR 404: Documentation not found'), + ).toBeInTheDocument(); + }); + }); + + it('should render progress bar when content is loading', async () => { + getEntityMetadata.mockResolvedValue(mockEntityMetadata); + getTechDocsMetadata.mockResolvedValue(mockTechDocsMetadata); + useTechDocsReaderDom.mockReturnValue(document.createElement('html')); + useReaderState.mockReturnValue({ state: 'CHECKING' }); + + const rendered = await renderInTestApp( + + + , + ); + + await waitFor(() => { + expect(rendered.queryByRole('progressbar')).toBeInTheDocument(); + }); + }); + + it('should render progress bar when styles are loading', async () => { + getEntityMetadata.mockResolvedValue(mockEntityMetadata); + getTechDocsMetadata.mockResolvedValue(mockTechDocsMetadata); + useTechDocsReaderDom.mockReturnValue(document.createElement('html')); + useReaderState.mockReturnValue({ state: 'cached' }); + useShadowDomStylesLoading.mockReturnValue(true); + + const rendered = await renderInTestApp( + + + , + ); + + await waitFor(() => { + expect(rendered.queryByRole('progressbar')).toBeInTheDocument(); + }); + }); +}); diff --git a/plugins/techdocs/src/alpha/components/TechDocsReaderContent.tsx b/plugins/techdocs/src/alpha/components/TechDocsReaderContent.tsx new file mode 100644 index 0000000000..b84f066681 --- /dev/null +++ b/plugins/techdocs/src/alpha/components/TechDocsReaderContent.tsx @@ -0,0 +1,104 @@ +/* + * Copyright 2026 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 { useCallback, useEffect } from 'react'; +import { useLocation } from 'react-router-dom'; + +import { + TechDocsShadowDom, + useShadowDomStylesLoading, + useShadowRootElements, + useTechDocsReaderPage, +} from '@backstage/plugin-techdocs-react'; +import { Progress } from '@backstage/core-components'; + +import { TechDocsStateIndicator } from '../../reader/components/TechDocsStateIndicator'; + +import { useTechDocsReaderDom } from '../../reader/components/TechDocsReaderPageContent/dom'; +import { + useTechDocsReader, + withTechDocsReaderProvider, +} from '../../reader/components/TechDocsReaderProvider'; +import { TechDocsReaderPageContentAddons } from '../../reader/components/TechDocsReaderPageContent/TechDocsReaderPageContentAddons'; +import { useApp } from '@backstage/core-plugin-api'; +import type { TechDocsReaderPageContentProps } from '../../reader/components/TechDocsReaderPageContent/TechDocsReaderPageContent'; + +export const TechDocsReaderContent = withTechDocsReaderProvider( + (props: TechDocsReaderPageContentProps) => { + const { onReady } = props; + + const { + entityMetadata: { value: entityMetadata, loading: entityMetadataLoading }, + entityRef, + setShadowRoot, + } = useTechDocsReaderPage(); + const { state } = useTechDocsReader(); + const dom = useTechDocsReaderDom(entityRef, props.defaultPath); + const location = useLocation(); + const path = location.pathname; + const hash = location.hash; + const isStyleLoading = useShadowDomStylesLoading(dom); + const [hashElement] = useShadowRootElements([`[id="${hash.slice(1)}"]`]); + const app = useApp(); + const { NotFoundErrorPage } = app.getComponents(); + + useEffect(() => { + if (isStyleLoading) return; + + if (hash) { + if (hashElement) { + hashElement.scrollIntoView(); + const link = hashElement.querySelector('a.headerlink'); + if (link) { + link.focus(); + } + } + } else { + document?.querySelector('header')?.scrollIntoView(); + } + }, [path, hash, hashElement, isStyleLoading]); + + const handleAppend = useCallback( + (newShadowRoot: ShadowRoot) => { + setShadowRoot(newShadowRoot); + if (onReady instanceof Function) { + onReady(); + } + }, + [setShadowRoot, onReady], + ); + + // No entity metadata = 404. Don't render content at all. + if (entityMetadataLoading === false && !entityMetadata) + return ; + + // Do not return content until dom is ready; instead, render a state + // indicator, which handles progress and content errors on our behalf. + if (!dom) { + return ; + } + + return ( + <> + + {(state === 'CHECKING' || isStyleLoading) && } + + + + + ); + }, +); diff --git a/plugins/techdocs/src/alpha/components/TechDocsReaderEntityCard.module.css b/plugins/techdocs/src/alpha/components/TechDocsReaderEntityCard.module.css new file mode 100644 index 0000000000..14fa9082fb --- /dev/null +++ b/plugins/techdocs/src/alpha/components/TechDocsReaderEntityCard.module.css @@ -0,0 +1,36 @@ +.entityCard { + margin-bottom: var(--bui-space-6); +} + +.cardBody { + display: flex; + align-items: flex-start; + justify-content: space-between; + flex-wrap: wrap; + gap: var(--bui-space-4); +} + +.definitionList { + display: flex; + flex-wrap: wrap; + gap: var(--bui-space-6); + margin: 0; + padding: 0; + flex: 1 1 0; + align-self: center; +} + +.definitionGroup { + display: flex; + align-items: baseline; + gap: var(--bui-space-2); +} + +.definitionValue { + margin: 0; +} + +.searchWrapper { + flex: 1 1 200px; + max-width: 240px; +} diff --git a/plugins/techdocs/src/alpha/components/TechDocsReaderEntityCard.test.tsx b/plugins/techdocs/src/alpha/components/TechDocsReaderEntityCard.test.tsx new file mode 100644 index 0000000000..90fcc4b753 --- /dev/null +++ b/plugins/techdocs/src/alpha/components/TechDocsReaderEntityCard.test.tsx @@ -0,0 +1,215 @@ +/* + * Copyright 2026 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 { ReactNode } from 'react'; + +import { CompoundEntityRef, RELATION_OWNED_BY } from '@backstage/catalog-model'; +import { entityRouteRef } from '@backstage/plugin-catalog-react'; +import { + techdocsApiRef, + TechDocsReaderPageProvider, +} from '@backstage/plugin-techdocs-react'; +import { + renderInTestApp, + TestApiProvider, + mockApis, +} from '@backstage/test-utils'; +import { configApiRef } from '@backstage/core-plugin-api'; +import { searchApiRef, MockSearchApi } from '@backstage/plugin-search-react'; + +import { TechDocsReaderEntityCard } from './TechDocsReaderEntityCard'; + +const mockEntityMetadata = { + apiVersion: 'v1', + kind: 'Component', + metadata: { + name: 'test-component', + namespace: 'default', + title: 'Test Component Title', + }, + spec: { + owner: 'team-a', + lifecycle: 'production', + }, + relations: [ + { + type: RELATION_OWNED_BY, + targetRef: 'group:default/team-a', + }, + ], +}; + +const mockTechDocsMetadata = { + site_name: 'test-site-name', + site_description: 'test-site-desc', +}; + +const getEntityMetadata = jest.fn(); +const getTechDocsMetadata = jest.fn(); + +const techdocsApiMock = { + getEntityMetadata, + getTechDocsMetadata, +}; + +const Wrapper = ({ + entityRef = { + kind: mockEntityMetadata.kind, + name: mockEntityMetadata.metadata.name, + namespace: mockEntityMetadata.metadata.namespace!!, + }, + children, +}: { + entityRef?: CompoundEntityRef; + children: ReactNode; +}) => ( + + + {children} + + +); + +const mountedRoutes = { + '/catalog/:namespace/:kind/:name/*': entityRouteRef, +}; + +describe('', () => { + afterEach(() => { + jest.clearAllMocks(); + }); + + it('should render entity kind, owner, and lifecycle', async () => { + getEntityMetadata.mockResolvedValue(mockEntityMetadata); + getTechDocsMetadata.mockResolvedValue(mockTechDocsMetadata); + + const rendered = await renderInTestApp( + + + , + { mountedRoutes }, + ); + + expect(await rendered.findByText('Component:')).toBeInTheDocument(); + expect(rendered.getByText('Owner:')).toBeInTheDocument(); + expect(rendered.getByText('Lifecycle:')).toBeInTheDocument(); + expect(rendered.getByText('production')).toBeInTheDocument(); + }); + + it('should not render when entity metadata is loading', async () => { + // Don't resolve the metadata — it stays in loading state + getEntityMetadata.mockReturnValue(new Promise(() => {})); + getTechDocsMetadata.mockReturnValue(new Promise(() => {})); + + const rendered = await renderInTestApp( + + + , + { mountedRoutes }, + ); + + expect(rendered.queryByText('Component:')).not.toBeInTheDocument(); + }); + + it('should not render when entity metadata is missing', async () => { + getEntityMetadata.mockResolvedValue(undefined); + getTechDocsMetadata.mockResolvedValue(mockTechDocsMetadata); + + const rendered = await renderInTestApp( + + + , + { mountedRoutes }, + ); + + expect(rendered.queryByText('Component:')).not.toBeInTheDocument(); + }); + + it('should not render lifecycle when not present', async () => { + getEntityMetadata.mockResolvedValue({ + ...mockEntityMetadata, + spec: { owner: 'team-a' }, + }); + getTechDocsMetadata.mockResolvedValue(mockTechDocsMetadata); + + const rendered = await renderInTestApp( + + + , + { mountedRoutes }, + ); + + expect(await rendered.findByText('Component:')).toBeInTheDocument(); + expect(rendered.queryByText('Lifecycle:')).not.toBeInTheDocument(); + }); + + it('should not render owner when no owned-by relations exist', async () => { + getEntityMetadata.mockResolvedValue({ + ...mockEntityMetadata, + relations: [], + }); + getTechDocsMetadata.mockResolvedValue(mockTechDocsMetadata); + + const rendered = await renderInTestApp( + + + , + { mountedRoutes }, + ); + + expect(await rendered.findByText('Component:')).toBeInTheDocument(); + expect(rendered.queryByText('Owner:')).not.toBeInTheDocument(); + }); + + it('should render search when withSearch is true', async () => { + getEntityMetadata.mockResolvedValue(mockEntityMetadata); + getTechDocsMetadata.mockResolvedValue(mockTechDocsMetadata); + + const rendered = await renderInTestApp( + + + , + { mountedRoutes }, + ); + + expect(await rendered.findByText('Component:')).toBeInTheDocument(); + expect( + rendered.getByRole('searchbox', { name: 'Search docs' }), + ).toBeInTheDocument(); + }); + + it('should not render search when withSearch is false', async () => { + getEntityMetadata.mockResolvedValue(mockEntityMetadata); + getTechDocsMetadata.mockResolvedValue(mockTechDocsMetadata); + + const rendered = await renderInTestApp( + + + , + { mountedRoutes }, + ); + + expect(await rendered.findByText('Component:')).toBeInTheDocument(); + expect( + rendered.queryByRole('searchbox', { name: 'Search docs' }), + ).not.toBeInTheDocument(); + }); +}); diff --git a/plugins/techdocs/src/alpha/components/TechDocsReaderEntityCard.tsx b/plugins/techdocs/src/alpha/components/TechDocsReaderEntityCard.tsx new file mode 100644 index 0000000000..1448eb3fda --- /dev/null +++ b/plugins/techdocs/src/alpha/components/TechDocsReaderEntityCard.tsx @@ -0,0 +1,105 @@ +/* + * Copyright 2026 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 { useTechDocsReaderPage } from '@backstage/plugin-techdocs-react'; +import { + EntityRefLink, + EntityRefLinks, + getEntityRelations, +} from '@backstage/plugin-catalog-react'; +import { RELATION_OWNED_BY } from '@backstage/catalog-model'; +import { Card, CardBody, Text } from '@backstage/ui'; +import capitalize from 'lodash/capitalize'; +import styles from './TechDocsReaderEntityCard.module.css'; +import { TechDocsReaderSearch } from './TechDocsReaderSearch'; + +export type TechDocsReaderEntityCardProps = { + withSearch?: boolean; +}; + +export const TechDocsReaderEntityCard = ( + props: TechDocsReaderEntityCardProps, +) => { + const { withSearch } = props; + const { + entityRef, + entityMetadata: { value: entityMetadata, loading: entityMetadataLoading }, + } = useTechDocsReaderPage(); + + if (entityMetadataLoading || !entityMetadata) return null; + + const { spec } = entityMetadata; + const lifecycle = spec?.lifecycle; + const ownedByRelations = getEntityRelations( + entityMetadata, + RELATION_OWNED_BY, + ); + + return ( + + +
+
+
+ + {capitalize(entityMetadata.kind)}: + +
+
+ +
+
+ {ownedByRelations.length > 0 && ( +
+
+ + Owner: + +
+
+ +
+
+ )} + {lifecycle && ( +
+
+ + Lifecycle: + +
+
+ {String(lifecycle)} +
+
+ )} +
+ {withSearch && ( +
+ +
+ )} +
+
+ ); +}; diff --git a/plugins/techdocs/src/alpha/components/TechDocsReaderHeader.test.tsx b/plugins/techdocs/src/alpha/components/TechDocsReaderHeader.test.tsx new file mode 100644 index 0000000000..625ee832d9 --- /dev/null +++ b/plugins/techdocs/src/alpha/components/TechDocsReaderHeader.test.tsx @@ -0,0 +1,224 @@ +/* + * Copyright 2026 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 { ReactNode } from 'react'; +import { waitFor } from '@testing-library/react'; + +import { CompoundEntityRef } from '@backstage/catalog-model'; +import { + entityPresentationApiRef, + entityRouteRef, +} from '@backstage/plugin-catalog-react'; +import { + techdocsApiRef, + TechDocsReaderPageProvider, +} from '@backstage/plugin-techdocs-react'; +import { + renderInTestApp, + TestApiProvider, + mockApis, +} from '@backstage/test-utils'; +import { configApiRef } from '@backstage/core-plugin-api'; + +import { rootRouteRef } from '../../routes'; + +import { TechDocsReaderHeader } from './TechDocsReaderHeader'; + +const mockEntityMetadata = { + locationMetadata: { + type: 'github', + target: 'https://example.com/', + }, + apiVersion: 'v1', + kind: 'test', + metadata: { + name: 'test-name', + namespace: 'test-namespace', + }, + spec: { + owner: 'test', + }, +}; + +const mockTechDocsMetadata = { + site_name: 'test-site-name', + site_description: 'test-site-desc', +}; + +let useParamsPath = '/'; +jest.mock('react-router-dom', () => { + return { + ...(jest.requireActual('react-router-dom') as any), + useParams: () => ({ '*': useParamsPath }), + }; +}); + +const getEntityMetadata = jest.fn(); +const getTechDocsMetadata = jest.fn(); + +const techdocsApiMock = { + getEntityMetadata, + getTechDocsMetadata, +}; + +const forEntity = jest.fn(); + +forEntity.mockReturnValue({ + snapshot: { + primaryTitle: 'Test Entity', + }, +}); + +const entityPresentationApiMock = { + forEntity, +}; + +const configApiMock = mockApis.config(); + +const Wrapper = ({ + entityRef = { + kind: mockEntityMetadata.kind, + name: mockEntityMetadata.metadata.name, + namespace: mockEntityMetadata.metadata.namespace!!, + }, + children, +}: { + entityRef?: CompoundEntityRef; + children: ReactNode; +}) => ( + + + {children} + + +); + +const mountedRoutes = { + '/catalog/:namespace/:kind/:name/*': entityRouteRef, + '/docs': rootRouteRef, +}; + +describe('', () => { + beforeEach(() => { + useParamsPath = '/'; + jest.clearAllMocks(); + forEntity.mockReturnValue({ + snapshot: { primaryTitle: 'Test Entity' }, + }); + }); + + it('should render the header with site name as title', async () => { + getEntityMetadata.mockResolvedValue(mockEntityMetadata); + getTechDocsMetadata.mockResolvedValue(mockTechDocsMetadata); + + const rendered = await renderInTestApp( + + + , + { mountedRoutes }, + ); + + expect(await rendered.findByText('test-site-name')).toBeInTheDocument(); + }); + + it('should not render the header if entity metadata is missing', async () => { + getEntityMetadata.mockResolvedValue(undefined); + + const rendered = await renderInTestApp( + + + , + { mountedRoutes }, + ); + + expect(rendered.container.innerHTML).not.toContain('header'); + }); + + it('should not render the header if techdocs metadata is missing', async () => { + getTechDocsMetadata.mockResolvedValue(undefined); + + const rendered = await renderInTestApp( + + + , + { mountedRoutes }, + ); + + expect(rendered.container.innerHTML).not.toContain('header'); + }); + + it('should render a source link for remote docs', async () => { + getEntityMetadata.mockResolvedValue(mockEntityMetadata); + getTechDocsMetadata.mockResolvedValue(mockTechDocsMetadata); + + const rendered = await renderInTestApp( + + + , + { mountedRoutes }, + ); + + const sourceLink = await rendered.findByRole('link', { + name: 'View source', + }); + expect(sourceLink).toHaveAttribute('href', 'https://example.com/'); + expect(sourceLink).toHaveAttribute('target', '_blank'); + }); + + it('should not render a source link for local docs', async () => { + getEntityMetadata.mockResolvedValue({ + ...mockEntityMetadata, + locationMetadata: { type: 'dir', target: '/local/path' }, + }); + getTechDocsMetadata.mockResolvedValue(mockTechDocsMetadata); + + const rendered = await renderInTestApp( + + + , + { mountedRoutes }, + ); + + await rendered.findByText('test-site-name'); + expect( + rendered.queryByRole('link', { name: 'View source' }), + ).not.toBeInTheDocument(); + }); + + it('should set document title based on URL path segments', async () => { + getEntityMetadata.mockResolvedValue(mockEntityMetadata); + getTechDocsMetadata.mockResolvedValue(mockTechDocsMetadata); + + useParamsPath = 'foo/bar/baz/'; + await renderInTestApp( + + + , + { mountedRoutes }, + ); + + await waitFor(() => { + expect(document.title).toEqual( + 'Test Entity | Foo | Bar | Baz | Backstage', + ); + }); + }); +}); diff --git a/plugins/techdocs/src/alpha/components/TechDocsReaderHeader.tsx b/plugins/techdocs/src/alpha/components/TechDocsReaderHeader.tsx new file mode 100644 index 0000000000..2b6651cfb3 --- /dev/null +++ b/plugins/techdocs/src/alpha/components/TechDocsReaderHeader.tsx @@ -0,0 +1,121 @@ +/* + * Copyright 2026 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 { useEffect } from 'react'; +import Helmet from 'react-helmet'; +import { Header, ButtonLink } from '@backstage/ui'; +import { RiCodeLine } from '@remixicon/react'; +import { + TechDocsAddonLocations as locations, + useTechDocsAddons, + useTechDocsReaderPage, +} from '@backstage/plugin-techdocs-react'; +import { entityPresentationApiRef } from '@backstage/plugin-catalog-react'; +import { stringifyEntityRef } from '@backstage/catalog-model'; +import { configApiRef, useApi } from '@backstage/core-plugin-api'; +import capitalize from 'lodash/capitalize'; +import { useParams } from 'react-router-dom'; +import { TechDocsReaderSearch } from './TechDocsReaderSearch'; + +export type TechDocsReaderHeaderProps = { + withSearch?: boolean; +}; + +export const TechDocsReaderHeader = (props: TechDocsReaderHeaderProps) => { + const { withSearch = true } = props; + const addons = useTechDocsAddons(); + const configApi = useApi(configApiRef); + const entityPresentationApi = useApi(entityPresentationApiRef); + const { '*': path = '' } = useParams(); + + const { + title, + setTitle, + setSubtitle, + entityRef, + metadata: { value: metadata, loading: metadataLoading }, + entityMetadata: { value: entityMetadata, loading: entityMetadataLoading }, + } = useTechDocsReaderPage(); + + useEffect(() => { + if (!metadata) return; + setTitle(metadata.site_name); + setSubtitle(() => { + let { site_description } = metadata; + if (!site_description || site_description === 'None') { + site_description = ''; + } + return site_description; + }); + }, [metadata, setTitle, setSubtitle]); + + const appTitle = configApi.getOptional('app.title') || 'Backstage'; + const locationMetadata = entityMetadata?.locationMetadata; + const showSourceLink = + locationMetadata && + locationMetadata.type !== 'dir' && + locationMetadata.type !== 'file'; + + // Hide header on 404 pages + const noEntMetadata = !entityMetadataLoading && entityMetadata === undefined; + const noTdMetadata = !metadataLoading && metadata === undefined; + if (noEntMetadata || noTdMetadata) return null; + + const stringEntityRef = stringifyEntityRef(entityRef); + const entityDisplayName = + entityPresentationApi.forEntity(stringEntityRef).snapshot.primaryTitle; + + const removeTrailingSlash = (str: string) => str.replace(/\/$/, ''); + const normalizeAndSpace = (str: string) => + str.replace(/[-_]/g, ' ').split(' ').map(capitalize).join(' '); + + let techdocsTabTitleItems: string[] = []; + if (path !== '') + techdocsTabTitleItems = removeTrailingSlash(path) + .split('/') + .map(normalizeAndSpace); + + const tabTitleItems = [entityDisplayName, ...techdocsTabTitleItems, appTitle]; + const tabTitle = tabTitleItems.join(' | '); + + return ( + <> + + {tabTitle} + +
+ {withSearch && } + {showSourceLink && ( + } + aria-label="View source" + /> + )} + {addons.renderComponentsByLocation(locations.Header)} + + } + /> + + ); +}; diff --git a/plugins/techdocs/src/alpha/components/TechDocsReaderLayout.tsx b/plugins/techdocs/src/alpha/components/TechDocsReaderLayout.tsx new file mode 100644 index 0000000000..b43d9c6d14 --- /dev/null +++ b/plugins/techdocs/src/alpha/components/TechDocsReaderLayout.tsx @@ -0,0 +1,40 @@ +/* + * Copyright 2026 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 { Container } from '@backstage/ui'; +import { TechDocsReaderHeader } from './TechDocsReaderHeader'; +import { TechDocsReaderEntityCard } from './TechDocsReaderEntityCard'; +import { TechDocsReaderPageSubheader } from '../../reader/components/TechDocsReaderPageSubheader'; +import { TechDocsReaderContent } from './TechDocsReaderContent'; + +export type TechDocsReaderLayoutProps = { + withHeader?: boolean; + withSearch?: boolean; +}; + +export const TechDocsReaderLayout = (props: TechDocsReaderLayoutProps) => { + const { withSearch, withHeader = true } = props; + return ( + <> + {withHeader && } + + + + + + + ); +}; diff --git a/plugins/techdocs/src/alpha/components/TechDocsReaderSearch.test.tsx b/plugins/techdocs/src/alpha/components/TechDocsReaderSearch.test.tsx new file mode 100644 index 0000000000..d5b3bbfb67 --- /dev/null +++ b/plugins/techdocs/src/alpha/components/TechDocsReaderSearch.test.tsx @@ -0,0 +1,73 @@ +/* + * Copyright 2026 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 { screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; + +import { searchApiRef, MockSearchApi } from '@backstage/plugin-search-react'; +import { renderInTestApp, TestApiProvider } from '@backstage/test-utils'; + +import { TechDocsReaderSearch } from './TechDocsReaderSearch'; + +const entityId = { + kind: 'Component', + name: 'test-component', + namespace: 'default', +}; + +const mockSearchApi = new MockSearchApi({ + results: [ + { + type: 'techdocs', + document: { + location: '/docs/default/component/test-component/getting-started', + title: 'Getting Started', + text: 'This guide helps you get started.', + }, + }, + ], +}); + +describe('', () => { + it('should render the search input', async () => { + await renderInTestApp( + + + , + ); + + expect( + screen.getByRole('searchbox', { name: 'Search docs' }), + ).toBeInTheDocument(); + }); + + it('should render search results when user types', async () => { + const user = userEvent.setup(); + + await renderInTestApp( + + + , + ); + + const input = screen.getByRole('searchbox', { name: 'Search docs' }); + await user.type(input, 'getting'); + + expect(await screen.findByText('Getting Started')).toBeInTheDocument(); + expect( + screen.getByText('This guide helps you get started.'), + ).toBeInTheDocument(); + }); +}); diff --git a/plugins/techdocs/src/alpha/components/TechDocsReaderSearch.tsx b/plugins/techdocs/src/alpha/components/TechDocsReaderSearch.tsx new file mode 100644 index 0000000000..ebe3f8ca43 --- /dev/null +++ b/plugins/techdocs/src/alpha/components/TechDocsReaderSearch.tsx @@ -0,0 +1,172 @@ +/* + * Copyright 2026 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 { useEffect, useRef, useState } from 'react'; +import { useNavigate } from 'react-router-dom'; +import { + Text, + Flex, + SearchAutocomplete, + SearchAutocompleteItem, +} from '@backstage/ui'; +import { + SearchContextProvider, + useSearch, +} from '@backstage/plugin-search-react'; +import { CompoundEntityRef } from '@backstage/catalog-model'; +import { ResultHighlight } from '@backstage/plugin-search-common'; +import { HighlightedSearchResultText } from '@backstage/plugin-search-react'; + +type TechDocsDoc = { + namespace: string; + kind: string; + name: string; + path: string; + location: string; + title: string; + text: string; +}; + +type TechDocsSearchResult = { + type: string; + document: TechDocsDoc; + highlight?: ResultHighlight; +}; + +type TechDocsReaderSearchBarProps = { + entityId: CompoundEntityRef; +}; + +const TechDocsReaderSearchBar = (props: TechDocsReaderSearchBarProps) => { + const { entityId } = props; + const navigate = useNavigate(); + const { + setFilters, + setTerm, + term, + result: { loading, value: searchVal }, + } = useSearch(); + const [results, setResults] = useState([]); + const [deferredLoading, setDeferredLoading] = useState(false); + const loadingTimer = useRef>(); + + useEffect(() => { + if (searchVal) { + setResults(searchVal.results.slice(0, 10) as TechDocsSearchResult[]); + } + }, [loading, searchVal]); + + useEffect(() => { + clearTimeout(loadingTimer.current); + setDeferredLoading(false); + if (loading) { + loadingTimer.current = setTimeout(() => setDeferredLoading(true), 200); + } + return () => clearTimeout(loadingTimer.current); + }, [term, loading]); + + const { kind, name, namespace } = entityId; + useEffect(() => { + setFilters(prevFilters => ({ + ...prevFilters, + kind, + namespace, + name, + })); + }, [kind, namespace, name, setFilters]); + + return ( + + {results.map((result, index) => ( + { + setTerm(''); + navigate(result.document.location); + requestAnimationFrame(() => { + (document.activeElement as HTMLElement)?.blur(); + }); + }} + > + + + {result.highlight?.fields.title ? ( + + ) : ( + result.document.title + )} + + {(result.highlight?.fields.text || result.document.text) && ( + + {result.highlight?.fields.text ? ( + + ) : ( + result.document.text + )} + + )} + + + ))} + + ); +}; + +export type TechDocsReaderSearchProps = { + entityId: CompoundEntityRef; +}; + +export const TechDocsReaderSearch = (props: TechDocsReaderSearchProps) => { + const initialState = { + term: '', + types: ['techdocs'], + pageCursor: '', + filters: props.entityId, + }; + return ( + + + + ); +}; diff --git a/plugins/techdocs/src/alpha/index.tsx b/plugins/techdocs/src/alpha/index.tsx index 1ec92edfd3..9f68fd7907 100644 --- a/plugins/techdocs/src/alpha/index.tsx +++ b/plugins/techdocs/src/alpha/index.tsx @@ -15,12 +15,13 @@ */ import { Suspense } from 'react'; -import LibraryBooks from '@material-ui/icons/LibraryBooks'; +import { RiArticleLine } from '@remixicon/react'; import { createFrontendPlugin, ApiBlueprint, PageBlueprint, NavItemBlueprint, + PluginHeaderActionBlueprint, createExtensionInput, coreExtensionData, createExtension, @@ -46,7 +47,7 @@ import { rootDocsRouteRef, rootRouteRef, } from '../routes'; -import { TechDocsReaderLayout } from '../reader'; +import { TechDocsReaderLayout } from './components/TechDocsReaderLayout'; import { TechDocsAddons, techdocsApiRef, @@ -54,7 +55,7 @@ import { } from '@backstage/plugin-techdocs-react'; import { useTechdocsReaderIconLinkProps } from './hooks/useTechdocsReaderIconLinkProps'; -import { DocsIcon } from '@backstage/core-components'; +import { DocsIcon, SupportButton } from '@backstage/core-components'; /** @alpha */ const techdocsEntityIconLink = EntityIconLinkBlueprint.make({ @@ -140,8 +141,8 @@ const techDocsPage = PageBlueprint.make({ path: '/docs', routeRef: rootRouteRef, loader: () => - import('../home/components/TechDocsIndexPage').then(m => ( - + import('./components/TechDocsIndexPageContent').then(m => ( + )), }, }); @@ -270,23 +271,33 @@ const techDocsEntityContentEmptyState = createExtension({ /** @alpha */ const techDocsNavItem = NavItemBlueprint.make({ params: { - icon: LibraryBooks, + icon: () => , title: 'Docs', routeRef: rootRouteRef, }, }); +const techDocsSupportAction = PluginHeaderActionBlueprint.make({ + params: defineParams => + defineParams({ + loader: async () => ( + Discover documentation in your ecosystem. + ), + }), +}); + /** @alpha */ export default createFrontendPlugin({ pluginId: 'techdocs', - title: 'Docs', - icon: , + title: 'Documentation', + icon: , info: { packageJson: () => import('../../package.json') }, extensions: [ techDocsClientApi, techDocsStorageApi, TechDocsAddonsApiExtension, techDocsNavItem, + techDocsSupportAction, techDocsPage, techDocsReaderPage, techdocsEntityIconLink, diff --git a/yarn.lock b/yarn.lock index fd201d7eea..97edc5ef66 100644 --- a/yarn.lock +++ b/yarn.lock @@ -7657,11 +7657,13 @@ __metadata: "@backstage/plugin-techdocs-react": "workspace:^" "@backstage/test-utils": "workspace:^" "@backstage/theme": "workspace:^" + "@backstage/ui": "workspace:^" "@material-ui/core": "npm:^4.12.2" "@material-ui/icons": "npm:^4.9.1" "@material-ui/lab": "npm:4.0.0-alpha.61" "@material-ui/styles": "npm:^4.10.0" "@microsoft/fetch-event-source": "npm:^2.0.1" + "@remixicon/react": "npm:^4.6.0" "@testing-library/dom": "npm:^10.0.0" "@testing-library/jest-dom": "npm:^6.0.0" "@testing-library/react": "npm:^16.0.0"