feat(techdocs): migrate alpha plugin pages to BUI header system
Signed-off-by: Johan Persson <johanopersson@gmail.com>
This commit is contained in:
@@ -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`.
|
||||
@@ -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",
|
||||
|
||||
@@ -448,6 +448,19 @@ const _default: OverridableFrontendPlugin<
|
||||
noHeader?: boolean;
|
||||
};
|
||||
}>;
|
||||
'plugin-header-action:techdocs': OverridableExtensionDefinition<{
|
||||
kind: 'plugin-header-action';
|
||||
name: undefined;
|
||||
config: {};
|
||||
configInput: {};
|
||||
output: ExtensionDataRef<JSX_2.Element, 'core.reactElement', {}>;
|
||||
inputs: {};
|
||||
params: (params: {
|
||||
loader: () => Promise<JSX.Element>;
|
||||
}) => ExtensionBlueprintParams<{
|
||||
loader: () => Promise<JSX.Element>;
|
||||
}>;
|
||||
}>;
|
||||
'search-result-list-item:techdocs': OverridableExtensionDefinition<{
|
||||
config: {
|
||||
title: string | undefined;
|
||||
|
||||
@@ -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('<TechDocsIndexPageContent />', () => {
|
||||
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(
|
||||
<ApiProvider apis={apiRegistry}>
|
||||
<TechDocsIndexPageContent />
|
||||
</ApiProvider>,
|
||||
{
|
||||
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();
|
||||
});
|
||||
});
|
||||
@@ -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 (
|
||||
<Container mt="6">
|
||||
<EntityListProvider pagination={pagination}>
|
||||
<CatalogFilterLayout>
|
||||
<CatalogFilterLayout.Filters>
|
||||
<TechDocsPicker />
|
||||
<UserListPicker initialFilter={initialFilter} />
|
||||
<EntityOwnerPicker mode={ownerPickerMode} />
|
||||
<EntityTagPicker />
|
||||
</CatalogFilterLayout.Filters>
|
||||
<CatalogFilterLayout.Content>
|
||||
<EntityListDocsTable
|
||||
actions={actions}
|
||||
columns={columns}
|
||||
options={options}
|
||||
/>
|
||||
</CatalogFilterLayout.Content>
|
||||
</CatalogFilterLayout>
|
||||
</EntityListProvider>
|
||||
</Container>
|
||||
);
|
||||
};
|
||||
@@ -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;
|
||||
}) => (
|
||||
<TestApiProvider
|
||||
apis={[
|
||||
[techdocsApiRef, techdocsApiMock],
|
||||
[configApiRef, mockApis.config()],
|
||||
]}
|
||||
>
|
||||
<TechDocsReaderPageProvider entityRef={entityRef}>
|
||||
{children}
|
||||
</TechDocsReaderPageProvider>
|
||||
</TestApiProvider>
|
||||
);
|
||||
|
||||
describe('<TechDocsReaderContent />', () => {
|
||||
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(
|
||||
<Wrapper>
|
||||
<TechDocsReaderContent />
|
||||
</Wrapper>,
|
||||
);
|
||||
|
||||
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(
|
||||
<Wrapper>
|
||||
<TechDocsReaderContent defaultPath={defaultPath} />
|
||||
</Wrapper>,
|
||||
);
|
||||
|
||||
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(
|
||||
<Wrapper>
|
||||
<TechDocsReaderContent />
|
||||
</Wrapper>,
|
||||
),
|
||||
).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(
|
||||
<Wrapper>
|
||||
<TechDocsReaderContent />
|
||||
</Wrapper>,
|
||||
);
|
||||
|
||||
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(
|
||||
<Wrapper>
|
||||
<TechDocsReaderContent />
|
||||
</Wrapper>,
|
||||
);
|
||||
|
||||
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(
|
||||
<Wrapper>
|
||||
<TechDocsReaderContent />
|
||||
</Wrapper>,
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(rendered.queryByRole('progressbar')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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<HTMLElement>('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 <NotFoundErrorPage />;
|
||||
|
||||
// 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 <TechDocsStateIndicator />;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<TechDocsStateIndicator />
|
||||
{(state === 'CHECKING' || isStyleLoading) && <Progress />}
|
||||
<TechDocsShadowDom element={dom} onAppend={handleAppend}>
|
||||
<TechDocsReaderPageContentAddons />
|
||||
</TechDocsShadowDom>
|
||||
</>
|
||||
);
|
||||
},
|
||||
);
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}) => (
|
||||
<TestApiProvider
|
||||
apis={[
|
||||
[techdocsApiRef, techdocsApiMock],
|
||||
[configApiRef, mockApis.config()],
|
||||
[searchApiRef, new MockSearchApi()],
|
||||
]}
|
||||
>
|
||||
<TechDocsReaderPageProvider entityRef={entityRef}>
|
||||
{children}
|
||||
</TechDocsReaderPageProvider>
|
||||
</TestApiProvider>
|
||||
);
|
||||
|
||||
const mountedRoutes = {
|
||||
'/catalog/:namespace/:kind/:name/*': entityRouteRef,
|
||||
};
|
||||
|
||||
describe('<TechDocsReaderEntityCard />', () => {
|
||||
afterEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
it('should render entity kind, owner, and lifecycle', async () => {
|
||||
getEntityMetadata.mockResolvedValue(mockEntityMetadata);
|
||||
getTechDocsMetadata.mockResolvedValue(mockTechDocsMetadata);
|
||||
|
||||
const rendered = await renderInTestApp(
|
||||
<Wrapper>
|
||||
<TechDocsReaderEntityCard />
|
||||
</Wrapper>,
|
||||
{ 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(
|
||||
<Wrapper>
|
||||
<TechDocsReaderEntityCard />
|
||||
</Wrapper>,
|
||||
{ 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(
|
||||
<Wrapper>
|
||||
<TechDocsReaderEntityCard />
|
||||
</Wrapper>,
|
||||
{ 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(
|
||||
<Wrapper>
|
||||
<TechDocsReaderEntityCard />
|
||||
</Wrapper>,
|
||||
{ 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(
|
||||
<Wrapper>
|
||||
<TechDocsReaderEntityCard />
|
||||
</Wrapper>,
|
||||
{ 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(
|
||||
<Wrapper>
|
||||
<TechDocsReaderEntityCard withSearch />
|
||||
</Wrapper>,
|
||||
{ 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(
|
||||
<Wrapper>
|
||||
<TechDocsReaderEntityCard />
|
||||
</Wrapper>,
|
||||
{ mountedRoutes },
|
||||
);
|
||||
|
||||
expect(await rendered.findByText('Component:')).toBeInTheDocument();
|
||||
expect(
|
||||
rendered.queryByRole('searchbox', { name: 'Search docs' }),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -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 (
|
||||
<Card className={styles.entityCard}>
|
||||
<CardBody className={styles.cardBody}>
|
||||
<dl className={styles.definitionList}>
|
||||
<div className={styles.definitionGroup}>
|
||||
<dt>
|
||||
<Text variant="body-small" weight="bold">
|
||||
{capitalize(entityMetadata.kind)}:
|
||||
</Text>
|
||||
</dt>
|
||||
<dd className={styles.definitionValue}>
|
||||
<EntityRefLink
|
||||
entityRef={entityRef}
|
||||
title={entityMetadata.metadata.title}
|
||||
defaultKind="Component"
|
||||
/>
|
||||
</dd>
|
||||
</div>
|
||||
{ownedByRelations.length > 0 && (
|
||||
<div className={styles.definitionGroup}>
|
||||
<dt>
|
||||
<Text variant="body-small" weight="bold">
|
||||
Owner:
|
||||
</Text>
|
||||
</dt>
|
||||
<dd className={styles.definitionValue}>
|
||||
<EntityRefLinks
|
||||
entityRefs={ownedByRelations}
|
||||
defaultKind="group"
|
||||
/>
|
||||
</dd>
|
||||
</div>
|
||||
)}
|
||||
{lifecycle && (
|
||||
<div className={styles.definitionGroup}>
|
||||
<dt>
|
||||
<Text variant="body-small" weight="bold">
|
||||
Lifecycle:
|
||||
</Text>
|
||||
</dt>
|
||||
<dd className={styles.definitionValue}>
|
||||
<Text variant="body-small">{String(lifecycle)}</Text>
|
||||
</dd>
|
||||
</div>
|
||||
)}
|
||||
</dl>
|
||||
{withSearch && (
|
||||
<div className={styles.searchWrapper}>
|
||||
<TechDocsReaderSearch entityId={entityRef} />
|
||||
</div>
|
||||
)}
|
||||
</CardBody>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
@@ -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;
|
||||
}) => (
|
||||
<TestApiProvider
|
||||
apis={[
|
||||
[techdocsApiRef, techdocsApiMock],
|
||||
[entityPresentationApiRef, entityPresentationApiMock],
|
||||
[configApiRef, configApiMock],
|
||||
]}
|
||||
>
|
||||
<TechDocsReaderPageProvider entityRef={entityRef}>
|
||||
{children}
|
||||
</TechDocsReaderPageProvider>
|
||||
</TestApiProvider>
|
||||
);
|
||||
|
||||
const mountedRoutes = {
|
||||
'/catalog/:namespace/:kind/:name/*': entityRouteRef,
|
||||
'/docs': rootRouteRef,
|
||||
};
|
||||
|
||||
describe('<TechDocsReaderHeader withSearch={false} />', () => {
|
||||
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(
|
||||
<Wrapper>
|
||||
<TechDocsReaderHeader withSearch={false} />
|
||||
</Wrapper>,
|
||||
{ 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(
|
||||
<Wrapper>
|
||||
<TechDocsReaderHeader withSearch={false} />
|
||||
</Wrapper>,
|
||||
{ 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(
|
||||
<Wrapper>
|
||||
<TechDocsReaderHeader withSearch={false} />
|
||||
</Wrapper>,
|
||||
{ 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(
|
||||
<Wrapper>
|
||||
<TechDocsReaderHeader withSearch={false} />
|
||||
</Wrapper>,
|
||||
{ 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(
|
||||
<Wrapper>
|
||||
<TechDocsReaderHeader withSearch={false} />
|
||||
</Wrapper>,
|
||||
{ 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(
|
||||
<Wrapper>
|
||||
<TechDocsReaderHeader withSearch={false} />
|
||||
</Wrapper>,
|
||||
{ mountedRoutes },
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(document.title).toEqual(
|
||||
'Test Entity | Foo | Bar | Baz | Backstage',
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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 (
|
||||
<>
|
||||
<Helmet titleTemplate="%s">
|
||||
<title>{tabTitle}</title>
|
||||
</Helmet>
|
||||
<Header
|
||||
title={title || ''}
|
||||
customActions={
|
||||
<>
|
||||
{withSearch && <TechDocsReaderSearch entityId={entityRef} />}
|
||||
{showSourceLink && (
|
||||
<ButtonLink
|
||||
href={locationMetadata.target}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
variant="secondary"
|
||||
size="small"
|
||||
iconStart={<RiCodeLine />}
|
||||
aria-label="View source"
|
||||
/>
|
||||
)}
|
||||
{addons.renderComponentsByLocation(locations.Header)}
|
||||
</>
|
||||
}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -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 && <TechDocsReaderHeader withSearch={withSearch} />}
|
||||
<Container mt="6">
|
||||
<TechDocsReaderEntityCard withSearch={!withHeader && withSearch} />
|
||||
<TechDocsReaderPageSubheader />
|
||||
<TechDocsReaderContent />
|
||||
</Container>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -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('<TechDocsReaderSearch />', () => {
|
||||
it('should render the search input', async () => {
|
||||
await renderInTestApp(
|
||||
<TestApiProvider apis={[[searchApiRef, mockSearchApi]]}>
|
||||
<TechDocsReaderSearch entityId={entityId} />
|
||||
</TestApiProvider>,
|
||||
);
|
||||
|
||||
expect(
|
||||
screen.getByRole('searchbox', { name: 'Search docs' }),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render search results when user types', async () => {
|
||||
const user = userEvent.setup();
|
||||
|
||||
await renderInTestApp(
|
||||
<TestApiProvider apis={[[searchApiRef, mockSearchApi]]}>
|
||||
<TechDocsReaderSearch entityId={entityId} />
|
||||
</TestApiProvider>,
|
||||
);
|
||||
|
||||
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();
|
||||
});
|
||||
});
|
||||
@@ -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<TechDocsSearchResult[]>([]);
|
||||
const [deferredLoading, setDeferredLoading] = useState(false);
|
||||
const loadingTimer = useRef<ReturnType<typeof setTimeout>>();
|
||||
|
||||
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 (
|
||||
<SearchAutocomplete
|
||||
aria-label="Search docs"
|
||||
placeholder="Search docs"
|
||||
size="small"
|
||||
inputValue={term}
|
||||
onInputChange={setTerm}
|
||||
isLoading={deferredLoading}
|
||||
popoverWidth="min(720px, calc(100vw - 32px))"
|
||||
popoverPlacement="bottom end"
|
||||
>
|
||||
{results.map((result, index) => (
|
||||
<SearchAutocompleteItem
|
||||
key={index}
|
||||
id={String(index)}
|
||||
textValue={result.document.title}
|
||||
onAction={() => {
|
||||
setTerm('');
|
||||
navigate(result.document.location);
|
||||
requestAnimationFrame(() => {
|
||||
(document.activeElement as HTMLElement)?.blur();
|
||||
});
|
||||
}}
|
||||
>
|
||||
<Flex direction="column" gap="1">
|
||||
<Text weight="bold">
|
||||
{result.highlight?.fields.title ? (
|
||||
<HighlightedSearchResultText
|
||||
text={result.highlight.fields.title}
|
||||
preTag={result.highlight.preTag}
|
||||
postTag={result.highlight.postTag}
|
||||
/>
|
||||
) : (
|
||||
result.document.title
|
||||
)}
|
||||
</Text>
|
||||
{(result.highlight?.fields.text || result.document.text) && (
|
||||
<Text
|
||||
variant="body-small"
|
||||
color="secondary"
|
||||
style={{
|
||||
display: '-webkit-box',
|
||||
WebkitLineClamp: 3,
|
||||
WebkitBoxOrient: 'vertical',
|
||||
overflow: 'hidden',
|
||||
}}
|
||||
>
|
||||
{result.highlight?.fields.text ? (
|
||||
<HighlightedSearchResultText
|
||||
text={result.highlight.fields.text}
|
||||
preTag={result.highlight.preTag}
|
||||
postTag={result.highlight.postTag}
|
||||
/>
|
||||
) : (
|
||||
result.document.text
|
||||
)}
|
||||
</Text>
|
||||
)}
|
||||
</Flex>
|
||||
</SearchAutocompleteItem>
|
||||
))}
|
||||
</SearchAutocomplete>
|
||||
);
|
||||
};
|
||||
|
||||
export type TechDocsReaderSearchProps = {
|
||||
entityId: CompoundEntityRef;
|
||||
};
|
||||
|
||||
export const TechDocsReaderSearch = (props: TechDocsReaderSearchProps) => {
|
||||
const initialState = {
|
||||
term: '',
|
||||
types: ['techdocs'],
|
||||
pageCursor: '',
|
||||
filters: props.entityId,
|
||||
};
|
||||
return (
|
||||
<SearchContextProvider initialState={initialState}>
|
||||
<TechDocsReaderSearchBar {...props} />
|
||||
</SearchContextProvider>
|
||||
);
|
||||
};
|
||||
@@ -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 => (
|
||||
<m.TechDocsIndexPage />
|
||||
import('./components/TechDocsIndexPageContent').then(m => (
|
||||
<m.TechDocsIndexPageContent />
|
||||
)),
|
||||
},
|
||||
});
|
||||
@@ -270,23 +271,33 @@ const techDocsEntityContentEmptyState = createExtension({
|
||||
/** @alpha */
|
||||
const techDocsNavItem = NavItemBlueprint.make({
|
||||
params: {
|
||||
icon: LibraryBooks,
|
||||
icon: () => <RiArticleLine />,
|
||||
title: 'Docs',
|
||||
routeRef: rootRouteRef,
|
||||
},
|
||||
});
|
||||
|
||||
const techDocsSupportAction = PluginHeaderActionBlueprint.make({
|
||||
params: defineParams =>
|
||||
defineParams({
|
||||
loader: async () => (
|
||||
<SupportButton>Discover documentation in your ecosystem.</SupportButton>
|
||||
),
|
||||
}),
|
||||
});
|
||||
|
||||
/** @alpha */
|
||||
export default createFrontendPlugin({
|
||||
pluginId: 'techdocs',
|
||||
title: 'Docs',
|
||||
icon: <LibraryBooks fontSize="inherit" />,
|
||||
title: 'Documentation',
|
||||
icon: <RiArticleLine />,
|
||||
info: { packageJson: () => import('../../package.json') },
|
||||
extensions: [
|
||||
techDocsClientApi,
|
||||
techDocsStorageApi,
|
||||
TechDocsAddonsApiExtension,
|
||||
techDocsNavItem,
|
||||
techDocsSupportAction,
|
||||
techDocsPage,
|
||||
techDocsReaderPage,
|
||||
techdocsEntityIconLink,
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user