Merge pull request #27853 from autodesk-forks/nikolarAutodesk/makeTechDocsCustomizable

[plugin-techdocs] Add props to increase flexibility and reusability
This commit is contained in:
John Philip
2025-01-20 03:25:59 -05:00
committed by GitHub
16 changed files with 761 additions and 32 deletions
@@ -52,15 +52,24 @@ export const DefaultTechDocsHome = (props: TechDocsIndexPageProps) => {
actions,
ownerPickerMode,
pagination,
options,
PageWrapper,
CustomHeader,
} = props;
const Wrapper: React.FC<{
children: React.ReactNode;
}> = PageWrapper ? PageWrapper : TechDocsPageWrapper;
const Header: React.FC =
CustomHeader ||
(() => (
<ContentHeader title="">
<SupportButton>Discover documentation in your ecosystem.</SupportButton>
</ContentHeader>
));
return (
<TechDocsPageWrapper>
<Wrapper>
<Content>
<ContentHeader title="">
<SupportButton>
Discover documentation in your ecosystem.
</SupportButton>
</ContentHeader>
<Header />
<EntityListProvider pagination={pagination}>
<CatalogFilterLayout>
<CatalogFilterLayout.Filters>
@@ -70,11 +79,15 @@ export const DefaultTechDocsHome = (props: TechDocsIndexPageProps) => {
<EntityTagPicker />
</CatalogFilterLayout.Filters>
<CatalogFilterLayout.Content>
<EntityListDocsTable actions={actions} columns={columns} />
<EntityListDocsTable
actions={actions}
columns={columns}
options={options}
/>
</CatalogFilterLayout.Content>
</CatalogFilterLayout>
</EntityListProvider>
</Content>
</TechDocsPageWrapper>
</Wrapper>
);
};
@@ -0,0 +1,192 @@
/*
* Copyright 2021 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { TestApiProvider, renderInTestApp } from '@backstage/test-utils';
import { screen } from '@testing-library/react';
import { entityPresentationApiRef } from '@backstage/plugin-catalog-react';
import { DefaultEntityPresentationApi } from '@backstage/plugin-catalog';
import { catalogApiMock } from '@backstage/plugin-catalog-react/testUtils';
import React from 'react';
import { rootDocsRouteRef } from '../../../routes';
import { InfoCardGrid } from './InfoCardGrid';
describe('Entity Info Card Grid', () => {
const catalogApi = catalogApiMock();
let Wrapper: React.ComponentType<React.PropsWithChildren<{}>>;
beforeEach(() => {
jest.resetAllMocks();
Wrapper = ({ children }: { children?: React.ReactNode }) => (
<TestApiProvider
apis={[
[
entityPresentationApiRef,
DefaultEntityPresentationApi.create({ catalogApi }),
],
]}
>
{children}
</TestApiProvider>
);
});
it('should render multiple entities', async () => {
await renderInTestApp(
<Wrapper>
<InfoCardGrid
entities={[
{
apiVersion: 'version',
kind: 'TestKind',
metadata: {
name: 'testName',
title: 'TestTitle',
},
spec: {
owner: 'techdocs@example.com',
},
},
{
apiVersion: 'version',
kind: 'TestKind2',
metadata: {
name: 'testName2',
title: 'TestTitle2',
},
spec: {
owner: 'techdocs2@example.com',
},
},
]}
/>
</Wrapper>,
{
mountedRoutes: {
'/docs/:namespace/:kind/:name/*': rootDocsRouteRef,
},
},
);
expect(await screen.findByText('TestTitle')).toBeInTheDocument();
expect(await screen.findByText('TestTitle2')).toBeInTheDocument();
});
it('should render links correctly', async () => {
await renderInTestApp(
<Wrapper>
<InfoCardGrid
entities={[
{
apiVersion: 'version',
kind: 'TestKind',
metadata: {
name: 'testName',
title: 'TestTitle',
},
spec: {
owner: 'techdocs@example.com',
},
},
{
apiVersion: 'version',
kind: 'TestKind2',
metadata: {
name: 'testName2',
title: 'TestTitle2',
},
spec: {
owner: 'techdocs2@example.com',
},
},
]}
/>
</Wrapper>,
{
mountedRoutes: {
'/docs/:namespace/:kind/:name/*': rootDocsRouteRef,
},
},
);
expect(await screen.findByText('TestTitle')).toBeInTheDocument();
expect(await screen.findByText('TestTitle2')).toBeInTheDocument();
const [button1, button2] = await screen.findAllByTestId('read-docs-link');
expect(button1.getAttribute('href')).toContain(
'/docs/default/testkind/testname',
);
expect(button2.getAttribute('href')).toContain(
'/docs/default/testkind2/testname2',
);
});
it('should render entity title if available', async () => {
await renderInTestApp(
<Wrapper>
<InfoCardGrid
entities={[
{
apiVersion: 'version',
kind: 'TestKind',
metadata: {
name: 'testName',
title: 'TestTitle',
},
spec: {
owner: 'techdocs@example.com',
},
},
]}
/>
</Wrapper>,
{
mountedRoutes: {
'/docs/:namespace/:kind/:name/*': rootDocsRouteRef,
},
},
);
expect(await screen.findByText('TestTitle')).toBeInTheDocument();
});
it('should render entity name if title is not available', async () => {
await renderInTestApp(
<Wrapper>
<InfoCardGrid
entities={[
{
apiVersion: 'version',
kind: 'TestKind',
metadata: {
name: 'testName',
},
spec: {
owner: 'techdocs@example.com',
},
},
]}
/>
</Wrapper>,
{
mountedRoutes: {
'/docs/:namespace/:kind/:name/*': rootDocsRouteRef,
},
},
);
expect(await screen.findByText('testName')).toBeInTheDocument();
});
});
@@ -0,0 +1,125 @@
/*
* Copyright 2021 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import React from 'react';
import useAsync from 'react-use/esm/useAsync';
import { Entity, stringifyEntityRef } from '@backstage/catalog-model';
import { useApi, useRouteRef, configApiRef } from '@backstage/core-plugin-api';
import {
ItemCardGrid,
InfoCard,
Link,
Progress,
} from '@backstage/core-components';
import {
EntityRefPresentationSnapshot,
entityPresentationApiRef,
} from '@backstage/plugin-catalog-react';
import { makeStyles } from '@material-ui/core/styles';
import { rootDocsRouteRef } from '../../../routes';
import { toLowerMaybe } from '../../../helpers';
/** @public */
export type InfoCardGridClassKey = 'linkSpacer' | 'readMoreLink';
const useStyles = makeStyles(
theme => ({
linkSpacer: {
paddingTop: theme.spacing(0.2),
},
readMoreLink: {
paddingTop: theme.spacing(0.2),
},
}),
{ name: 'BackstageInfoCardGrid' },
);
/**
* Props for {@link InfoCardGrid}
*
* @public
*/
export type InfoCardGridProps = {
entities: Entity[] | undefined;
linkContent?: string | JSX.Element;
linkDestination?: (entity: Entity) => string | undefined;
};
/**
* Component which accepts a list of entities and renders a info card for each entity
*
* @public
*/
export const InfoCardGrid = (props: InfoCardGridProps) => {
const { entities, linkContent, linkDestination } = props;
const classes = useStyles();
const getRouteToReaderPageFor = useRouteRef(rootDocsRouteRef);
const config = useApi(configApiRef);
const linkRoute = (entity: Entity) => {
if (linkDestination) {
const destination = linkDestination(entity);
if (destination) {
return destination;
}
}
return getRouteToReaderPageFor({
namespace: toLowerMaybe(entity.metadata.namespace ?? 'default', config),
kind: toLowerMaybe(entity.kind, config),
name: toLowerMaybe(entity.metadata.name, config),
});
};
const entityPresentationApi = useApi(entityPresentationApiRef);
const { value: entityRefToPresentation, loading } = useAsync(async () => {
return new Map<string, EntityRefPresentationSnapshot>(
await Promise.all(
entities?.map(async entity => {
const presentation = await entityPresentationApi.forEntity(entity)
.promise;
return [stringifyEntityRef(entity), presentation] as [
string,
EntityRefPresentationSnapshot,
];
}) || [],
),
);
});
if (loading) return <Progress />;
if (!entities || !entities?.length) return null;
return (
<ItemCardGrid data-testid="info-card-container">
{entities.map(entity => (
<InfoCard
key={entity.metadata.name}
data-testid={entity?.metadata?.title}
title={
entityRefToPresentation?.get(stringifyEntityRef(entity))
?.primaryTitle
}
>
<div>{entity?.metadata?.description}</div>
<div className={classes.linkSpacer} />
<Link
to={linkRoute(entity)}
className={classes.readMoreLink}
data-testid="read-docs-link"
>
{linkContent || 'Read Docs'}
</Link>
</InfoCard>
))}
</ItemCardGrid>
);
};
@@ -16,3 +16,4 @@
export * from './EntityListDocsGrid';
export * from './DocsCardGrid';
export * from './InfoCardGrid';
@@ -19,6 +19,7 @@ import {
starredEntitiesApiRef,
MockStarredEntitiesApi,
} from '@backstage/plugin-catalog-react';
import { ContentHeader, PageWithHeader } from '@backstage/core-components';
import { catalogApiMock } from '@backstage/plugin-catalog-react/testUtils';
import { renderInTestApp, TestApiRegistry } from '@backstage/test-utils';
import { screen } from '@testing-library/react';
@@ -103,4 +104,80 @@ describe('TechDocsCustomHome', () => {
await screen.findByText('Second Tab Description'),
).toBeInTheDocument();
});
it('should render ContentHeader based on CustomHeader prop', async () => {
const tabsConfig = [
{
label: 'First Tab',
panels: [
{
title: 'First Tab',
description: 'First Tab Description',
panelType: 'DocsCardGrid' as PanelType,
panelProps: {
CustomHeader: () => (
<ContentHeader
title="Custom Header"
description="useful docs"
/>
),
},
filterPredicate: () => true,
},
],
},
];
await renderInTestApp(
<ApiProvider apis={apiRegistry}>
<TechDocsCustomHome tabsConfig={tabsConfig} />
</ApiProvider>,
{
mountedRoutes: {
'/docs/:namespace/:kind/:name/*': rootDocsRouteRef,
},
},
);
expect(screen.getByText('Custom Header')).toBeInTheDocument();
});
it('should render CustomPageWrapper', async () => {
const tabsConfig = [
{
label: 'First Tab',
panels: [
{
title: 'First Tab',
description: 'First Tab Description',
panelType: 'DocsCardGrid' as PanelType,
filterPredicate: () => true,
},
],
},
];
await renderInTestApp(
<ApiProvider apis={apiRegistry}>
<TechDocsCustomHome
tabsConfig={tabsConfig}
CustomPageWrapper={({ children }: React.PropsWithChildren<{}>) => (
<PageWithHeader
title="Custom Title"
subtitle="Custom Subtitle"
themeId="documentation"
>
{children}
</PageWithHeader>
)}
/>
</ApiProvider>,
{
mountedRoutes: {
'/docs/:namespace/:kind/:name/*': rootDocsRouteRef,
},
},
);
expect(screen.getByText('Custom Title')).toBeInTheDocument();
expect(screen.getByText('Custom Subtitle')).toBeInTheDocument();
});
});
@@ -26,9 +26,10 @@ import {
EntityListProvider,
} from '@backstage/plugin-catalog-react';
import { Entity } from '@backstage/catalog-model';
import { DocsTable } from './Tables';
import { DocsCardGrid } from './Grids';
import { DocsTable, DocsTableRow } from './Tables';
import { DocsCardGrid, InfoCardGrid } from './Grids';
import { TechDocsPageWrapper } from './TechDocsPageWrapper';
import { TechDocsIndexPage } from './TechDocsIndexPage';
import {
CodeSnippet,
@@ -38,13 +39,17 @@ import {
WarningPanel,
SupportButton,
ContentHeader,
TableOptions,
} from '@backstage/core-components';
import { useApi } from '@backstage/core-plugin-api';
import { TECHDOCS_ANNOTATION } from '@backstage/plugin-techdocs-common';
import { EntityFilterQuery } from '@backstage/catalog-client';
const panels = {
DocsTable: DocsTable,
DocsCardGrid: DocsCardGrid,
TechDocsIndexPage: TechDocsIndexPage,
InfoCardGrid: InfoCardGrid,
};
/**
@@ -52,7 +57,24 @@ const panels = {
*
* @public
*/
export type PanelType = 'DocsCardGrid' | 'DocsTable';
export type PanelType =
| 'DocsCardGrid'
| 'DocsTable'
| 'TechDocsIndexPage'
| 'InfoCardGrid';
/**
* Type representing Panel props
*
* @public
*/
export interface PanelProps {
options?: TableOptions<DocsTableRow>;
linkContent?: string | JSX.Element;
linkDestination?: (entity: Entity) => string | undefined;
PageWrapper?: React.FC;
CustomHeader?: React.FC;
}
/**
* Type representing a TechDocsCustomHome panel.
@@ -65,6 +87,7 @@ export interface PanelConfig {
panelType: PanelType;
panelCSS?: CSSProperties;
filterPredicate: ((entity: Entity) => boolean) | string;
panelProps?: PanelProps;
}
/**
@@ -84,7 +107,12 @@ export interface TabConfig {
*/
export type TabsConfig = TabConfig[];
const CustomPanel = ({
/**
* Component which can be used to render entities in a custom way.
*
* @public
*/
export const CustomDocsPanel = ({
config,
entities,
index,
@@ -118,8 +146,9 @@ const CustomPanel = ({
);
});
return (
<>
const Header: React.FC =
config.panelProps?.CustomHeader ||
(() => (
<ContentHeader title={config.title} description={config.description}>
{index === 0 ? (
<SupportButton>
@@ -127,9 +156,18 @@ const CustomPanel = ({
</SupportButton>
) : null}
</ContentHeader>
));
return (
<>
<Header />
<div className={classes.panelContainer}>
<EntityListProvider>
<Panel data-testid="techdocs-custom-panel" entities={shownEntities} />
<Panel
data-testid="techdocs-custom-panel"
entities={shownEntities}
{...config.panelProps}
/>
</EntityListProvider>
</div>
</>
@@ -143,10 +181,12 @@ const CustomPanel = ({
*/
export type TechDocsCustomHomeProps = {
tabsConfig: TabsConfig;
filter?: EntityFilterQuery;
CustomPageWrapper?: React.FC;
};
export const TechDocsCustomHome = (props: TechDocsCustomHomeProps) => {
const { tabsConfig } = props;
const { tabsConfig, filter, CustomPageWrapper } = props;
const [selectedTab, setSelectedTab] = useState<number>(0);
const catalogApi: CatalogApi = useApi(catalogApiRef);
@@ -157,6 +197,7 @@ export const TechDocsCustomHome = (props: TechDocsCustomHomeProps) => {
} = useAsync(async () => {
const response = await catalogApi.getEntities({
filter: {
...filter,
[`metadata.annotations.${TECHDOCS_ANNOTATION}`]: CATALOG_FILTER_EXISTS,
},
fields: [
@@ -177,7 +218,7 @@ export const TechDocsCustomHome = (props: TechDocsCustomHomeProps) => {
if (loading) {
return (
<TechDocsPageWrapper>
<TechDocsPageWrapper CustomPageWrapper={CustomPageWrapper}>
<Content>
<Progress />
</Content>
@@ -187,7 +228,7 @@ export const TechDocsCustomHome = (props: TechDocsCustomHomeProps) => {
if (error) {
return (
<TechDocsPageWrapper>
<TechDocsPageWrapper CustomPageWrapper={CustomPageWrapper}>
<Content>
<WarningPanel
severity="error"
@@ -201,7 +242,7 @@ export const TechDocsCustomHome = (props: TechDocsCustomHomeProps) => {
}
return (
<TechDocsPageWrapper>
<TechDocsPageWrapper CustomPageWrapper={CustomPageWrapper}>
<HeaderTabs
selectedIndex={selectedTab}
onChange={index => setSelectedTab(index)}
@@ -212,7 +253,7 @@ export const TechDocsCustomHome = (props: TechDocsCustomHomeProps) => {
/>
<Content data-testid="techdocs-content">
{currentTabConfig.panels.map((config, index) => (
<CustomPanel
<CustomDocsPanel
key={index}
config={config}
entities={!!entities ? entities : []}
@@ -16,7 +16,11 @@
import React from 'react';
import { useOutlet } from 'react-router-dom';
import { TableColumn, TableProps } from '@backstage/core-components';
import {
TableColumn,
TableProps,
TableOptions,
} from '@backstage/core-components';
import {
EntityListPagination,
EntityOwnerPickerProps,
@@ -36,6 +40,9 @@ export type TechDocsIndexPageProps = {
actions?: TableProps<DocsTableRow>['actions'];
ownerPickerMode?: EntityOwnerPickerProps['mode'];
pagination?: EntityListPagination;
options?: TableOptions<DocsTableRow>;
PageWrapper?: React.FC;
CustomHeader?: React.FC;
};
export const TechDocsIndexPage = (props: TechDocsIndexPageProps) => {
@@ -26,6 +26,7 @@ import { useApi, configApiRef } from '@backstage/core-plugin-api';
*/
export type TechDocsPageWrapperProps = {
children?: React.ReactNode;
CustomPageWrapper?: React.FC<{ children?: React.ReactNode }>;
};
/**
@@ -34,19 +35,25 @@ export type TechDocsPageWrapperProps = {
* @public
*/
export const TechDocsPageWrapper = (props: TechDocsPageWrapperProps) => {
const { children } = props;
const { children, CustomPageWrapper } = props;
const configApi = useApi(configApiRef);
const generatedSubtitle = `Documentation available in ${
configApi.getOptionalString('organization.name') ?? 'Backstage'
}`;
return (
<PageWithHeader
title="Documentation"
subtitle={generatedSubtitle}
themeId="documentation"
>
{children}
</PageWithHeader>
<>
{CustomPageWrapper ? (
<CustomPageWrapper>{children}</CustomPageWrapper>
) : (
<PageWithHeader
title="Documentation"
subtitle={generatedSubtitle}
themeId="documentation"
>
{children}
</PageWithHeader>
)}
</>
);
};
@@ -20,10 +20,12 @@ export * from './DefaultTechDocsHome';
export type {
PanelType,
PanelConfig,
PanelProps,
TabConfig,
TabsConfig,
TechDocsCustomHomeProps,
} from './TechDocsCustomHome';
export { CustomDocsPanel } from './TechDocsCustomHome';
export type { TechDocsIndexPageProps } from './TechDocsIndexPage';
export * from './TechDocsPageWrapper';
export * from './TechDocsPicker';
+2
View File
@@ -67,3 +67,5 @@ export type {
DeprecatedTechDocsEntityMetadata as TechDocsEntityMetadata,
DeprecatedTechDocsMetadata as TechDocsMetadata,
};
export * from './overridableComponents';
@@ -0,0 +1,36 @@
/*
* Copyright 2021 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { Overrides } from '@material-ui/core/styles/overrides';
import { StyleRules } from '@material-ui/core/styles/withStyles';
import { InfoCardGridClassKey } from './home/components/Grids/InfoCardGrid';
/** @public */
export type CatalogReactComponentsNameToClassKey = {
BackstageInfoCardGrid: InfoCardGridClassKey;
};
/** @public */
export type BackstageOverrides = Overrides & {
[Name in keyof CatalogReactComponentsNameToClassKey]?: Partial<
StyleRules<CatalogReactComponentsNameToClassKey[Name]>
>;
};
declare module '@backstage/theme' {
interface OverrideComponentNameToClassKeys
extends CatalogReactComponentsNameToClassKey {}
}