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:
Jussi Hallila
2021-07-13 09:07:18 +02:00
parent ffae1bb6e4
commit 9266b80ab3
24 changed files with 968 additions and 28 deletions
+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';