Merge pull request #6709 from backstage/techdocs-common/case-sensitive-migration
[TechDocs] Beta!
This commit is contained in:
@@ -271,6 +271,11 @@ export const TechDocsPicker: () => null;
|
||||
const techdocsPlugin: BackstagePlugin<
|
||||
{
|
||||
root: RouteRef<undefined>;
|
||||
docRoot: RouteRef<{
|
||||
name: string;
|
||||
kind: string;
|
||||
namespace: string;
|
||||
}>;
|
||||
entityContent: RouteRef<undefined>;
|
||||
},
|
||||
{}
|
||||
@@ -374,7 +379,7 @@ export class TechDocsStorageClient implements TechDocsStorageApi {
|
||||
//
|
||||
// src/home/components/EntityListDocsTable.d.ts:11:5 - (ae-forgotten-export) The symbol "columnFactories" needs to be exported by the entry point index.d.ts
|
||||
// src/home/components/EntityListDocsTable.d.ts:12:5 - (ae-forgotten-export) The symbol "actionFactories" needs to be exported by the entry point index.d.ts
|
||||
// src/plugin.d.ts:24:5 - (ae-forgotten-export) The symbol "TabsConfig" needs to be exported by the entry point index.d.ts
|
||||
// src/plugin.d.ts:29:5 - (ae-forgotten-export) The symbol "TabsConfig" needs to be exported by the entry point index.d.ts
|
||||
|
||||
// (No @packageDocumentation comment for this package)
|
||||
```
|
||||
|
||||
Vendored
+7
@@ -26,6 +26,13 @@ export interface Config {
|
||||
*/
|
||||
builder: 'local' | 'external';
|
||||
|
||||
/**
|
||||
* Allows fallback to case-sensitive triplets in case of migration issues.
|
||||
* @visibility frontend
|
||||
* @see https://backstage.io/docs/features/techdocs/how-to-guides#how-to-migrate-from-techdocs-alpha-to-beta
|
||||
*/
|
||||
legacyUseCaseSensitiveTripletPaths?: boolean;
|
||||
|
||||
/**
|
||||
* @example http://localhost:7000/api/techdocs
|
||||
* @visibility frontend
|
||||
|
||||
@@ -18,11 +18,6 @@ import React from 'react';
|
||||
import { Entity } from '@backstage/catalog-model';
|
||||
import { useEntity } from '@backstage/plugin-catalog-react';
|
||||
import { Route, Routes } from 'react-router-dom';
|
||||
import {
|
||||
rootRouteRef,
|
||||
rootDocsRouteRef,
|
||||
rootCatalogDocsRouteRef,
|
||||
} from './routes';
|
||||
import { TechDocsIndexPage } from './home/components/TechDocsIndexPage';
|
||||
import { TechDocsPage as TechDocsReaderPage } from './reader/components/TechDocsPage';
|
||||
import { EntityPageDocs } from './EntityPageDocs';
|
||||
@@ -33,9 +28,9 @@ const TECHDOCS_ANNOTATION = 'backstage.io/techdocs-ref';
|
||||
export const Router = () => {
|
||||
return (
|
||||
<Routes>
|
||||
<Route path={`/${rootRouteRef.path}`} element={<TechDocsIndexPage />} />
|
||||
<Route path="/" element={<TechDocsIndexPage />} />
|
||||
<Route
|
||||
path={`/${rootDocsRouteRef.path}`}
|
||||
path="/:namespace/:kind/:name/*"
|
||||
element={<TechDocsReaderPage />}
|
||||
/>
|
||||
</Routes>
|
||||
@@ -58,10 +53,7 @@ export const EmbeddedDocsRouter = (_props: Props) => {
|
||||
|
||||
return (
|
||||
<Routes>
|
||||
<Route
|
||||
path={`/${rootCatalogDocsRouteRef.path}`}
|
||||
element={<EntityPageDocs entity={entity} />}
|
||||
/>
|
||||
<Route path="/*" element={<EntityPageDocs entity={entity} />} />
|
||||
</Routes>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -30,6 +30,7 @@ import {
|
||||
configApiRef,
|
||||
storageApiRef,
|
||||
} from '@backstage/core-plugin-api';
|
||||
import { rootDocsRouteRef } from '../../routes';
|
||||
|
||||
jest.mock('@backstage/plugin-catalog-react', () => {
|
||||
const actual = jest.requireActual('@backstage/plugin-catalog-react');
|
||||
@@ -73,6 +74,11 @@ describe('TechDocs Home', () => {
|
||||
<ApiProvider apis={apiRegistry}>
|
||||
<DefaultTechDocsHome />
|
||||
</ApiProvider>,
|
||||
{
|
||||
mountedRoutes: {
|
||||
'/docs/:namespace/:kind/:name/*': rootDocsRouteRef,
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
// Header
|
||||
|
||||
@@ -17,11 +17,36 @@
|
||||
import { wrapInTestApp } from '@backstage/test-utils';
|
||||
import { render } from '@testing-library/react';
|
||||
import React from 'react';
|
||||
import { configApiRef } from '@backstage/core-plugin-api';
|
||||
import { DocsCardGrid } from './DocsCardGrid';
|
||||
import { rootDocsRouteRef } from '../../routes';
|
||||
|
||||
// Hacky way to mock a specific boolean config value.
|
||||
const getOptionalBooleanMock = jest.fn().mockReturnValue(false);
|
||||
jest.mock('@backstage/core-plugin-api', () => ({
|
||||
...jest.requireActual('@backstage/core-plugin-api'),
|
||||
useApi: (apiRef: any) => {
|
||||
const actualUseApi = jest.requireActual(
|
||||
'@backstage/core-plugin-api',
|
||||
).useApi;
|
||||
const actualApi = actualUseApi(apiRef);
|
||||
if (apiRef === configApiRef) {
|
||||
const configReader = actualApi;
|
||||
configReader.getOptionalBoolean = getOptionalBooleanMock;
|
||||
return configReader;
|
||||
}
|
||||
|
||||
return actualApi;
|
||||
},
|
||||
}));
|
||||
|
||||
describe('Entity Docs Card Grid', () => {
|
||||
beforeEach(() => {
|
||||
jest.resetAllMocks();
|
||||
});
|
||||
|
||||
it('should render all entities passed ot it', async () => {
|
||||
const { findByText } = render(
|
||||
const { findByText, findAllByRole } = render(
|
||||
wrapInTestApp(
|
||||
<DocsCardGrid
|
||||
entities={[
|
||||
@@ -47,9 +72,58 @@ describe('Entity Docs Card Grid', () => {
|
||||
},
|
||||
]}
|
||||
/>,
|
||||
{
|
||||
mountedRoutes: {
|
||||
'/docs/:namespace/:kind/:name/*': rootDocsRouteRef,
|
||||
},
|
||||
},
|
||||
),
|
||||
);
|
||||
expect(await findByText('testName')).toBeInTheDocument();
|
||||
expect(await findByText('testName2')).toBeInTheDocument();
|
||||
const [button1, button2] = await findAllByRole('button');
|
||||
expect(button1.getAttribute('href')).toContain(
|
||||
'/docs/default/testkind/testname',
|
||||
);
|
||||
expect(button2.getAttribute('href')).toContain(
|
||||
'/docs/default/testkind2/testname2',
|
||||
);
|
||||
});
|
||||
|
||||
it('should fall back to case-sensitive links when configured', async () => {
|
||||
getOptionalBooleanMock.mockReturnValue(true);
|
||||
|
||||
const { findByRole } = render(
|
||||
wrapInTestApp(
|
||||
<DocsCardGrid
|
||||
entities={[
|
||||
{
|
||||
apiVersion: 'version',
|
||||
kind: 'TestKind',
|
||||
metadata: {
|
||||
name: 'testName',
|
||||
namespace: 'SomeNamespace',
|
||||
},
|
||||
spec: {
|
||||
owner: 'techdocs@example.com',
|
||||
},
|
||||
},
|
||||
]}
|
||||
/>,
|
||||
{
|
||||
mountedRoutes: {
|
||||
'/techdocs/:namespace/:kind/:name/*': rootDocsRouteRef,
|
||||
},
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
const button = await findByRole('button');
|
||||
expect(getOptionalBooleanMock).toHaveBeenCalledWith(
|
||||
'techdocs.legacyUseCaseSensitiveTripletPaths',
|
||||
);
|
||||
expect(button.getAttribute('href')).toContain(
|
||||
'/techdocs/SomeNamespace/TestKind/testName',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -15,9 +15,9 @@
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { generatePath } from 'react-router-dom';
|
||||
|
||||
import { Entity } from '@backstage/catalog-model';
|
||||
import { configApiRef, useApi, useRouteRef } from '@backstage/core-plugin-api';
|
||||
import { Card, CardActions, CardContent, CardMedia } from '@material-ui/core';
|
||||
import { rootDocsRouteRef } from '../../routes';
|
||||
|
||||
@@ -32,6 +32,15 @@ export const DocsCardGrid = ({
|
||||
}: {
|
||||
entities: Entity[] | undefined;
|
||||
}) => {
|
||||
const getRouteToReaderPageFor = useRouteRef(rootDocsRouteRef);
|
||||
|
||||
// Lower-case entity triplets by default, but allow override.
|
||||
const toLowerMaybe = useApi(configApiRef).getOptionalBoolean(
|
||||
'techdocs.legacyUseCaseSensitiveTripletPaths',
|
||||
)
|
||||
? (str: string) => str
|
||||
: (str: string) => str.toLocaleLowerCase();
|
||||
|
||||
if (!entities) return null;
|
||||
return (
|
||||
<ItemCardGrid data-testid="docs-explore">
|
||||
@@ -45,10 +54,12 @@ export const DocsCardGrid = ({
|
||||
<CardContent>{entity.metadata.description}</CardContent>
|
||||
<CardActions>
|
||||
<Button
|
||||
to={generatePath(rootDocsRouteRef.path, {
|
||||
namespace: entity.metadata.namespace ?? 'default',
|
||||
kind: entity.kind,
|
||||
name: entity.metadata.name,
|
||||
to={getRouteToReaderPageFor({
|
||||
namespace: toLowerMaybe(
|
||||
entity.metadata.namespace ?? 'default',
|
||||
),
|
||||
kind: toLowerMaybe(entity.kind),
|
||||
name: toLowerMaybe(entity.metadata.name),
|
||||
})}
|
||||
color="primary"
|
||||
>
|
||||
|
||||
@@ -16,9 +16,34 @@
|
||||
import React from 'react';
|
||||
import { render } from '@testing-library/react';
|
||||
import { wrapInTestApp } from '@backstage/test-utils';
|
||||
import { configApiRef } from '@backstage/core-plugin-api';
|
||||
import { DocsTable } from './DocsTable';
|
||||
import { rootDocsRouteRef } from '../../routes';
|
||||
|
||||
// Hacky way to mock a specific boolean config value.
|
||||
const getOptionalBooleanMock = jest.fn().mockReturnValue(false);
|
||||
jest.mock('@backstage/core-plugin-api', () => ({
|
||||
...jest.requireActual('@backstage/core-plugin-api'),
|
||||
useApi: (apiRef: any) => {
|
||||
const actualUseApi = jest.requireActual(
|
||||
'@backstage/core-plugin-api',
|
||||
).useApi;
|
||||
const actualApi = actualUseApi(apiRef);
|
||||
if (apiRef === configApiRef) {
|
||||
const configReader = actualApi;
|
||||
configReader.getOptionalBoolean = getOptionalBooleanMock;
|
||||
return configReader;
|
||||
}
|
||||
|
||||
return actualApi;
|
||||
},
|
||||
}));
|
||||
|
||||
describe('DocsTable test', () => {
|
||||
beforeEach(() => {
|
||||
jest.resetAllMocks();
|
||||
});
|
||||
|
||||
it('should render documents passed', async () => {
|
||||
const { findByText } = render(
|
||||
wrapInTestApp(
|
||||
@@ -66,15 +91,81 @@ describe('DocsTable test', () => {
|
||||
},
|
||||
]}
|
||||
/>,
|
||||
{
|
||||
mountedRoutes: {
|
||||
'/docs/:namespace/:kind/:name/*': rootDocsRouteRef,
|
||||
},
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
expect(await findByText('testName')).toBeInTheDocument();
|
||||
expect(await findByText('testName2')).toBeInTheDocument();
|
||||
const link1 = await findByText('testName');
|
||||
const link2 = await findByText('testName2');
|
||||
expect(link1).toBeInTheDocument();
|
||||
expect(link1.getAttribute('href')).toContain(
|
||||
'/docs/default/testkind/testname',
|
||||
);
|
||||
expect(link2).toBeInTheDocument();
|
||||
expect(link2.getAttribute('href')).toContain(
|
||||
'/docs/default/testkind2/testname2',
|
||||
);
|
||||
});
|
||||
|
||||
it('should fall back to case-sensitive links when configured', async () => {
|
||||
getOptionalBooleanMock.mockReturnValue(true);
|
||||
|
||||
const { findByText } = render(
|
||||
wrapInTestApp(
|
||||
<DocsTable
|
||||
entities={[
|
||||
{
|
||||
apiVersion: 'version',
|
||||
kind: 'TestKind',
|
||||
metadata: {
|
||||
name: 'testName',
|
||||
namespace: 'SomeNamespace',
|
||||
},
|
||||
spec: {
|
||||
owner: 'user:owned',
|
||||
},
|
||||
relations: [
|
||||
{
|
||||
target: {
|
||||
kind: 'user',
|
||||
namespace: 'default',
|
||||
name: 'owned',
|
||||
},
|
||||
type: 'ownedBy',
|
||||
},
|
||||
],
|
||||
},
|
||||
]}
|
||||
/>,
|
||||
{
|
||||
mountedRoutes: {
|
||||
'/techdocs/:namespace/:kind/:name/*': rootDocsRouteRef,
|
||||
},
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
const button = await findByText('testName');
|
||||
expect(getOptionalBooleanMock).toHaveBeenCalledWith(
|
||||
'techdocs.legacyUseCaseSensitiveTripletPaths',
|
||||
);
|
||||
expect(button.getAttribute('href')).toContain(
|
||||
'/techdocs/SomeNamespace/TestKind/testName',
|
||||
);
|
||||
});
|
||||
|
||||
it('should render empty state if no owned documents exist', async () => {
|
||||
const { findByText } = render(wrapInTestApp(<DocsTable entities={[]} />));
|
||||
const { findByText } = render(
|
||||
wrapInTestApp(<DocsTable entities={[]} />, {
|
||||
mountedRoutes: {
|
||||
'/docs/:namespace/:kind/:name/*': rootDocsRouteRef,
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
expect(await findByText('No documents to show')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
@@ -16,8 +16,8 @@
|
||||
|
||||
import React from 'react';
|
||||
import { useCopyToClipboard } from 'react-use';
|
||||
import { generatePath } from 'react-router-dom';
|
||||
|
||||
import { configApiRef, useApi, useRouteRef } from '@backstage/core-plugin-api';
|
||||
import { Entity, RELATION_OWNED_BY } from '@backstage/catalog-model';
|
||||
import {
|
||||
formatEntityRefTitle,
|
||||
@@ -49,6 +49,14 @@ export const DocsTable = ({
|
||||
actions?: TableProps<DocsTableRow>['actions'];
|
||||
}) => {
|
||||
const [, copyToClipboard] = useCopyToClipboard();
|
||||
const getRouteToReaderPageFor = useRouteRef(rootDocsRouteRef);
|
||||
|
||||
// Lower-case entity triplets by default, but allow override.
|
||||
const toLowerMaybe = useApi(configApiRef).getOptionalBoolean(
|
||||
'techdocs.legacyUseCaseSensitiveTripletPaths',
|
||||
)
|
||||
? (str: string) => str
|
||||
: (str: string) => str.toLocaleLowerCase();
|
||||
|
||||
if (!entities) return null;
|
||||
|
||||
@@ -58,10 +66,10 @@ export const DocsTable = ({
|
||||
return {
|
||||
entity,
|
||||
resolved: {
|
||||
docsUrl: generatePath(rootDocsRouteRef.path, {
|
||||
namespace: entity.metadata.namespace ?? 'default',
|
||||
kind: entity.kind,
|
||||
name: entity.metadata.name,
|
||||
docsUrl: getRouteToReaderPageFor({
|
||||
namespace: toLowerMaybe(entity.metadata.namespace ?? 'default'),
|
||||
kind: toLowerMaybe(entity.kind),
|
||||
name: toLowerMaybe(entity.metadata.name),
|
||||
}),
|
||||
ownedByRelations,
|
||||
ownedByRelationsTitle: ownedByRelations
|
||||
|
||||
@@ -26,6 +26,7 @@ import {
|
||||
ConfigReader,
|
||||
} from '@backstage/core-app-api';
|
||||
import { ConfigApi, configApiRef } from '@backstage/core-plugin-api';
|
||||
import { rootDocsRouteRef } from '../../routes';
|
||||
|
||||
jest.mock('@backstage/plugin-catalog-react', () => {
|
||||
const actual = jest.requireActual('@backstage/plugin-catalog-react');
|
||||
@@ -68,6 +69,11 @@ describe('Legacy TechDocs Home', () => {
|
||||
<ApiProvider apis={apiRegistry}>
|
||||
<LegacyTechDocsHome />
|
||||
</ApiProvider>,
|
||||
{
|
||||
mountedRoutes: {
|
||||
'/docs/:namespace/:kind/:name/*': rootDocsRouteRef,
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
// Header
|
||||
|
||||
@@ -20,6 +20,7 @@ import { screen } from '@testing-library/react';
|
||||
import React from 'react';
|
||||
import { TechDocsCustomHome, PanelType } from './TechDocsCustomHome';
|
||||
import { ApiProvider, ApiRegistry } from '@backstage/core-app-api';
|
||||
import { rootDocsRouteRef } from '../../routes';
|
||||
|
||||
jest.mock('@backstage/plugin-catalog-react', () => {
|
||||
const actual = jest.requireActual('@backstage/plugin-catalog-react');
|
||||
@@ -78,6 +79,11 @@ describe('TechDocsCustomHome', () => {
|
||||
<ApiProvider apis={apiRegistry}>
|
||||
<TechDocsCustomHome tabsConfig={tabsConfig} />
|
||||
</ApiProvider>,
|
||||
{
|
||||
mountedRoutes: {
|
||||
'/docs/:namespace/:kind/:name/*': rootDocsRouteRef,
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
// Header
|
||||
|
||||
@@ -65,6 +65,7 @@ export const techdocsPlugin = createPlugin({
|
||||
],
|
||||
routes: {
|
||||
root: rootRouteRef,
|
||||
docRoot: rootDocsRouteRef,
|
||||
entityContent: rootCatalogDocsRouteRef,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -17,16 +17,17 @@
|
||||
import { createRouteRef } from '@backstage/core-plugin-api';
|
||||
|
||||
export const rootRouteRef = createRouteRef({
|
||||
path: '',
|
||||
id: 'techdocs-index-page',
|
||||
title: 'TechDocs Landing Page',
|
||||
});
|
||||
|
||||
export const rootDocsRouteRef = createRouteRef({
|
||||
path: ':namespace/:kind/:name/*',
|
||||
id: 'techdocs-reader-page',
|
||||
title: 'Docs',
|
||||
params: ['namespace', 'kind', 'name'],
|
||||
});
|
||||
|
||||
export const rootCatalogDocsRouteRef = createRouteRef({
|
||||
path: '*',
|
||||
id: 'catalog-techdocs-reader-view',
|
||||
title: 'Docs',
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user