Implement DefaultTechDocsCollator
* Implements a collator for tech docs. * Retrieves mkdocs created search index for entities that have documentation configured * Registers collator to expose tech docs content to be searchable * Adds pagination to example search * Modifies example search to contain tech docs * Displays docs results with link to docs and the entity name as title. * Creates a reusable type filter to be located in the search package. * Add tests for type filter Signed-off-by: Jussi Hallila <jussi@hallila.com>
This commit is contained in:
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'@backstage/plugin-techdocs': patch
|
||||
---
|
||||
|
||||
Add search list item to display tech docs search results
|
||||
@@ -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
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'@backstage/plugin-search': patch
|
||||
---
|
||||
|
||||
Adding a type filter to new search
|
||||
@@ -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.4",
|
||||
"@roadiehq/backstage-plugin-github-insights": "^1.1.15",
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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';
|
||||
@@ -16,6 +16,7 @@
|
||||
|
||||
export * from './Filters';
|
||||
export * from './SearchFilter';
|
||||
export * from './SearchType';
|
||||
export * from './SearchBar';
|
||||
export * from './SearchPage';
|
||||
export * from './SearchResult';
|
||||
|
||||
@@ -32,6 +32,7 @@ export {
|
||||
useSearch,
|
||||
SearchPage as Router,
|
||||
SearchFilter,
|
||||
SearchType,
|
||||
SearchFilterNext,
|
||||
SidebarSearch,
|
||||
} from './components';
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -35,6 +35,7 @@
|
||||
"@backstage/catalog-model": "^0.9.0",
|
||||
"@backstage/config": "^0.1.5",
|
||||
"@backstage/errors": "^0.1.1",
|
||||
"@backstage/search-common": "^0.1.2",
|
||||
"@backstage/techdocs-common": "^0.6.8",
|
||||
"@types/express": "^4.17.6",
|
||||
"cross-fetch": "^3.0.6",
|
||||
@@ -43,10 +44,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"
|
||||
|
||||
@@ -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';
|
||||
@@ -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)
|
||||
|
||||
@@ -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';
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user