Merge branch 'master' into techdoc_title
This commit is contained in:
@@ -21,6 +21,7 @@ import { Reader } from './reader';
|
||||
export const EntityPageDocs = ({ entity }: { entity: Entity }) => {
|
||||
return (
|
||||
<Reader
|
||||
withSearch={false}
|
||||
entityId={{
|
||||
kind: entity.kind,
|
||||
namespace: entity.metadata.namespace ?? 'default',
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -46,4 +46,17 @@ describe('DocsResultListItem test', () => {
|
||||
),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should use title if defined', async () => {
|
||||
const { findByText } = render(
|
||||
<DocsResultListItem result={validResult} title="Count Dookumentation" />,
|
||||
);
|
||||
|
||||
expect(await findByText('Count Dookumentation')).toBeInTheDocument();
|
||||
expect(
|
||||
await findByText(
|
||||
'Backstage is an open-source developer portal that puts the developer experience first.',
|
||||
),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import React, { PropsWithChildren } from 'react';
|
||||
import { Divider, ListItem, ListItemText, makeStyles } from '@material-ui/core';
|
||||
import { Link } from '@backstage/core-components';
|
||||
import TextTruncate from 'react-text-truncate';
|
||||
@@ -32,29 +32,53 @@ const useStyles = makeStyles({
|
||||
export const DocsResultListItem = ({
|
||||
result,
|
||||
lineClamp = 5,
|
||||
asListItem = true,
|
||||
asLink = true,
|
||||
title,
|
||||
}: {
|
||||
result: any;
|
||||
lineClamp?: number;
|
||||
asListItem?: boolean;
|
||||
asLink?: boolean;
|
||||
title?: string;
|
||||
}) => {
|
||||
const classes = useStyles();
|
||||
return (
|
||||
<Link to={result.location}>
|
||||
<ListItem alignItems="flex-start" className={classes.flexContainer}>
|
||||
<ListItemText
|
||||
className={classes.itemText}
|
||||
primaryTypographyProps={{ variant: 'h6' }}
|
||||
primary={`${result.title} | ${result.name} docs `}
|
||||
secondary={
|
||||
<TextTruncate
|
||||
line={lineClamp}
|
||||
truncateText="…"
|
||||
text={result.text}
|
||||
element="span"
|
||||
/>
|
||||
}
|
||||
const TextItem = () => (
|
||||
<ListItemText
|
||||
className={classes.itemText}
|
||||
primaryTypographyProps={{ variant: 'h6' }}
|
||||
primary={title ? title : `${result.title} | ${result.name} docs`}
|
||||
secondary={
|
||||
<TextTruncate
|
||||
line={lineClamp}
|
||||
truncateText="…"
|
||||
text={result.text}
|
||||
element="span"
|
||||
/>
|
||||
</ListItem>
|
||||
<Divider component="li" />
|
||||
</Link>
|
||||
}
|
||||
/>
|
||||
);
|
||||
|
||||
const LinkWrapper = ({ children }: PropsWithChildren<{}>) =>
|
||||
asLink ? <Link to={result.location}>{children}</Link> : <>{children}</>;
|
||||
|
||||
const ListItemWrapper = ({ children }: PropsWithChildren<{}>) =>
|
||||
asListItem ? (
|
||||
<>
|
||||
<ListItem alignItems="flex-start" className={classes.flexContainer}>
|
||||
{children}
|
||||
</ListItem>
|
||||
<Divider component="li" />
|
||||
</>
|
||||
) : (
|
||||
<>{children}</>
|
||||
);
|
||||
|
||||
return (
|
||||
<LinkWrapper>
|
||||
<ListItemWrapper>
|
||||
<TextItem />
|
||||
</ListItemWrapper>
|
||||
</LinkWrapper>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -25,6 +25,7 @@ import React from 'react';
|
||||
import { TechDocsStorageApi, techdocsStorageApiRef } from '../../api';
|
||||
import { Reader } from './Reader';
|
||||
import { ApiProvider, ApiRegistry } from '@backstage/core-app-api';
|
||||
import { searchApiRef } from '@backstage/plugin-search';
|
||||
|
||||
jest.mock('react-router-dom', () => {
|
||||
const actual = jest.requireActual('react-router-dom');
|
||||
@@ -50,10 +51,16 @@ describe('<Reader />', () => {
|
||||
}),
|
||||
);
|
||||
const techdocsStorageApi: Partial<TechDocsStorageApi> = {};
|
||||
|
||||
const searchApi = {
|
||||
query: () =>
|
||||
Promise.resolve({
|
||||
results: [],
|
||||
}),
|
||||
};
|
||||
const apiRegistry = ApiRegistry.from([
|
||||
[scmIntegrationsApiRef, scmIntegrationsApi],
|
||||
[techdocsStorageApiRef, techdocsStorageApi],
|
||||
[searchApiRef, searchApi],
|
||||
]);
|
||||
|
||||
await act(async () => {
|
||||
|
||||
@@ -22,6 +22,7 @@ import { BackstageTheme } from '@backstage/theme';
|
||||
import {
|
||||
Button,
|
||||
CircularProgress,
|
||||
Grid,
|
||||
makeStyles,
|
||||
useTheme,
|
||||
} from '@material-ui/core';
|
||||
@@ -43,23 +44,30 @@ import {
|
||||
} from '../transformers';
|
||||
import { TechDocsBuildLogs } from './TechDocsBuildLogs';
|
||||
import { TechDocsNotFound } from './TechDocsNotFound';
|
||||
import { TechDocsSearch } from './TechDocsSearch';
|
||||
import { useReaderState } from './useReaderState';
|
||||
|
||||
type Props = {
|
||||
entityId: EntityName;
|
||||
onReady?: () => void;
|
||||
withSearch?: boolean;
|
||||
};
|
||||
|
||||
const useStyles = makeStyles<BackstageTheme>(() => ({
|
||||
const useStyles = makeStyles<BackstageTheme>(theme => ({
|
||||
message: {
|
||||
// `word-break: break-word` is deprecated, but gives legacy support to browsers not supporting `overflow-wrap` yet
|
||||
// https://developer.mozilla.org/en-US/docs/Web/CSS/word-break
|
||||
wordBreak: 'break-word',
|
||||
overflowWrap: 'anywhere',
|
||||
},
|
||||
searchBar: {
|
||||
marginLeft: '20rem',
|
||||
maxWidth: 'calc(100% - 20rem * 2 - 3rem)',
|
||||
marginTop: theme.spacing(1),
|
||||
},
|
||||
}));
|
||||
|
||||
export const Reader = ({ entityId, onReady }: Props) => {
|
||||
export const Reader = ({ entityId, onReady, withSearch = true }: Props) => {
|
||||
const { kind, namespace, name } = entityId;
|
||||
const theme = useTheme<BackstageTheme>();
|
||||
const classes = useStyles();
|
||||
@@ -433,6 +441,12 @@ export const Reader = ({ entityId, onReady }: Props) => {
|
||||
<TechDocsNotFound errorMessage={contentErrorMessage} />
|
||||
</>
|
||||
)}
|
||||
|
||||
{withSearch && shadowDomRef?.current?.shadowRoot?.innerHTML && (
|
||||
<Grid container className={classes.searchBar}>
|
||||
<TechDocsSearch entityId={entityId} />
|
||||
</Grid>
|
||||
)}
|
||||
<div data-testid="techdocs-content-shadowroot" ref={shadowDomRef} />
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -29,6 +29,7 @@ import {
|
||||
TechDocsStorageApi,
|
||||
} from '../../api';
|
||||
import { ApiRegistry, ApiProvider } from '@backstage/core-app-api';
|
||||
import { searchApiRef } from '@backstage/plugin-search';
|
||||
|
||||
jest.mock('react-router-dom', () => {
|
||||
const actual = jest.requireActual('react-router-dom');
|
||||
@@ -47,6 +48,7 @@ jest.mock('./TechDocsPageHeader', () => {
|
||||
|
||||
const { useParams }: { useParams: jest.Mock } =
|
||||
jest.requireMock('react-router-dom');
|
||||
global.scroll = jest.fn();
|
||||
|
||||
describe('<TechDocsPage />', () => {
|
||||
it('should render techdocs page', async () => {
|
||||
@@ -75,16 +77,23 @@ describe('<TechDocsPage />', () => {
|
||||
site_description: 'string',
|
||||
}),
|
||||
};
|
||||
|
||||
const techdocsStorageApi: Partial<TechDocsStorageApi> = {
|
||||
getEntityDocs: (): Promise<string> => Promise.resolve('String'),
|
||||
getBaseUrl: (): Promise<string> => Promise.resolve('String'),
|
||||
getApiOrigin: (): Promise<string> => Promise.resolve('String'),
|
||||
};
|
||||
|
||||
const searchApi = {
|
||||
query: () =>
|
||||
Promise.resolve({
|
||||
results: [],
|
||||
}),
|
||||
};
|
||||
const apiRegistry = ApiRegistry.from([
|
||||
[scmIntegrationsApiRef, scmIntegrationsApi],
|
||||
[techdocsApiRef, techdocsApi],
|
||||
[techdocsStorageApiRef, techdocsStorageApi],
|
||||
[searchApiRef, searchApi],
|
||||
]);
|
||||
|
||||
await act(async () => {
|
||||
|
||||
@@ -0,0 +1,122 @@
|
||||
/*
|
||||
* Copyright 2020 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 { TechDocsSearch } from './TechDocsSearch';
|
||||
import {
|
||||
act,
|
||||
fireEvent,
|
||||
render,
|
||||
waitFor,
|
||||
within,
|
||||
} from '@testing-library/react';
|
||||
import { wrapInTestApp } from '@backstage/test-utils';
|
||||
import { ApiProvider, ApiRegistry } from '@backstage/core-app-api';
|
||||
import { searchApiRef } from '@backstage/plugin-search';
|
||||
|
||||
const entityId = {
|
||||
name: 'test',
|
||||
namespace: 'testspace',
|
||||
kind: 'Testable',
|
||||
};
|
||||
|
||||
const emptyResults = Promise.resolve({
|
||||
results: [],
|
||||
});
|
||||
const singleResult = Promise.resolve({
|
||||
results: [
|
||||
{
|
||||
type: 'testsearchresult',
|
||||
document: {
|
||||
text: 'text',
|
||||
title: 'title',
|
||||
location: '/something/something',
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
describe('<TechDocsPage />', () => {
|
||||
it('should render techdocs search bar', async () => {
|
||||
const query = () => emptyResults;
|
||||
const querySpy = jest.fn(query);
|
||||
const searchApi = { query: querySpy };
|
||||
|
||||
const apiRegistry = ApiRegistry.from([[searchApiRef, searchApi]]);
|
||||
|
||||
await act(async () => {
|
||||
const rendered = render(
|
||||
wrapInTestApp(
|
||||
<ApiProvider apis={apiRegistry}>
|
||||
<TechDocsSearch entityId={entityId} />
|
||||
</ApiProvider>,
|
||||
),
|
||||
);
|
||||
|
||||
await emptyResults;
|
||||
expect(querySpy).toBeCalled();
|
||||
expect(rendered.getByTestId('techdocs-search-bar')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
it('should trigger query when autocomplete input changed', async () => {
|
||||
const query = () => singleResult;
|
||||
const querySpy = jest.fn(query);
|
||||
const searchApi = { query: querySpy };
|
||||
|
||||
const apiRegistry = ApiRegistry.from([[searchApiRef, searchApi]]);
|
||||
|
||||
await act(async () => {
|
||||
const rendered = render(
|
||||
wrapInTestApp(
|
||||
<ApiProvider apis={apiRegistry}>
|
||||
<TechDocsSearch entityId={entityId} debounceTime={0} />
|
||||
</ApiProvider>,
|
||||
),
|
||||
);
|
||||
|
||||
await singleResult;
|
||||
expect(querySpy).toBeCalledWith({
|
||||
filters: {
|
||||
kind: 'Testable',
|
||||
name: 'test',
|
||||
namespace: 'testspace',
|
||||
},
|
||||
pageCursor: '',
|
||||
term: '',
|
||||
types: ['techdocs'],
|
||||
});
|
||||
|
||||
const autocomplete = rendered.getByTestId('techdocs-search-bar');
|
||||
const input = within(autocomplete).getByRole('textbox');
|
||||
autocomplete.click();
|
||||
autocomplete.focus();
|
||||
fireEvent.change(input, { target: { value: 'asdf' } });
|
||||
|
||||
await singleResult;
|
||||
await waitFor(() =>
|
||||
expect(querySpy).toBeCalledWith({
|
||||
filters: {
|
||||
kind: 'Testable',
|
||||
name: 'test',
|
||||
namespace: 'testspace',
|
||||
},
|
||||
pageCursor: '',
|
||||
term: 'asdf',
|
||||
types: ['techdocs'],
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,177 @@
|
||||
/*
|
||||
* 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, { ChangeEvent, useEffect, useState } from 'react';
|
||||
import {
|
||||
Grid,
|
||||
IconButton,
|
||||
InputAdornment,
|
||||
TextField,
|
||||
CircularProgress,
|
||||
} from '@material-ui/core';
|
||||
import Autocomplete from '@material-ui/lab/Autocomplete';
|
||||
import { SearchContextProvider, useSearch } from '@backstage/plugin-search';
|
||||
import { DocsResultListItem } from '../../components/DocsResultListItem';
|
||||
import SearchIcon from '@material-ui/icons/Search';
|
||||
import { useDebounce } from 'react-use';
|
||||
import { useNavigate } from 'react-router';
|
||||
|
||||
type TechDocsSearchProps = {
|
||||
entityId: {
|
||||
name: string;
|
||||
namespace: string;
|
||||
kind: string;
|
||||
};
|
||||
debounceTime?: number;
|
||||
};
|
||||
|
||||
type TechDocsDoc = {
|
||||
namespace: string;
|
||||
kind: string;
|
||||
name: string;
|
||||
path: string;
|
||||
location: string;
|
||||
title: string;
|
||||
};
|
||||
|
||||
type TechDocsSearchResult = {
|
||||
type: string;
|
||||
document: TechDocsDoc;
|
||||
};
|
||||
|
||||
const TechDocsSearchBar = ({
|
||||
entityId,
|
||||
debounceTime = 150,
|
||||
}: TechDocsSearchProps) => {
|
||||
const [open, setOpen] = useState(false);
|
||||
const navigate = useNavigate();
|
||||
const {
|
||||
term,
|
||||
setTerm,
|
||||
result: { loading, value: searchVal },
|
||||
} = useSearch();
|
||||
const [options, setOptions] = useState<any[]>([]);
|
||||
useEffect(() => {
|
||||
let mounted = true;
|
||||
|
||||
if (mounted && searchVal) {
|
||||
// TODO: Change this into getting only subset of search results from the BE in the first place
|
||||
// once pagination is implemented for search engines
|
||||
// See: https://github.com/backstage/backstage/issues/6062
|
||||
const searchResults = searchVal.results.slice(0, 10);
|
||||
setOptions(searchResults);
|
||||
}
|
||||
return () => {
|
||||
mounted = false;
|
||||
};
|
||||
}, [loading, searchVal]);
|
||||
|
||||
const [value, setValue] = useState<string>(term);
|
||||
|
||||
useDebounce(() => setTerm(value), debounceTime, [value]);
|
||||
|
||||
const handleQuery = (e: ChangeEvent<HTMLInputElement>) => {
|
||||
if (!open) {
|
||||
setOpen(true);
|
||||
}
|
||||
setValue(e.target.value);
|
||||
};
|
||||
|
||||
const handleSelection = (_: any, selection: TechDocsSearchResult | null) => {
|
||||
if (selection?.document) {
|
||||
const { location } = selection.document;
|
||||
navigate(location);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Grid item xs={12}>
|
||||
<Autocomplete
|
||||
data-testid="techdocs-search-bar"
|
||||
size="small"
|
||||
open={open}
|
||||
getOptionLabel={() => ''}
|
||||
filterOptions={x => {
|
||||
return x; // This is needed to get renderOption to be called after options change. Bug in material-ui?
|
||||
}}
|
||||
onClose={() => {
|
||||
setOpen(false);
|
||||
}}
|
||||
onFocus={() => {
|
||||
setOpen(true);
|
||||
}}
|
||||
onChange={handleSelection}
|
||||
blurOnSelect
|
||||
noOptionsText="No results found"
|
||||
value={null}
|
||||
options={options}
|
||||
renderOption={({ document }) => (
|
||||
<DocsResultListItem
|
||||
result={document}
|
||||
lineClamp={3}
|
||||
asListItem={false}
|
||||
asLink={false}
|
||||
title={document.title}
|
||||
/>
|
||||
)}
|
||||
loading={loading}
|
||||
renderInput={params => (
|
||||
<TextField
|
||||
{...params}
|
||||
data-testid="techdocs-search-bar-input"
|
||||
variant="outlined"
|
||||
fullWidth
|
||||
placeholder={`Search ${entityId.name} docs`}
|
||||
value={value}
|
||||
onChange={handleQuery}
|
||||
InputProps={{
|
||||
...params.InputProps,
|
||||
startAdornment: (
|
||||
<InputAdornment position="start">
|
||||
<IconButton aria-label="Query term" disabled>
|
||||
<SearchIcon />
|
||||
</IconButton>
|
||||
</InputAdornment>
|
||||
),
|
||||
endAdornment: (
|
||||
<React.Fragment>
|
||||
{loading ? (
|
||||
<CircularProgress color="inherit" size={20} />
|
||||
) : null}
|
||||
{params.InputProps.endAdornment}
|
||||
</React.Fragment>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</Grid>
|
||||
);
|
||||
};
|
||||
const TechDocsSearch = (props: TechDocsSearchProps) => {
|
||||
const initialState = {
|
||||
term: '',
|
||||
types: ['techdocs'],
|
||||
pageCursor: '',
|
||||
filters: props.entityId,
|
||||
};
|
||||
return (
|
||||
<SearchContextProvider initialState={initialState}>
|
||||
<TechDocsSearchBar {...props} />
|
||||
</SearchContextProvider>
|
||||
);
|
||||
};
|
||||
export { TechDocsSearch };
|
||||
@@ -85,7 +85,7 @@ describe('addGitFeedbackLink', () => {
|
||||
<html>
|
||||
<article class="md-content__inner">
|
||||
<h1>HeaderText</h1>
|
||||
<a title="Edit this page" href="https://github.com/groupname/reponame/blob/master/docs/docname.md"></>
|
||||
<a title="Edit this page" href="https://github.com/groupname/reponame/edit/master/docs/docname.md"></>
|
||||
</article>
|
||||
</html>
|
||||
`,
|
||||
@@ -99,7 +99,7 @@ describe('addGitFeedbackLink', () => {
|
||||
expect(
|
||||
(shadowDom.querySelector('#git-feedback-link') as HTMLLinkElement)!.href,
|
||||
).toEqual(
|
||||
'https://github.com/groupname/reponame/issues/new?title=Documentation%20Feedback%3A%20HeaderText&body=Page%20source%3A%0Ahttps%3A%2F%2Fgithub.com%2Fgroupname%2Freponame%2Fblob%2Fmaster%2Fdocs%2Fdocname.md%0A%0AFeedback%3A',
|
||||
'https://github.com/groupname/reponame/issues/new?title=Documentation%20Feedback%3A%20HeaderText&body=Page%20source%3A%0Ahttps%3A%2F%2Fgithub.com%2Fgroupname%2Freponame%2Fedit%2Fmaster%2Fdocs%2Fdocname.md%0A%0AFeedback%3A',
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -15,7 +15,10 @@
|
||||
*/
|
||||
|
||||
import type { Transformer } from './index';
|
||||
import { ScmIntegrationRegistry } from '@backstage/integration';
|
||||
import {
|
||||
replaceGitHubUrlType,
|
||||
ScmIntegrationRegistry,
|
||||
} from '@backstage/integration';
|
||||
import FeedbackOutlinedIcon from '@material-ui/icons/FeedbackOutlined';
|
||||
import React from 'react';
|
||||
import ReactDOM from 'react-dom';
|
||||
@@ -51,7 +54,13 @@ export const addGitFeedbackLink = (
|
||||
const issueDesc = encodeURIComponent(
|
||||
`Page source:\n${sourceAnchor.href}\n\nFeedback:`,
|
||||
);
|
||||
const gitInfo = parseGitUrl(sourceURL.pathname);
|
||||
|
||||
// Convert GitHub edit url to blob type so it can be parsed by git-url-parse correctly
|
||||
const gitUrl =
|
||||
integration?.type === 'github'
|
||||
? replaceGitHubUrlType(sourceURL.href, 'blob')
|
||||
: sourceURL.href;
|
||||
const gitInfo = parseGitUrl(gitUrl);
|
||||
const repoPath = `/${gitInfo.organization}/${gitInfo.name}`;
|
||||
|
||||
const feedbackLink = sourceAnchor.cloneNode() as HTMLAnchorElement;
|
||||
|
||||
@@ -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