Merge remote-tracking branch 'upstream/master' into feat/relative-ref-tmp

This commit is contained in:
Dominik Henneke
2021-07-21 15:12:52 +02:00
28 changed files with 1179 additions and 75 deletions
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/plugin-techdocs': patch
---
Add search list item to display tech docs search results
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/plugin-techdocs-backend': patch
---
Implements tech docs collator to retrieve and expose search indexes for entities that have tech docs configured
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/plugin-search': patch
---
Adding a type filter to new search
@@ -0,0 +1,7 @@
---
'@backstage/plugin-techdocs': patch
---
Only update the `path` when the content is updated.
If content and path are updated independently, the frontend rendering is triggered twice on each navigation: Once for the `path` change (with the old content) and once for the new content.
This might result in a flickering rendering that is caused by the async frontend preprocessing, and the fact that replacing the shadow dom content is expensive.
+2
View File
@@ -39,9 +39,11 @@
"@backstage/plugin-techdocs": "^0.10.0",
"@backstage/plugin-todo": "^0.1.5",
"@backstage/plugin-user-settings": "^0.3.0",
"@backstage/search-common": "^0.1.2",
"@backstage/theme": "^0.2.8",
"@material-ui/core": "^4.11.0",
"@material-ui/icons": "^4.9.1",
"@material-ui/lab": "4.0.0-alpha.45",
"@octokit/rest": "^18.5.3",
"@roadiehq/backstage-plugin-buildkite": "^1.0.6",
"@roadiehq/backstage-plugin-github-insights": "^1.1.20",
@@ -14,17 +14,20 @@
* limitations under the License.
*/
import React from 'react';
import React, { useState } from 'react';
import { makeStyles, Theme, Grid, List, Paper } from '@material-ui/core';
import Pagination from '@material-ui/lab/Pagination';
import { CatalogResultListItem } from '@backstage/plugin-catalog';
import {
SearchBar,
SearchFilter,
SearchResult,
SearchType,
DefaultResultListItem,
} from '@backstage/plugin-search';
import { Content, Header, Lifecycle, Page } from '@backstage/core-components';
import { DocsResultListItem } from '@backstage/plugin-techdocs';
import { SearchResultSet } from '@backstage/search-common';
const useStyles = makeStyles((theme: Theme) => ({
bar: {
@@ -34,15 +37,63 @@ const useStyles = makeStyles((theme: Theme) => ({
padding: theme.spacing(2),
},
filter: {
'& + &': {
marginTop: theme.spacing(2.5),
},
marginTop: theme.spacing(2.5),
},
}));
// TODO: Move this into the search plugin once pagination is natively supported.
// See: https://github.com/backstage/backstage/issues/6062
const SearchResultList = ({ results }: SearchResultSet) => {
const pageSize = 10;
const [page, setPage] = useState(1);
const changePage = (_: any, pageIndex: number) => {
setPage(pageIndex);
};
const pageAmount = Math.ceil((results.length || 0) / pageSize);
return (
<>
<List>
{results
.slice(pageSize * (page - 1), pageSize * page)
.map(({ type, document }) => {
switch (type) {
case 'software-catalog':
return (
<CatalogResultListItem
key={document.location}
result={document}
/>
);
case 'techdocs':
return (
<DocsResultListItem
key={document.location}
result={document}
/>
);
default:
return (
<DefaultResultListItem
key={document.location}
result={document}
/>
);
}
})}
</List>
<Pagination
count={pageAmount}
page={page}
onChange={changePage}
showFirstButton
showLastButton
/>
</>
);
};
const SearchPage = () => {
const classes = useStyles();
return (
<Page themeId="home">
<Header title="Search" subtitle={<Lifecycle alpha />} />
@@ -55,6 +106,11 @@ const SearchPage = () => {
</Grid>
<Grid item xs={3}>
<Paper className={classes.filters}>
<SearchType
values={['techdocs', 'software-catalog']}
name="type"
defaultValue="software-catalog"
/>
<SearchFilter.Select
className={classes.filter}
name="kind"
@@ -69,28 +125,7 @@ const SearchPage = () => {
</Grid>
<Grid item xs={9}>
<SearchResult>
{({ results }) => (
<List>
{results.map(({ type, document }) => {
switch (type) {
case 'software-catalog':
return (
<CatalogResultListItem
key={document.location}
result={document}
/>
);
default:
return (
<DefaultResultListItem
key={document.location}
result={document}
/>
);
}
})}
</List>
)}
{({ results }) => <SearchResultList results={results} />}
</SearchResult>
</Grid>
</Grid>
+6
View File
@@ -21,6 +21,7 @@ import {
} from '@backstage/plugin-search-backend-node';
import { PluginEnvironment } from '../types';
import { DefaultCatalogCollator } from '@backstage/plugin-catalog-backend';
import { DefaultTechDocsCollator } from '@backstage/plugin-techdocs-backend';
export default async function createPlugin({
logger,
@@ -37,6 +38,11 @@ export default async function createPlugin({
collator: new DefaultCatalogCollator({ discovery }),
});
indexBuilder.addCollator({
defaultRefreshIntervalSeconds: 600,
collator: new DefaultTechDocsCollator({ discovery, logger }),
});
// The scheduler controls when documents are gathered from collators and sent
// to the search engine for indexing.
const { scheduler } = await indexBuilder.build();
+11
View File
@@ -143,6 +143,17 @@ export const SearchResult: ({
children: (results: { results: SearchResult_2[] }) => JSX.Element;
}) => JSX.Element;
// Warning: (ae-forgotten-export) The symbol "SearchTypeProps" needs to be exported by the entry point index.d.ts
// Warning: (ae-missing-release-tag) "SearchType" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal)
//
// @public (undocumented)
export const SearchType: ({
values,
className,
name,
defaultValue,
}: SearchTypeProps) => JSX.Element;
// Warning: (ae-missing-release-tag) "SidebarSearch" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal)
//
// @public (undocumented)
@@ -0,0 +1,231 @@
/*
* 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 { screen, render, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { SearchType } from './SearchType';
import { SearchContextProvider } from '../SearchContext';
import { useApi } from '@backstage/core-plugin-api';
jest.mock('@backstage/core-plugin-api', () => ({
...jest.requireActual('@backstage/core-plugin-api'),
useApi: jest.fn().mockReturnValue({}),
}));
describe('SearchType', () => {
const initialState = {
term: '',
filters: {},
types: [],
pageCursor: '',
};
const name = 'field';
const values = ['value1', 'value2'];
const typeValues = ['preselected'];
const query = jest.fn().mockResolvedValue({});
(useApi as jest.Mock).mockReturnValue({ query: query });
afterAll(() => {
jest.resetAllMocks();
});
describe('Type Filter', () => {
it('Renders field name and values when provided as props', async () => {
render(
<SearchContextProvider initialState={initialState}>
<SearchType name={name} values={values} />
</SearchContextProvider>,
);
await waitFor(() => {
expect(screen.getByText(name)).toBeInTheDocument();
});
userEvent.click(screen.getByRole('button'));
await waitFor(() => {
expect(screen.getByRole('listbox')).toBeInTheDocument();
});
expect(
screen.getByRole('option', { name: values[0] }),
).toBeInTheDocument();
expect(
screen.getByRole('option', { name: values[1] }),
).toBeInTheDocument();
});
it('Renders correctly based on type filter state', async () => {
render(
<SearchContextProvider
initialState={{
...initialState,
types: [values[0]],
}}
>
<SearchType name={name} values={values} />
</SearchContextProvider>,
);
await waitFor(() => {
expect(screen.getByText(name)).toBeInTheDocument();
});
userEvent.click(screen.getByRole('button'));
await waitFor(() => {
expect(screen.getByRole('listbox')).toBeInTheDocument();
});
expect(screen.getByRole('option', { name: values[0] })).toHaveAttribute(
'aria-selected',
'true',
);
expect(
screen.getByRole('option', { name: values[1] }),
).not.toHaveAttribute('aria-selected');
expect(screen.getByRole('option', { name: 'All' })).not.toHaveAttribute(
'aria-selected',
);
});
it('Renders correctly based on type filter defaultValue', async () => {
render(
<SearchContextProvider initialState={initialState}>
<SearchType name={name} values={values} defaultValue={values[0]} />
</SearchContextProvider>,
);
await waitFor(() => {
expect(screen.getByText(name)).toBeInTheDocument();
});
userEvent.click(screen.getByRole('button'));
await waitFor(() => {
expect(screen.getByRole('listbox')).toBeInTheDocument();
});
expect(screen.getByRole('option', { name: values[0] })).toHaveAttribute(
'aria-selected',
'true',
);
expect(
screen.getByRole('option', { name: values[1] }),
).not.toHaveAttribute('aria-selected');
expect(screen.getByRole('option', { name: 'All' })).not.toHaveAttribute(
'aria-selected',
);
});
it('Selecting a value sets type filter state', async () => {
render(
<SearchContextProvider initialState={initialState}>
<SearchType name={name} values={values} />
</SearchContextProvider>,
);
await waitFor(() => {
expect(screen.getByText(name)).toBeInTheDocument();
});
const button = screen.getByRole('button');
userEvent.click(button);
await waitFor(() => {
expect(screen.getByRole('listbox')).toBeInTheDocument();
});
userEvent.click(screen.getByRole('option', { name: values[0] }));
await waitFor(() => {
expect(query).toHaveBeenLastCalledWith(
expect.objectContaining({
types: [values[0]],
}),
);
});
userEvent.click(button);
await waitFor(() => {
expect(screen.getByRole('listbox')).toBeInTheDocument();
});
userEvent.click(screen.getByRole('option', { name: 'All' }));
await waitFor(() => {
expect(query).toHaveBeenLastCalledWith(
expect.objectContaining({
types: [],
}),
);
});
});
it('Selecting a value maintains unrelated filter state, selecting All defaults to default empty state', async () => {
render(
<SearchContextProvider
initialState={{
...initialState,
types: typeValues,
}}
>
<SearchType name={name} values={values} />
</SearchContextProvider>,
);
await waitFor(() => {
expect(screen.getByText(name)).toBeInTheDocument();
});
const button = screen.getByRole('button');
userEvent.click(button);
await waitFor(() => {
expect(screen.getByRole('listbox')).toBeInTheDocument();
});
userEvent.click(screen.getByRole('option', { name: values[0] }));
await waitFor(() => {
expect(query).toHaveBeenLastCalledWith(
expect.objectContaining({
types: [...typeValues, values[0]],
}),
);
});
userEvent.click(button);
await waitFor(() => {
expect(screen.getByRole('listbox')).toBeInTheDocument();
});
userEvent.click(screen.getByRole('option', { name: 'All' }));
await waitFor(() => {
expect(query).toHaveBeenLastCalledWith(expect.objectContaining([]));
});
});
});
});
@@ -0,0 +1,110 @@
/*
* 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 { useSearch } from '../SearchContext';
import { useEffectOnce } from 'react-use';
import React, { ChangeEvent } from 'react';
import {
Chip,
FormControl,
InputLabel,
makeStyles,
MenuItem,
Select,
} from '@material-ui/core';
const useStyles = makeStyles({
label: {
textTransform: 'capitalize',
},
chips: {
display: 'flex',
flexWrap: 'wrap',
},
chip: {
margin: 2,
},
});
export type SearchTypeProps = {
className?: string;
name: string;
values?: string[];
defaultValue?: string[] | string | null;
};
const SearchType = ({
values = [],
className,
name,
defaultValue,
}: SearchTypeProps) => {
const classes = useStyles();
const { types, setTypes } = useSearch();
useEffectOnce(() => {
if (defaultValue && Array.isArray(defaultValue)) {
setTypes(defaultValue);
} else if (defaultValue) {
setTypes([defaultValue]);
}
});
const handleChange = (e: ChangeEvent<{ value: unknown }>) => {
const value = e.target.value as string[];
if (!value || value.includes('*')) {
setTypes([]);
} else {
setTypes(value.filter(it => it !== 'All'));
}
};
return (
<FormControl
className={className}
variant="filled"
fullWidth
data-testid="search-typefilter-next"
>
<InputLabel className={classes.label} margin="dense">
{name}
</InputLabel>
<Select
multiple
variant="outlined"
value={types.length ? types : ['All']}
onChange={handleChange}
renderValue={selected => (
<div className={classes.chips}>
{(selected as string[]).map(value => (
<Chip key={value} label={value} className={classes.chip} />
))}
</div>
)}
>
<MenuItem value="*">
<em>All</em>
</MenuItem>
{values.map((value: string) => (
<MenuItem key={value} value={value}>
{value}
</MenuItem>
))}
</Select>
</FormControl>
);
};
export { SearchType };
@@ -0,0 +1,17 @@
/*
* 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.
*/
export { SearchType } from './SearchType';
+1
View File
@@ -16,6 +16,7 @@
export * from './Filters';
export * from './SearchFilter';
export * from './SearchType';
export * from './SearchBar';
export * from './SearchPage';
export * from './SearchResult';
+1
View File
@@ -32,6 +32,7 @@ export {
useSearch,
SearchPage as Router,
SearchFilter,
SearchType,
SearchFilterNext,
SidebarSearch,
} from './components';
+51
View File
@@ -3,9 +3,12 @@
> Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/).
```ts
import { CatalogApi } from '@backstage/catalog-client';
import { Config } from '@backstage/config';
import { DocumentCollator } from '@backstage/search-common';
import express from 'express';
import { GeneratorBuilder } from '@backstage/techdocs-common';
import { IndexableDocument } from '@backstage/search-common';
import { Knex } from 'knex';
import { Logger as Logger_2 } from 'winston';
import { PluginEndpointDiscovery } from '@backstage/backend-common';
@@ -18,6 +21,54 @@ import { PublisherBase } from '@backstage/techdocs-common';
// @public (undocumented)
export function createRouter(options: RouterOptions): Promise<express.Router>;
// Warning: (ae-missing-release-tag) "DefaultTechDocsCollator" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal)
//
// @public (undocumented)
export class DefaultTechDocsCollator implements DocumentCollator {
constructor({
discovery,
locationTemplate,
logger,
catalogClient,
parallelismLimit,
}: {
discovery: PluginEndpointDiscovery;
logger: Logger_2;
locationTemplate?: string;
catalogClient?: CatalogApi;
parallelismLimit?: number;
});
// (undocumented)
protected applyArgsToFormat(
format: string,
args: Record<string, string>,
): string;
// (undocumented)
protected discovery: PluginEndpointDiscovery;
// (undocumented)
execute(): Promise<TechDocsDocument[]>;
// (undocumented)
protected locationTemplate: string;
// (undocumented)
readonly type: string;
}
// Warning: (ae-missing-release-tag) "TechDocsDocument" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal)
//
// @public (undocumented)
export interface TechDocsDocument extends IndexableDocument {
// (undocumented)
kind: string;
// (undocumented)
lifecycle: string;
// (undocumented)
name: string;
// (undocumented)
namespace: string;
// (undocumented)
owner: string;
}
export * from '@backstage/techdocs-common';
// (No @packageDocumentation comment for this package)
+4
View File
@@ -36,6 +36,7 @@
"@backstage/config": "^0.1.5",
"@backstage/errors": "^0.1.1",
"@backstage/integration": "^0.5.8",
"@backstage/search-common": "^0.1.2",
"@backstage/techdocs-common": "^0.6.8",
"@types/express": "^4.17.6",
"cross-fetch": "^3.0.6",
@@ -44,10 +45,13 @@
"express-promise-router": "^4.1.0",
"fs-extra": "9.1.0",
"knex": "^0.95.1",
"lodash": "^4.17.21",
"p-limit": "^3.1.0",
"winston": "^3.2.1"
},
"devDependencies": {
"@backstage/cli": "^0.7.4",
"@backstage/test-utils": "^0.1.14",
"@types/dockerode": "^3.2.1",
"msw": "^0.29.0",
"supertest": "^6.1.3"
+1
View File
@@ -15,4 +15,5 @@
*/
export { createRouter } from './service/router';
export * from './search';
export * from '@backstage/techdocs-common';
@@ -0,0 +1,149 @@
/*
* 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 {
PluginEndpointDiscovery,
getVoidLogger,
} from '@backstage/backend-common';
import { Entity } from '@backstage/catalog-model';
import { DefaultTechDocsCollator } from './DefaultTechDocsCollator';
import { msw } from '@backstage/test-utils';
import { setupServer } from 'msw/node';
import { rest } from 'msw';
const logger = getVoidLogger();
const mockSearchDocIndex = {
config: {
lang: ['en'],
min_search_length: 3,
prebuild_index: false,
separator: '[\\s\\-]+',
},
docs: [
{
location: '',
text: 'docs docs docs',
title: 'Home',
},
{
location: 'local-development/',
text: 'Docs for first subtitle',
title: 'Local development',
},
{
location: 'local-development/#development',
text: 'Docs for sub-subtitle',
title: 'Development',
},
],
};
const expectedEntities: Entity[] = [
{
apiVersion: 'backstage.io/v1alpha1',
kind: 'Component',
metadata: {
name: 'test-entity-with-docs',
description: 'Documented description',
annotations: {
'backstage.io/techdocs-ref': './',
},
},
spec: {
type: 'dog',
lifecycle: 'experimental',
owner: 'someone',
},
},
{
apiVersion: 'backstage.io/v1alpha1',
kind: 'Component',
metadata: {
name: 'test-entity',
description: 'The expected description',
},
spec: {
type: 'some-type',
lifecycle: 'experimental',
},
},
];
describe('DefaultTechDocsCollator', () => {
let mockDiscoveryApi: jest.Mocked<PluginEndpointDiscovery>;
let collator: DefaultTechDocsCollator;
const worker = setupServer();
msw.setupDefaultHandlers(worker);
beforeEach(() => {
mockDiscoveryApi = {
getBaseUrl: jest.fn().mockResolvedValue('http://test-backend'),
getExternalBaseUrl: jest.fn(),
};
collator = new DefaultTechDocsCollator({
discovery: mockDiscoveryApi,
logger,
});
worker.use(
rest.get(
'http://test-backend/static/docs/default/Component/test-entity-with-docs/search/search_index.json',
(_, res, ctx) => res(ctx.status(200), ctx.json(mockSearchDocIndex)),
),
rest.get('http://test-backend/entities', (_, res, ctx) =>
res(ctx.status(200), ctx.json(expectedEntities)),
),
);
});
it('fetches from the configured catalog and tech docs services', async () => {
const documents = await collator.execute();
expect(mockDiscoveryApi.getBaseUrl).toHaveBeenCalledWith('catalog');
expect(mockDiscoveryApi.getBaseUrl).toHaveBeenCalledWith('techdocs');
expect(documents).toHaveLength(mockSearchDocIndex.docs.length);
});
it('should create documents for each tech docs search index', async () => {
const documents = await collator.execute();
const entity = expectedEntities[0];
documents.forEach((document, idx) => {
expect(document).toMatchObject({
title: mockSearchDocIndex.docs[idx].title,
location: `/docs/default/Component/${entity.metadata.name}/${mockSearchDocIndex.docs[idx].location}`,
text: mockSearchDocIndex.docs[idx].text,
namespace: 'default',
componentType: entity!.spec!.type,
lifecycle: entity!.spec!.lifecycle,
owner: '',
});
});
});
it('maps a returned entity with a custom locationTemplate', async () => {
// Provide an alternate location template.
collator = new DefaultTechDocsCollator({
discovery: mockDiscoveryApi,
locationTemplate: '/software/:name',
logger,
});
const documents = await collator.execute();
expect(documents[0]).toMatchObject({
location: '/software/test-entity-with-docs',
});
});
});
@@ -0,0 +1,149 @@
/*
* 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 { PluginEndpointDiscovery } from '@backstage/backend-common';
import { Entity, RELATION_OWNED_BY } from '@backstage/catalog-model';
import { IndexableDocument, DocumentCollator } from '@backstage/search-common';
import fetch from 'cross-fetch';
import unescape from 'lodash/unescape';
import { Logger } from 'winston';
import pLimit from 'p-limit';
import { CatalogApi, CatalogClient } from '@backstage/catalog-client';
interface MkSearchIndexDoc {
title: string;
text: string;
location: string;
}
export interface TechDocsDocument extends IndexableDocument {
kind: string;
namespace: string;
name: string;
lifecycle: string;
owner: string;
}
export class DefaultTechDocsCollator implements DocumentCollator {
protected discovery: PluginEndpointDiscovery;
protected locationTemplate: string;
private readonly logger: Logger;
private readonly catalogClient: CatalogApi;
private readonly parallelismLimit: number;
public readonly type: string = 'techdocs';
constructor({
discovery,
locationTemplate,
logger,
catalogClient,
parallelismLimit = 10,
}: {
discovery: PluginEndpointDiscovery;
logger: Logger;
locationTemplate?: string;
catalogClient?: CatalogApi;
parallelismLimit?: number;
}) {
this.discovery = discovery;
this.locationTemplate =
locationTemplate || '/docs/:namespace/:kind/:name/:path';
this.logger = logger;
this.catalogClient =
catalogClient || new CatalogClient({ discoveryApi: discovery });
this.parallelismLimit = parallelismLimit;
}
async execute() {
const limit = pLimit(this.parallelismLimit);
const techDocsBaseUrl = await this.discovery.getBaseUrl('techdocs');
const entities = await this.catalogClient.getEntities({
fields: [
'kind',
'namespace',
'metadata.annotations',
'metadata.name',
'metadata.namespace',
'spec.type',
'spec.lifecycle',
'relations',
],
});
const docPromises = entities.items
.filter(it => it.metadata?.annotations?.['backstage.io/techdocs-ref'])
.map((entity: Entity) =>
limit(
async (): Promise<TechDocsDocument[]> => {
const entityInfo = {
kind: entity.kind,
namespace: entity.metadata.namespace || 'default',
name: entity.metadata.name,
};
try {
const searchIndexResponse = await fetch(
DefaultTechDocsCollator.constructDocsIndexUrl(
techDocsBaseUrl,
entityInfo,
),
);
const searchIndex = await searchIndexResponse.json();
return searchIndex.docs.map((doc: MkSearchIndexDoc) => ({
title: unescape(doc.title),
text: unescape(doc.text || ''),
location: this.applyArgsToFormat(this.locationTemplate, {
...entityInfo,
path: doc.location,
}),
...entityInfo,
componentType: entity.spec?.type?.toString() || 'other',
lifecycle: (entity.spec?.lifecycle as string) || '',
owner:
entity.relations?.find(r => r.type === RELATION_OWNED_BY)
?.target?.name || '',
}));
} catch (e) {
this.logger.warn(
`Failed to retrieve tech docs search index for entity ${entityInfo.namespace}/${entityInfo.kind}/${entityInfo.name}`,
e,
);
return [];
}
},
),
);
return (await Promise.all(docPromises)).flat();
}
protected applyArgsToFormat(
format: string,
args: Record<string, string>,
): string {
let formatted = format;
for (const [key, value] of Object.entries(args)) {
formatted = formatted.replace(`:${key}`, value);
}
return formatted;
}
private static constructDocsIndexUrl(
techDocsBaseUrl: string,
entityInfo: { kind: string; namespace: string; name: string },
) {
return `${techDocsBaseUrl}/static/docs/${entityInfo.namespace}/${entityInfo.kind}/${entityInfo.name}/search/search_index.json`;
}
}
@@ -0,0 +1,17 @@
/*
* 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.
*/
export { DefaultTechDocsCollator } from './DefaultTechDocsCollator';
export type { TechDocsDocument } from './DefaultTechDocsCollator';
+11
View File
@@ -25,6 +25,17 @@ export const DocsCardGrid: ({
entities: Entity[] | undefined;
}) => JSX.Element | null;
// Warning: (ae-missing-release-tag) "DocsResultListItem" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal)
//
// @public (undocumented)
export const DocsResultListItem: ({
result,
lineClamp,
}: {
result: any;
lineClamp?: number | undefined;
}) => JSX.Element;
// Warning: (ae-missing-release-tag) "DocsTable" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal)
//
// @public (undocumented)
+1
View File
@@ -51,6 +51,7 @@
"react-router": "6.0.0-beta.0",
"react-router-dom": "6.0.0-beta.0",
"react-use": "^17.2.4",
"react-text-truncate": "^0.16.0",
"sanitize-html": "^2.3.2"
},
"devDependencies": {
@@ -0,0 +1,50 @@
/*
* 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 { render } from '@testing-library/react';
import { DocsResultListItem } from './DocsResultListItem';
// Using canvas to render text..
jest.mock('react-text-truncate', () => {
return ({ text }: { text: string }) => <span>{text}</span>;
});
const validResult = {
location: 'https://backstage.io/docs',
title: 'Documentation',
text:
'Backstage is an open-source developer portal that puts the developer experience first.',
kind: 'library',
namespace: '',
name: 'Backstage',
lifecycle: 'production',
};
describe('DocsResultListItem test', () => {
it('should render search doc passed in', async () => {
const { findByText } = render(<DocsResultListItem result={validResult} />);
expect(
await findByText('Documentation | Backstage docs'),
).toBeInTheDocument();
expect(
await findByText(
'Backstage is an open-source developer portal that puts the developer experience first.',
),
).toBeInTheDocument();
});
});
@@ -0,0 +1,60 @@
/*
* 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 { Divider, ListItem, ListItemText, makeStyles } from '@material-ui/core';
import { Link } from '@backstage/core-components';
import TextTruncate from 'react-text-truncate';
const useStyles = makeStyles({
flexContainer: {
flexWrap: 'wrap',
},
itemText: {
width: '100%',
marginBottom: '1rem',
},
});
export const DocsResultListItem = ({
result,
lineClamp = 5,
}: {
result: any;
lineClamp?: number;
}) => {
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"
/>
}
/>
</ListItem>
<Divider component="li" />
</Link>
);
};
@@ -0,0 +1,17 @@
/*
* 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.
*/
export { DocsResultListItem } from './DocsResultListItem';
+1
View File
@@ -19,6 +19,7 @@ export { techdocsApiRef, techdocsStorageApiRef } from './api';
export type { TechDocsApi, TechDocsStorageApi } from './api';
export { TechDocsClient, TechDocsStorageClient } from './client';
export type { PanelType } from './home/components/TechDocsCustomHome';
export * from './components/DocsResultListItem';
export {
DocsCardGrid,
DocsTable,
@@ -61,18 +61,18 @@ const useStyles = makeStyles<BackstageTheme>(() => ({
export const Reader = ({ entityId, onReady }: Props) => {
const { kind, namespace, name } = entityId;
const { '*': path } = useParams();
const theme = useTheme<BackstageTheme>();
const classes = useStyles();
const {
state,
path,
contentReload,
content: rawPage,
contentErrorMessage,
syncErrorMessage,
buildLog,
} = useReaderState(kind, namespace, name, path);
} = useReaderState(kind, namespace, name, useParams()['*']);
const techdocsStorageApi = useApi(techdocsStorageApiRef);
const [sidebars, setSidebars] = useState<HTMLElement[]>();
@@ -111,6 +111,10 @@ export const Reader = ({ entityId, onReady }: Props) => {
useEffect(() => {
if (!rawPage || !shadowDomRef.current) {
// clear the shadow dom if no content is available
if (shadowDomRef.current?.shadowRoot) {
shadowDomRef.current.shadowRoot.innerHTML = '';
}
return;
}
if (onReady) {
@@ -86,7 +86,7 @@ describe('useReaderState', () => {
};
it('should return a copy of the state', () => {
expect(reducer(oldState, { type: 'navigate', path: '/' })).toEqual({
expect(reducer(oldState, { type: 'content', path: '/' })).toEqual({
activeSyncState: 'CHECKING',
contentLoading: false,
path: '/',
@@ -102,13 +102,13 @@ describe('useReaderState', () => {
});
it.each`
type | oldActiveSyncState | newActiveSyncState
${'content'} | ${'BUILD_READY'} | ${'UP_TO_DATE'}
${'content'} | ${'BUILD_READY_RELOAD'} | ${'UP_TO_DATE'}
${'navigate'} | ${'BUILD_READY'} | ${'UP_TO_DATE'}
${'navigate'} | ${'BUILD_READY_RELOAD'} | ${'UP_TO_DATE'}
${'sync'} | ${'BUILD_READY'} | ${undefined}
${'sync'} | ${'BUILD_READY_RELOAD'} | ${undefined}
type | oldActiveSyncState | newActiveSyncState
${'contentLoading'} | ${'BUILD_READY'} | ${'UP_TO_DATE'}
${'contentLoading'} | ${'BUILD_READY_RELOAD'} | ${'UP_TO_DATE'}
${'content'} | ${'BUILD_READY'} | ${'UP_TO_DATE'}
${'content'} | ${'BUILD_READY_RELOAD'} | ${'UP_TO_DATE'}
${'sync'} | ${'BUILD_READY'} | ${undefined /* undefined, because we don't set an input */}
${'sync'} | ${'BUILD_READY_RELOAD'} | ${undefined /* undefined, because we don't set an input */}
`(
'should, when type=$type and activeSyncState=$oldActiveSyncState, set activeSyncState=$newActiveSyncState',
({ type, oldActiveSyncState, newActiveSyncState }) => {
@@ -124,18 +124,45 @@ describe('useReaderState', () => {
},
);
describe('"content" action', () => {
describe('"contentLoading" action', () => {
it('should set loading', () => {
expect(
reducer(oldState, {
type: 'contentLoading',
}),
).toEqual({
...oldState,
contentLoading: true,
});
});
it('should keep content', () => {
expect(
reducer(
{
...oldState,
content: 'some-old-content',
},
{
type: 'contentLoading',
},
),
).toEqual({
...oldState,
contentLoading: true,
content: 'some-old-content',
});
});
it('should reset errors', () => {
expect(
reducer(
{
...oldState,
contentError: new Error(),
},
{
type: 'content',
contentLoading: true,
type: 'contentLoading',
},
),
).toEqual({
@@ -143,7 +170,9 @@ describe('useReaderState', () => {
contentLoading: true,
});
});
});
describe('"content" action', () => {
it('should set content', () => {
expect(
reducer(
@@ -164,6 +193,27 @@ describe('useReaderState', () => {
});
});
it('should set content and update path', () => {
expect(
reducer(
{
...oldState,
contentLoading: true,
},
{
type: 'content',
content: 'asdf',
path: '/new-path',
},
),
).toEqual({
...oldState,
contentLoading: false,
content: 'asdf',
path: '/new-path',
});
});
it('should set error', () => {
expect(
reducer(
@@ -185,20 +235,6 @@ describe('useReaderState', () => {
});
});
describe('"navigate" action', () => {
it('should work', () => {
expect(
reducer(oldState, {
type: 'navigate',
path: '/',
}),
).toEqual({
...oldState,
path: '/',
});
});
});
describe('"sync" action', () => {
it('should update state', () => {
expect(
@@ -256,6 +292,7 @@ describe('useReaderState', () => {
expect(result.current).toEqual({
state: 'CHECKING',
path: '/example',
content: undefined,
contentErrorMessage: undefined,
syncErrorMessage: undefined,
@@ -267,6 +304,7 @@ describe('useReaderState', () => {
expect(result.current).toEqual({
state: 'CONTENT_FRESH',
path: '/example',
content: 'my content',
contentErrorMessage: undefined,
syncErrorMessage: undefined,
@@ -313,6 +351,7 @@ describe('useReaderState', () => {
expect(result.current).toEqual({
state: 'CHECKING',
path: '/example',
content: undefined,
contentErrorMessage: undefined,
syncErrorMessage: undefined,
@@ -324,6 +363,7 @@ describe('useReaderState', () => {
expect(result.current).toEqual({
state: 'INITIAL_BUILD',
path: '/example',
content: undefined,
contentErrorMessage: 'NotFoundError: Page Not Found',
syncErrorMessage: undefined,
@@ -335,6 +375,7 @@ describe('useReaderState', () => {
expect(result.current).toEqual({
state: 'CHECKING',
path: '/example',
content: undefined,
contentErrorMessage: undefined,
syncErrorMessage: undefined,
@@ -346,6 +387,7 @@ describe('useReaderState', () => {
expect(result.current).toEqual({
state: 'CONTENT_FRESH',
path: '/example',
content: 'my content',
contentErrorMessage: undefined,
syncErrorMessage: undefined,
@@ -394,6 +436,7 @@ describe('useReaderState', () => {
expect(result.current).toEqual({
state: 'CHECKING',
path: '/example',
content: undefined,
contentErrorMessage: undefined,
syncErrorMessage: undefined,
@@ -405,6 +448,7 @@ describe('useReaderState', () => {
await waitForValueToChange(() => result.current.state);
expect(result.current).toEqual({
state: 'CONTENT_FRESH',
path: '/example',
content: 'my content',
contentErrorMessage: undefined,
syncErrorMessage: undefined,
@@ -416,6 +460,7 @@ describe('useReaderState', () => {
await waitForValueToChange(() => result.current.state);
expect(result.current).toEqual({
state: 'CONTENT_STALE_REFRESHING',
path: '/example',
content: 'my content',
contentErrorMessage: undefined,
syncErrorMessage: undefined,
@@ -427,6 +472,7 @@ describe('useReaderState', () => {
await waitForValueToChange(() => result.current.state);
expect(result.current).toEqual({
state: 'CONTENT_STALE_READY',
path: '/example',
content: 'my content',
contentErrorMessage: undefined,
syncErrorMessage: undefined,
@@ -441,7 +487,8 @@ describe('useReaderState', () => {
await waitForValueToChange(() => result.current.state);
expect(result.current).toEqual({
state: 'CHECKING',
content: undefined,
path: '/example',
content: 'my content',
contentErrorMessage: undefined,
syncErrorMessage: undefined,
buildLog: [],
@@ -452,6 +499,7 @@ describe('useReaderState', () => {
await waitForValueToChange(() => result.current.state);
expect(result.current).toEqual({
state: 'CONTENT_FRESH',
path: '/example',
content: 'my new content',
contentErrorMessage: undefined,
syncErrorMessage: undefined,
@@ -475,6 +523,103 @@ describe('useReaderState', () => {
});
});
it('should handle navigation', async () => {
techdocsStorageApi.getEntityDocs
.mockResolvedValueOnce('my content')
.mockImplementationOnce(async () => {
await new Promise(resolve => setTimeout(resolve, 1100));
return 'my new content';
})
.mockRejectedValueOnce(new NotFoundError('Some error description'));
techdocsStorageApi.syncEntityDocs.mockResolvedValue('cached');
await act(async () => {
const { result, waitForValueToChange, rerender } = await renderHook(
({ path }: { path: string }) =>
useReaderState('Component', 'default', 'backstage', path),
{ initialProps: { path: '/example' }, wrapper: Wrapper as any },
);
expect(result.current).toEqual({
state: 'CHECKING',
path: '/example',
content: undefined,
contentErrorMessage: undefined,
syncErrorMessage: undefined,
buildLog: [],
contentReload: expect.any(Function),
});
// show the content
await waitForValueToChange(() => result.current.state);
expect(result.current).toEqual({
state: 'CONTENT_FRESH',
path: '/example',
content: 'my content',
contentErrorMessage: undefined,
syncErrorMessage: undefined,
buildLog: [],
contentReload: expect.any(Function),
});
// navigate
rerender({ path: '/new' });
await waitForValueToChange(() => result.current.state);
expect(result.current).toEqual({
state: 'CHECKING',
path: '/example',
content: 'my content',
contentErrorMessage: undefined,
syncErrorMessage: undefined,
buildLog: [],
contentReload: expect.any(Function),
});
await waitForValueToChange(() => result.current.state);
expect(result.current).toEqual({
state: 'CONTENT_FRESH',
path: '/new',
content: 'my new content',
contentErrorMessage: undefined,
syncErrorMessage: undefined,
buildLog: [],
contentReload: expect.any(Function),
});
// navigate
rerender({ path: '/missing' });
await waitForValueToChange(() => result.current.state);
expect(result.current).toEqual({
state: 'CONTENT_NOT_FOUND',
path: '/missing',
content: undefined,
contentErrorMessage: 'NotFoundError: Some error description',
syncErrorMessage: undefined,
buildLog: [],
contentReload: expect.any(Function),
});
expect(techdocsStorageApi.getEntityDocs).toBeCalledWith(
{ kind: 'Component', namespace: 'default', name: 'backstage' },
'/example',
);
expect(techdocsStorageApi.getEntityDocs).toBeCalledWith(
{ kind: 'Component', namespace: 'default', name: 'backstage' },
'/new',
);
expect(techdocsStorageApi.syncEntityDocs).toBeCalledWith(
{
kind: 'Component',
namespace: 'default',
name: 'backstage',
},
expect.any(Function),
);
});
});
it('should handle content error', async () => {
techdocsStorageApi.getEntityDocs.mockRejectedValue(
new NotFoundError('Some error description'),
@@ -489,6 +634,7 @@ describe('useReaderState', () => {
expect(result.current).toEqual({
state: 'CHECKING',
path: '/example',
content: undefined,
contentErrorMessage: undefined,
syncErrorMessage: undefined,
@@ -500,6 +646,7 @@ describe('useReaderState', () => {
await waitForValueToChange(() => result.current.state);
expect(result.current).toEqual({
state: 'CONTENT_NOT_FOUND',
path: '/example',
content: undefined,
contentErrorMessage: 'NotFoundError: Some error description',
syncErrorMessage: undefined,
@@ -15,7 +15,7 @@
*/
import { useApi } from '@backstage/core-plugin-api';
import { useEffect, useMemo, useReducer, useRef } from 'react';
import { useMemo, useReducer, useRef } from 'react';
import { useAsync, useAsyncRetry } from 'react-use';
import { techdocsStorageApiRef } from '../../api';
@@ -131,13 +131,13 @@ type ReducerActions =
state: SyncStates;
syncError?: Error;
}
| { type: 'contentLoading' }
| {
type: 'content';
path?: string;
content?: string;
contentLoading?: true;
contentError?: Error;
}
| { type: 'navigate'; path: string }
| { type: 'buildLog'; log: string };
type ReducerState = {
@@ -186,14 +186,22 @@ export function reducer(
newState.syncError = action.syncError;
break;
case 'content':
newState.content = action.content;
newState.contentLoading = action.contentLoading ?? false;
newState.contentError = action.contentError;
case 'contentLoading':
newState.contentLoading = true;
// only reset errors but keep the old content until it is replaced by the 'content' action
newState.contentError = undefined;
break;
case 'navigate':
newState.path = action.path;
case 'content':
// only override the path if it is part of the action
if (typeof action.path === 'string') {
newState.path = action.path;
}
newState.contentLoading = false;
newState.content = action.content;
newState.contentError = action.contentError;
break;
case 'buildLog':
@@ -204,10 +212,10 @@ export function reducer(
throw new Error();
}
// a navigation or a content update loads fresh content so the build is updated to being up-to-date
// a content update loads fresh content so the build is updated to being up-to-date
if (
['BUILD_READY', 'BUILD_READY_RELOAD'].includes(newState.activeSyncState) &&
['content', 'navigate'].includes(action.type)
['contentLoading', 'content'].includes(action.type)
) {
newState.activeSyncState = 'UP_TO_DATE';
newState.buildLog = [];
@@ -223,6 +231,7 @@ export function useReaderState(
path: string,
): {
state: ContentStateTypes;
path: string;
contentReload: () => void;
content?: string;
contentErrorMessage?: string;
@@ -238,14 +247,9 @@ export function useReaderState(
const techdocsStorageApi = useApi(techdocsStorageApiRef);
// convert all path changes into actions
useEffect(() => {
dispatch({ type: 'navigate', path });
}, [path]);
// try to load the content. the function will fire events and we don't care for the return values
const { retry: contentReload } = useAsyncRetry(async () => {
dispatch({ type: 'content', contentLoading: true });
dispatch({ type: 'contentLoading' });
try {
const entityDocs = await techdocsStorageApi.getEntityDocs(
@@ -253,11 +257,12 @@ export function useReaderState(
path,
);
dispatch({ type: 'content', content: entityDocs });
// update content and path at the same time
dispatch({ type: 'content', content: entityDocs, path });
return entityDocs;
} catch (e) {
dispatch({ type: 'content', contentError: e });
dispatch({ type: 'content', contentError: e, path });
}
return undefined;
@@ -335,6 +340,7 @@ export function useReaderState(
return {
state: displayState,
contentReload,
path: state.path,
content: state.content,
contentErrorMessage: state.contentError?.toString(),
syncErrorMessage: state.syncError?.toString(),