Merge pull request #5786 from backstage/mob/search-fe

[Search] V1 Frontend
This commit is contained in:
Eric Peterson
2021-06-04 15:08:38 +02:00
committed by GitHub
42 changed files with 2167 additions and 121 deletions
@@ -30,6 +30,7 @@ export interface CatalogEntityDocument extends IndexableDocument {
export class DefaultCatalogCollator implements DocumentCollator {
protected discovery: PluginEndpointDiscovery;
protected locationTemplate: string;
public readonly type: string = 'software-catalog';
constructor({
discovery,
@@ -0,0 +1,59 @@
/*
* Copyright 2021 Spotify AB
*
* 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 { Link } from '@backstage/core';
import {
Box,
Chip,
Divider,
ListItem,
ListItemText,
makeStyles,
} from '@material-ui/core';
const useStyles = makeStyles({
flexContainer: {
flexWrap: 'wrap',
},
itemText: {
width: '100%',
marginBottom: '1rem',
},
});
export const CatalogResultListItem = ({ result }: any) => {
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}
secondary={result.text}
/>
<Box>
{result.kind && <Chip label={`Kind: ${result.kind}`} size="small" />}
{result.lifecycle && (
<Chip label={`Lifecycle: ${result.lifecycle}`} size="small" />
)}
</Box>
</ListItem>
<Divider component="li" />
</Link>
);
};
@@ -0,0 +1,17 @@
/*
* Copyright 2021 Spotify AB
*
* 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 { CatalogResultListItem } from './CatalogResultListItem';
+1
View File
@@ -15,6 +15,7 @@
*/
export { AboutCard } from './components/AboutCard';
export { CatalogResultListItem } from './components/CatalogResultListItem';
export { EntityLayout } from './components/EntityLayout';
export { EntityPageLayout } from './components/EntityPageLayout';
export { CatalogTable } from './components/CatalogTable';
@@ -24,22 +24,33 @@ import { IndexBuilder } from './IndexBuilder';
import { LunrSearchEngine, SearchEngine } from './index';
class TestDocumentCollator implements DocumentCollator {
async execute() {
readonly type: string = 'anything';
async execute(): Promise<IndexableDocument[]> {
return [];
}
}
class TypedDocumentCollator extends TestDocumentCollator {
readonly type = 'an-expected-type';
}
class TestDocumentDecorator implements DocumentDecorator {
async execute(documents: IndexableDocument[]) {
return documents;
}
}
class TypedDocumentDecorator extends TestDocumentDecorator {
readonly types = ['an-expected-type'];
}
class DifferentlyTypedDocumentDecorator extends TestDocumentDecorator {
readonly types = ['not-the-expected-type'];
}
describe('IndexBuilder', () => {
let testSearchEngine: SearchEngine;
let testIndexBuilder: IndexBuilder;
let testCollator: DocumentCollator;
let testDecorator: DocumentDecorator;
beforeEach(() => {
const logger = getVoidLogger();
@@ -48,18 +59,16 @@ describe('IndexBuilder', () => {
logger,
searchEngine: testSearchEngine,
});
testCollator = new TestDocumentCollator();
testDecorator = new TestDocumentDecorator();
});
describe('addCollator', () => {
it('adds a collator', async () => {
jest.useFakeTimers();
const testCollator = new TestDocumentCollator();
const collatorSpy = jest.spyOn(testCollator, 'execute');
// Add a collator.
testIndexBuilder.addCollator({
type: 'anything',
defaultRefreshIntervalSeconds: 6,
collator: testCollator,
});
@@ -75,11 +84,12 @@ describe('IndexBuilder', () => {
describe('addDecorator', () => {
it('adds a decorator', async () => {
jest.useFakeTimers();
const testCollator = new TestDocumentCollator();
const testDecorator = new TestDocumentDecorator();
const decoratorSpy = jest.spyOn(testDecorator, 'execute');
// Add a collator.
testIndexBuilder.addCollator({
type: 'anything',
defaultRefreshIntervalSeconds: 6,
collator: testCollator,
});
@@ -100,7 +110,8 @@ describe('IndexBuilder', () => {
it('adds a type-specific decorator', async () => {
jest.useFakeTimers();
const expectedType = 'an-expected-type';
const testCollator = new TypedDocumentCollator();
const testDecorator = new TypedDocumentDecorator();
const docFixture = {
title: 'Test',
text: 'Test text.',
@@ -113,14 +124,12 @@ describe('IndexBuilder', () => {
// Add a collator.
testIndexBuilder.addCollator({
type: expectedType,
defaultRefreshIntervalSeconds: 6,
collator: testCollator,
});
// Add a decorator for the same type.
testIndexBuilder.addDecorator({
types: [expectedType],
decorator: testDecorator,
});
@@ -135,12 +144,13 @@ describe('IndexBuilder', () => {
});
it('adds a type-specific decorator that should not be called', async () => {
const expectedType = 'an-expected-type';
const docFixture = {
title: 'Test',
text: 'Test text.',
location: '/test/location',
};
const testCollator = new TestDocumentCollator();
const testDecorator = new DifferentlyTypedDocumentDecorator();
const collatorSpy = jest
.spyOn(testCollator, 'execute')
.mockImplementation(async () => [docFixture]);
@@ -148,14 +158,12 @@ describe('IndexBuilder', () => {
// Add a collator.
testIndexBuilder.addCollator({
type: expectedType,
defaultRefreshIntervalSeconds: 6,
collator: testCollator,
});
// Add a decorator for a different type.
testIndexBuilder.addDecorator({
types: ['not-the-expected-type'],
decorator: testDecorator,
});
@@ -55,28 +55,25 @@ export class IndexBuilder {
* given refresh interval.
*/
addCollator({
type,
collator,
defaultRefreshIntervalSeconds,
}: RegisterCollatorParameters): void {
this.logger.info(
`Added ${collator.constructor.name} collator for type ${type}`,
`Added ${collator.constructor.name} collator for type ${collator.type}`,
);
this.collators[type] = {
this.collators[collator.type] = {
refreshInterval: defaultRefreshIntervalSeconds,
collate: collator,
};
}
/**
* Makes the index builder aware of a decorator. If no types are provided, it
* will be applied to documents from all known collators, otherwise it will
* only be applied to documents of the given types.
* Makes the index builder aware of a decorator. If no types are provided on
* the decorator, it will be applied to documents from all known collators,
* otherwise it will only be applied to documents of the given types.
*/
addDecorator({
types = ['*'],
decorator,
}: RegisterDecoratorParameters): void {
addDecorator({ decorator }: RegisterDecoratorParameters): void {
const types = decorator.types || ['*'];
this.logger.info(
`Added decorator ${decorator.constructor.name} to types ${types.join(
', ',
@@ -18,6 +18,15 @@ import { getVoidLogger } from '@backstage/backend-common';
import { LunrSearchEngine } from './LunrSearchEngine';
import { SearchEngine } from '../types';
/**
* Just used to test the default translator shipped with LunrSearchEngine.
*/
class LunrSearchEngineForTranslatorTests extends LunrSearchEngine {
getTranslator() {
return this.translator;
}
}
describe('LunrSearchEngine', () => {
let testLunrSearchEngine: SearchEngine;
@@ -27,16 +36,21 @@ describe('LunrSearchEngine', () => {
describe('translator', () => {
it('query translator invoked', async () => {
const translatorSpy = jest.spyOn(testLunrSearchEngine, 'translator');
// Given: Set a translator spy on the search engine.
const translatorSpy = jest.fn().mockReturnValue({
lunrQueryString: '',
documentTypes: [],
});
testLunrSearchEngine.setTranslator(translatorSpy);
// Translate query and ensure the translator was invoked.
await testLunrSearchEngine.translator({
// When: querying the search engine
testLunrSearchEngine.query({
term: 'testTerm',
filters: {},
pageCursor: '',
});
expect(translatorSpy).toHaveBeenCalled();
// Then: the translator is invoked with expected args.
expect(translatorSpy).toHaveBeenCalledWith({
term: 'testTerm',
filters: {},
@@ -45,41 +59,56 @@ describe('LunrSearchEngine', () => {
});
it('should return translated query', async () => {
const mockedTranslatedQuery = await testLunrSearchEngine.translator({
const inspectableSearchEngine = new LunrSearchEngineForTranslatorTests({
logger: getVoidLogger(),
});
const translatorUnderTest = inspectableSearchEngine.getTranslator();
const actualTranslatedQuery = translatorUnderTest({
term: 'testTerm',
filters: {},
pageCursor: '',
});
expect(mockedTranslatedQuery).toMatchObject({
expect(actualTranslatedQuery).toMatchObject({
documentTypes: ['*'],
lunrQueryString: 'testTerm',
lunrQueryString: '+testTerm',
});
});
it('should return translated query with 1 filter', async () => {
const mockedTranslatedQuery = await testLunrSearchEngine.translator({
const inspectableSearchEngine = new LunrSearchEngineForTranslatorTests({
logger: getVoidLogger(),
});
const translatorUnderTest = inspectableSearchEngine.getTranslator();
const actualTranslatedQuery = translatorUnderTest({
term: 'testTerm',
filters: { kind: 'testKind' },
pageCursor: '',
});
expect(mockedTranslatedQuery).toMatchObject({
expect(actualTranslatedQuery).toMatchObject({
documentTypes: ['*'],
lunrQueryString: 'testTerm +kind:testKind',
lunrQueryString: '+testTerm +kind:testKind',
});
});
it('should return translated query with multiple filters', async () => {
const mockedTranslatedQuery = await testLunrSearchEngine.translator({
const inspectableSearchEngine = new LunrSearchEngineForTranslatorTests({
logger: getVoidLogger(),
});
const translatorUnderTest = inspectableSearchEngine.getTranslator();
const actualTranslatedQuery = translatorUnderTest({
term: 'testTerm',
filters: { kind: 'testKind', namespace: 'testNameSpace' },
pageCursor: '',
});
expect(mockedTranslatedQuery).toMatchObject({
expect(actualTranslatedQuery).toMatchObject({
documentTypes: ['*'],
lunrQueryString: 'testTerm +kind:testKind +namespace:testNameSpace',
lunrQueryString: '+testTerm +kind:testKind +namespace:testNameSpace',
});
});
});
@@ -28,6 +28,13 @@ type ConcreteLunrQuery = {
documentTypes: string[];
};
type LunrResultEnvelope = {
result: lunr.Index.Result;
type: string;
};
type LunrQueryTranslator = (query: SearchQuery) => ConcreteLunrQuery;
export class LunrSearchEngine implements SearchEngine {
protected lunrIndices: Record<string, lunr.Index> = {};
protected docStore: Record<string, IndexableDocument>;
@@ -38,24 +45,49 @@ export class LunrSearchEngine implements SearchEngine {
this.docStore = {};
}
translator: QueryTranslator = ({
protected translator: QueryTranslator = ({
term,
filters,
types,
}: SearchQuery): ConcreteLunrQuery => {
let lunrQueryFilters;
const lunrTerm = term ? `+${term}` : '';
if (filters) {
lunrQueryFilters = Object.entries(filters)
.map(([key, value]) => ` +${key}:${value}`)
.map(([field, value]) => {
// Require that the given field has the given value (with +).
if (['string', 'number', 'boolean'].includes(typeof value)) {
return ` +${field}:${value}`;
}
// Illustrate how multi-value filters could work.
if (Array.isArray(value)) {
// But warn that Lurn supports this poorly.
this.logger.warn(
`Non-scalar filter value used for field ${field}. Consider using a different Search Engine for better results.`,
);
return ` ${value.map(v => {
return `${field}:${v}`;
})}`;
}
// Log a warning or something about unknown filter value
this.logger.warn(`Unknown filter type used on field ${field}`);
return '';
})
.join('');
}
return {
lunrQueryString: `${term}${lunrQueryFilters || ''}`,
lunrQueryString: `${lunrTerm}${lunrQueryFilters || ''}`,
documentTypes: types || ['*'],
};
};
setTranslator(translator: LunrQueryTranslator) {
this.translator = translator;
}
index(type: string, documents: IndexableDocument[]): void {
const lunrBuilder = new lunr.Builder();
// Make this lunr index aware of all relevant fields.
@@ -83,13 +115,20 @@ export class LunrSearchEngine implements SearchEngine {
query,
) as ConcreteLunrQuery;
const results: lunr.Index.Result[] = [];
const results: LunrResultEnvelope[] = [];
if (documentTypes.length === 1 && documentTypes[0] === '*') {
// Iterate over all this.lunrIndex values.
Object.values(this.lunrIndices).forEach(i => {
Object.keys(this.lunrIndices).forEach(type => {
try {
results.push(...i.search(lunrQueryString));
results.push(
...this.lunrIndices[type].search(lunrQueryString).map(result => {
return {
result: result,
type: type,
};
}),
);
} catch (err) {
// if a field does not exist on a index, we can see that as a no-match
if (
@@ -102,10 +141,17 @@ export class LunrSearchEngine implements SearchEngine {
} else {
// Iterate over the filtered list of this.lunrIndex keys.
Object.keys(this.lunrIndices)
.filter(d => documentTypes.includes(d))
.forEach(d => {
.filter(type => documentTypes.includes(type))
.forEach(type => {
try {
results.push(...this.lunrIndices[d].search(lunrQueryString));
results.push(
...this.lunrIndices[type].search(lunrQueryString).map(result => {
return {
result: result,
type: type,
};
}),
);
} catch (err) {
// if a field does not exist on a index, we can see that as a no-match
if (
@@ -119,16 +165,16 @@ export class LunrSearchEngine implements SearchEngine {
// Sort results.
results.sort((doc1, doc2) => {
return doc2.score - doc1.score;
return doc2.result.score - doc1.result.score;
});
// Translate results into SearchResultSet
const resultSet: SearchResultSet = {
const realResultSet: SearchResultSet = {
results: results.map(d => {
return { document: this.docStore[d.ref] };
return { type: d.type, document: this.docStore[d.result.ref] };
}),
};
return Promise.resolve(resultSet);
return Promise.resolve(realResultSet);
}
}
+12 -12
View File
@@ -26,11 +26,6 @@ import {
* Parameters required to register a collator.
*/
export interface RegisterCollatorParameters {
/**
* The type of document to be indexed (used to name indices, to configure refresh loop, etc).
*/
type: string;
/**
* The default interval (in seconds) that the provided collator will be called (can be overridden in config).
*/
@@ -50,12 +45,6 @@ export interface RegisterDecoratorParameters {
* The decorator class responsible for appending or modifying documents of the given type(s).
*/
decorator: DocumentDecorator;
/**
* (Optional) An array of document types that the given decorator should apply to. If none are provided,
* the decorator will be applied to all types.
*/
types?: string[];
}
/**
@@ -70,7 +59,18 @@ export type QueryTranslator = (query: SearchQuery) => unknown;
* concrete, search engine-specific queries.
*/
export interface SearchEngine {
translator: QueryTranslator;
/**
* Override the default translator provided by the SearchEngine.
*/
setTranslator(translator: QueryTranslator): void;
/**
* Add the given documents to the SearchEngine index of the given type.
*/
index(type: string, documents: IndexableDocument[]): void;
/**
* Perform a search query against the SearchEngine.
*/
query(query: SearchQuery): Promise<SearchResultSet>;
}
+5
View File
@@ -33,13 +33,16 @@
"@backstage/catalog-model": "^0.8.0",
"@backstage/plugin-catalog-react": "^0.2.0",
"@backstage/search-common": "^0.1.1",
"@backstage/config": "^0.1.5",
"@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",
"@types/react": "^16.9",
"qs": "^6.9.4",
"react": "^16.13.1",
"react-dom": "^16.13.1",
"react-router": "6.0.0-beta.0",
"react-router-dom": "6.0.0-beta.0",
"react-use": "^17.2.4"
},
@@ -49,7 +52,9 @@
"@backstage/test-utils": "^0.1.13",
"@testing-library/jest-dom": "^5.10.1",
"@testing-library/react": "^11.2.5",
"@testing-library/react-hooks": "^3.4.2",
"@testing-library/user-event": "^13.1.8",
"@types/react": "^16.9",
"@types/jest": "^26.0.7",
"@types/node": "^14.14.32",
"cross-fetch": "^3.0.6",
+55
View File
@@ -0,0 +1,55 @@
/*
* Copyright 2021 Spotify AB
*
* 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 { CatalogApi } from '@backstage/plugin-catalog-react';
import { SearchClient } from './apis';
describe('apis', () => {
const query = {
term: '',
filters: {},
types: [],
pageCursor: '',
};
const baseUrl = 'https://base-url.com/';
const getBaseUrl = jest.fn().mockResolvedValue(baseUrl);
const client = new SearchClient({
catalogApi: {} as CatalogApi,
discoveryApi: { getBaseUrl },
});
const json = jest.fn();
const originalFetch = window.fetch;
window.fetch = jest.fn().mockResolvedValue({ json });
afterAll(() => {
window.fetch = originalFetch;
});
it('Fetch is called with expected URL (including stringified Q params)', async () => {
await client._alphaPerformSearch(query);
expect(getBaseUrl).toHaveBeenLastCalledWith('search/query');
expect(fetch).toHaveBeenLastCalledWith(`${baseUrl}?term=&pageCursor=`);
});
it('Resolves JSON from fetch response', async () => {
const result = { loading: false, error: '', value: {} };
json.mockReturnValueOnce(result);
expect(await client._alphaPerformSearch(query)).toStrictEqual(result);
});
});
@@ -0,0 +1,41 @@
/*
* Copyright 2021 Spotify AB
*
* 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 } from '@testing-library/react';
import { renderInTestApp } from '@backstage/test-utils';
import { DefaultResultListItem } from './DefaultResultListItem';
describe('DefaultResultListItem', () => {
const result = {
title: 'title',
text: 'text',
location: '/location',
};
it('Links to result.location', async () => {
await renderInTestApp(<DefaultResultListItem result={result} />);
expect(screen.getByRole('link')).toHaveAttribute('href', result.location);
});
it('Includes primary/secondary text (title / text)', async () => {
await renderInTestApp(<DefaultResultListItem result={result} />);
expect(screen.getByRole('listitem')).toHaveTextContent(
result.title + result.text,
);
});
});
@@ -0,0 +1,39 @@
/*
* Copyright 2021 Spotify AB
*
* 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 { Link } from '@backstage/core';
import { IndexableDocument } from '@backstage/search-common';
import { ListItem, ListItemText, Divider } from '@material-ui/core';
type Props = {
result: IndexableDocument;
};
export const DefaultResultListItem = ({ result }: Props) => {
return (
<Link to={result.location}>
<ListItem alignItems="flex-start">
<ListItemText
primaryTypographyProps={{ variant: 'h6' }}
primary={result.title}
secondary={result.text}
/>
</ListItem>
<Divider component="li" />
</Link>
);
};
@@ -0,0 +1,17 @@
/*
* Copyright 2021 Spotify AB
*
* 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 { DefaultResultListItem } from './DefaultResultListItem';
@@ -0,0 +1,153 @@
/*
* Copyright 2021 Spotify AB
*
* 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, act } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { useApi } from '@backstage/core';
import { SearchContextProvider } from '../SearchContext';
import { SearchBarNext } from './SearchBarNext';
jest.mock('@backstage/core', () => ({
...jest.requireActual('@backstage/core'),
useApi: jest.fn().mockReturnValue({}),
}));
describe('SearchBarNext', () => {
const initialState = {
term: '',
pageCursor: '',
filters: {},
types: ['*'],
};
const name = 'Search term';
const term = 'term';
const _alphaPerformSearch = jest.fn().mockResolvedValue({});
(useApi as jest.Mock).mockReturnValue({ _alphaPerformSearch });
afterAll(() => {
jest.resetAllMocks();
});
it('Renders without exploding', async () => {
render(
<SearchContextProvider initialState={initialState}>
<SearchBarNext />
</SearchContextProvider>,
);
await waitFor(() => {
expect(screen.getByRole('textbox', { name })).toBeInTheDocument();
});
});
it('Renders based on initial search', async () => {
render(
<SearchContextProvider initialState={{ ...initialState, term }}>
<SearchBarNext />
</SearchContextProvider>,
);
await waitFor(() => {
expect(screen.getByRole('textbox', { name })).toHaveValue(term);
});
});
it('Updates term state when text is entered', async () => {
render(
<SearchContextProvider initialState={initialState}>
<SearchBarNext />
</SearchContextProvider>,
);
const textbox = screen.getByRole('textbox', { name });
const value = 'value';
userEvent.type(textbox, value);
await waitFor(() => {
expect(textbox).toHaveValue(value);
});
expect(_alphaPerformSearch).toHaveBeenLastCalledWith(
expect.objectContaining({ term: value }),
);
});
it('Clear button clears term state', async () => {
render(
<SearchContextProvider initialState={{ ...initialState, term }}>
<SearchBarNext />
</SearchContextProvider>,
);
await waitFor(() => {
expect(screen.getByRole('textbox', { name })).toHaveValue(term);
});
userEvent.click(screen.getByRole('button', { name: 'Clear term' }));
await waitFor(() => {
expect(screen.getByRole('textbox', { name })).toHaveValue('');
});
expect(_alphaPerformSearch).toHaveBeenLastCalledWith(
expect.objectContaining({ term: '' }),
);
});
it('Adheres to provided debounceTime', async () => {
jest.useFakeTimers();
const debounceTime = 600;
render(
<SearchContextProvider initialState={initialState}>
<SearchBarNext debounceTime={debounceTime} />
</SearchContextProvider>,
);
await waitFor(() => {
expect(screen.getByRole('textbox', { name })).toBeInTheDocument();
});
const textbox = screen.getByRole('textbox', { name });
const value = 'value';
userEvent.type(textbox, value);
expect(_alphaPerformSearch).not.toHaveBeenLastCalledWith(
expect.objectContaining({ term: value }),
);
act(() => {
jest.advanceTimersByTime(debounceTime);
});
await waitFor(() => {
expect(textbox).toHaveValue(value);
});
expect(_alphaPerformSearch).toHaveBeenLastCalledWith(
expect.objectContaining({ term: value }),
);
});
});
@@ -0,0 +1,67 @@
/*
* Copyright 2021 Spotify AB
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import React, { ChangeEvent, useState } from 'react';
import { useDebounce } from 'react-use';
import { InputBase, InputAdornment, IconButton } from '@material-ui/core';
import SearchIcon from '@material-ui/icons/Search';
import ClearButton from '@material-ui/icons/Clear';
import { useSearch } from '../SearchContext';
type Props = {
className?: string;
debounceTime?: number;
};
export const SearchBarNext = ({ className, debounceTime = 0 }: Props) => {
const { term, setTerm } = useSearch();
const [value, setValue] = useState<string>(term);
useDebounce(() => setTerm(value), debounceTime, [value]);
const handleQuery = (e: ChangeEvent<HTMLInputElement>) => {
setValue(e.target.value);
};
const handleClear = () => setValue('');
return (
<InputBase
className={className}
data-testid="search-bar-next"
fullWidth
placeholder="Search in Backstage"
value={value}
onChange={handleQuery}
inputProps={{ 'aria-label': 'Search term' }}
startAdornment={
<InputAdornment position="start">
<IconButton aria-label="Query term" disabled>
<SearchIcon />
</IconButton>
</InputAdornment>
}
endAdornment={
<InputAdornment position="end">
<IconButton aria-label="Clear term" onClick={handleClear}>
<ClearButton />
</IconButton>
</InputAdornment>
}
/>
);
};
@@ -0,0 +1,17 @@
/*
* Copyright 2021 Spotify AB
*
* 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 { SearchBarNext } from './SearchBarNext';
@@ -0,0 +1,219 @@
/*
* Copyright 2021 Spotify AB
*
* 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, screen, waitFor } from '@testing-library/react';
import { renderHook, act } from '@testing-library/react-hooks';
import { useApi } from '@backstage/core';
import { useSearch, SearchContextProvider } from './SearchContext';
jest.mock('@backstage/core', () => ({
...jest.requireActual('@backstage/core'),
useApi: jest.fn(),
}));
describe('SearchContext', () => {
const _alphaPerformSearch = jest.fn();
const wrapper = ({ children, initialState }: any) => (
<SearchContextProvider initialState={initialState}>
{children}
</SearchContextProvider>
);
const initialState = {
term: '',
pageCursor: '',
filters: {},
types: ['*'],
};
beforeEach(() => {
_alphaPerformSearch.mockResolvedValue({});
(useApi as jest.Mock).mockReturnValue({ _alphaPerformSearch });
});
afterAll(() => {
jest.resetAllMocks();
});
it('Passes children', async () => {
const text = 'text';
render(
<SearchContextProvider initialState={initialState}>
{text}
</SearchContextProvider>,
);
await waitFor(() => {
expect(screen.getByText(text)).toBeInTheDocument();
});
});
it('Throws error when no context is set', () => {
const { result } = renderHook(() => useSearch());
expect(result.error).toEqual(
Error('useSearch must be used within a SearchContextProvider'),
);
});
it('Uses initial state values', async () => {
const { result, waitForNextUpdate } = renderHook(() => useSearch(), {
wrapper,
initialProps: {
initialState,
},
});
await waitForNextUpdate();
expect(result.current).toEqual(expect.objectContaining(initialState));
});
it('Resets cursor when term is set (and different from previous)', async () => {
const { result, waitForNextUpdate } = renderHook(() => useSearch(), {
wrapper,
initialProps: {
initialState: {
...initialState,
pageCursor: '1',
},
},
});
await waitForNextUpdate();
expect(result.current.pageCursor).toBe('1');
act(() => {
result.current.setTerm('first term');
});
await waitForNextUpdate();
expect(result.current.pageCursor).toBe('1');
act(() => {
result.current.setTerm('second term');
});
await waitForNextUpdate();
expect(result.current.pageCursor).toBe('');
});
describe('Performs search (and sets results)', () => {
it('When term is set', async () => {
const { result, waitForNextUpdate } = renderHook(() => useSearch(), {
wrapper,
initialProps: {
initialState,
},
});
await waitForNextUpdate();
const term = 'term';
act(() => {
result.current.setTerm(term);
});
await waitForNextUpdate();
expect(_alphaPerformSearch).toHaveBeenLastCalledWith({
...initialState,
term,
});
});
it('When filters are set', async () => {
const { result, waitForNextUpdate } = renderHook(() => useSearch(), {
wrapper,
initialProps: {
initialState,
},
});
await waitForNextUpdate();
const filters = { filter: 'filter' };
act(() => {
result.current.setFilters(filters);
});
await waitForNextUpdate();
expect(_alphaPerformSearch).toHaveBeenLastCalledWith({
...initialState,
filters,
});
});
it('When pageCursor is set', async () => {
const { result, waitForNextUpdate } = renderHook(() => useSearch(), {
wrapper,
initialProps: {
initialState,
},
});
await waitForNextUpdate();
const pageCursor = 'pageCursor';
act(() => {
result.current.setPageCursor(pageCursor);
});
await waitForNextUpdate();
expect(_alphaPerformSearch).toHaveBeenLastCalledWith({
...initialState,
pageCursor,
});
});
it('When types is set', async () => {
const { result, waitForNextUpdate } = renderHook(() => useSearch(), {
wrapper,
initialProps: {
initialState,
},
});
await waitForNextUpdate();
const types = ['type'];
act(() => {
result.current.setTypes(types);
});
await waitForNextUpdate();
expect(_alphaPerformSearch).toHaveBeenLastCalledWith({
...initialState,
types,
});
});
});
});
@@ -0,0 +1,105 @@
/*
* Copyright 2021 Spotify AB
*
* 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, {
PropsWithChildren,
createContext,
useContext,
useState,
useEffect,
} from 'react';
import { useAsync, usePrevious } from 'react-use';
import { useApi } from '@backstage/core';
import { SearchResultSet } from '@backstage/search-common';
import { searchApiRef } from '../../apis';
import { AsyncState } from 'react-use/lib/useAsync';
import { JsonObject } from '@backstage/config';
type SearchContextValue = {
result: AsyncState<SearchResultSet>;
term: string;
setTerm: React.Dispatch<React.SetStateAction<string>>;
types: string[];
setTypes: React.Dispatch<React.SetStateAction<string[]>>;
filters: JsonObject;
setFilters: React.Dispatch<React.SetStateAction<JsonObject>>;
pageCursor: string;
setPageCursor: React.Dispatch<React.SetStateAction<string>>;
};
type SettableSearchContext = Omit<
SearchContextValue,
'result' | 'setTerm' | 'setTypes' | 'setFilters' | 'setPageCursor'
>;
const SearchContext = createContext<SearchContextValue | undefined>(undefined);
export const SearchContextProvider = ({
initialState = {
term: '',
pageCursor: '',
filters: {},
types: ['*'],
},
children,
}: PropsWithChildren<{ initialState?: SettableSearchContext }>) => {
const searchApi = useApi(searchApiRef);
const [pageCursor, setPageCursor] = useState<string>(initialState.pageCursor);
const [filters, setFilters] = useState<JsonObject>(initialState.filters);
const [term, setTerm] = useState<string>(initialState.term);
const [types, setTypes] = useState<string[]>(initialState.types);
const prevTerm = usePrevious(term);
const result = useAsync(
() =>
searchApi._alphaPerformSearch({
term,
filters,
pageCursor,
types,
}),
[term, filters, types, pageCursor],
);
useEffect(() => {
// Any time a term is reset, we want to start from page 0.
if (term && prevTerm && term !== prevTerm) {
setPageCursor('');
}
}, [term, prevTerm]);
const value: SearchContextValue = {
result,
filters,
setFilters,
term,
setTerm,
types,
setTypes,
pageCursor,
setPageCursor,
};
return <SearchContext.Provider value={value} children={children} />;
};
export const useSearch = () => {
const context = useContext(SearchContext);
if (context === undefined) {
throw new Error('useSearch must be used within a SearchContextProvider');
}
return context;
};
@@ -0,0 +1,17 @@
/*
* Copyright 2021 Spotify AB
*
* 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 { SearchContextProvider, useSearch } from './SearchContext';
@@ -0,0 +1,375 @@
/*
* Copyright 2021 Spotify AB
*
* 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 { useApi } from '@backstage/core';
import { SearchFilterNext } from './SearchFilterNext';
import { SearchContextProvider } from '../SearchContext';
jest.mock('@backstage/core', () => ({
...jest.requireActual('@backstage/core'),
useApi: jest.fn().mockReturnValue({}),
}));
describe('SearchFilterNext', () => {
const initialState = {
term: '',
filters: {},
types: [],
pageCursor: '',
};
const name = 'field';
const values = ['value1', 'value2'];
const filters = { unrelated: 'unrelated' };
const _alphaPerformSearch = jest.fn().mockResolvedValue({});
(useApi as jest.Mock).mockReturnValue({ _alphaPerformSearch });
afterAll(() => {
jest.resetAllMocks();
});
it('Check that element was rendered and received props', async () => {
const CustomFilter = (props: { name: string }) => <h6>{props.name}</h6>;
render(<SearchFilterNext name={name} component={CustomFilter} />);
expect(screen.getByRole('heading', { name })).toBeInTheDocument();
});
describe('Checkbox', () => {
it('Renders field name and values when provided as props', async () => {
render(
<SearchContextProvider initialState={initialState}>
<SearchFilterNext.Checkbox name={name} values={values} />
</SearchContextProvider>,
);
await waitFor(() => {
expect(screen.getByText(name)).toBeInTheDocument();
});
expect(
screen.getByRole('checkbox', { name: values[0] }),
).toBeInTheDocument();
expect(
screen.getByRole('checkbox', { name: values[1] }),
).toBeInTheDocument();
});
it('Renders correctly based on filter state', async () => {
render(
<SearchContextProvider
initialState={{
...initialState,
filters: {
[name]: [values[1]],
},
}}
>
<SearchFilterNext.Checkbox name={name} values={values} />
</SearchContextProvider>,
);
await waitFor(() => {
expect(screen.getByText(name)).toBeInTheDocument();
});
expect(
screen.getByRole('checkbox', { name: values[0] }),
).not.toBeChecked();
expect(screen.getByRole('checkbox', { name: values[1] })).toBeChecked();
});
it('Renders correctly based on defaultValue', async () => {
render(
<SearchContextProvider initialState={initialState}>
<SearchFilterNext.Checkbox
name={name}
values={values}
defaultValue={[values[0]]}
/>
</SearchContextProvider>,
);
await waitFor(() => {
expect(screen.getByText(name)).toBeInTheDocument();
});
expect(screen.getByRole('checkbox', { name: values[0] })).toBeChecked();
expect(
screen.getByRole('checkbox', { name: values[1] }),
).not.toBeChecked();
});
it('Checking / unchecking a value sets filter state', async () => {
render(
<SearchContextProvider initialState={initialState}>
<SearchFilterNext.Checkbox name={name} values={values} />
</SearchContextProvider>,
);
await waitFor(() => {
expect(screen.getByText(name)).toBeInTheDocument();
});
const checkBox = screen.getByRole('checkbox', { name: values[0] });
// Check the box.
userEvent.click(checkBox);
await waitFor(() => {
expect(_alphaPerformSearch).toHaveBeenLastCalledWith(
expect.objectContaining({ filters: { field: [values[0]] } }),
);
});
// Uncheck the box.
userEvent.click(checkBox);
await waitFor(() => {
expect(_alphaPerformSearch).toHaveBeenLastCalledWith(
expect.objectContaining({ filters: {} }),
);
});
});
it('Checking / unchecking a value maintains unrelated filter state', async () => {
render(
<SearchContextProvider initialState={{ ...initialState, filters }}>
<SearchFilterNext.Checkbox name={name} values={values} />
</SearchContextProvider>,
);
await waitFor(() => {
expect(screen.getByText(name)).toBeInTheDocument();
});
const checkBox = screen.getByRole('checkbox', { name: values[0] });
// Check the box.
userEvent.click(checkBox);
await waitFor(() => {
expect(_alphaPerformSearch).toHaveBeenLastCalledWith(
expect.objectContaining({
filters: { ...filters, field: [values[0]] },
}),
);
});
// Uncheck the box.
userEvent.click(checkBox);
await waitFor(() => {
expect(_alphaPerformSearch).toHaveBeenLastCalledWith(
expect.objectContaining({ filters }),
);
});
});
});
describe('Select', () => {
it('Renders field name and values when provided as props', async () => {
render(
<SearchContextProvider initialState={initialState}>
<SearchFilterNext.Select 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 filter state', async () => {
render(
<SearchContextProvider
initialState={{
...initialState,
filters: {
[name]: values[0],
},
}}
>
<SearchFilterNext.Select 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 defaultValue', async () => {
render(
<SearchContextProvider initialState={initialState}>
<SearchFilterNext.Select
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 filter state', async () => {
render(
<SearchContextProvider initialState={initialState}>
<SearchFilterNext.Select 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(_alphaPerformSearch).toHaveBeenLastCalledWith(
expect.objectContaining({
filters: { [name]: values[0] },
}),
);
});
userEvent.click(button);
await waitFor(() => {
expect(screen.getByRole('listbox')).toBeInTheDocument();
});
userEvent.click(screen.getByRole('option', { name: 'All' }));
await waitFor(() => {
expect(_alphaPerformSearch).toHaveBeenLastCalledWith(
expect.objectContaining({
filters: {},
}),
);
});
});
it('Selecting a value maintains unrelated filter state', async () => {
render(
<SearchContextProvider
initialState={{
...initialState,
filters,
}}
>
<SearchFilterNext.Select 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(_alphaPerformSearch).toHaveBeenLastCalledWith(
expect.objectContaining({
filters: { ...filters, [name]: values[0] },
}),
);
});
userEvent.click(button);
await waitFor(() => {
expect(screen.getByRole('listbox')).toBeInTheDocument();
});
userEvent.click(screen.getByRole('option', { name: 'All' }));
await waitFor(() => {
expect(_alphaPerformSearch).toHaveBeenLastCalledWith(
expect.objectContaining({ filters }),
);
});
});
});
});
@@ -0,0 +1,179 @@
/*
* Copyright 2021 Spotify AB
*
* 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, { ReactElement, ChangeEvent, useEffect } from 'react';
import {
makeStyles,
FormControl,
FormControlLabel,
InputLabel,
Checkbox,
Select,
MenuItem,
FormLabel,
} from '@material-ui/core';
import { useSearch } from '../SearchContext';
const useStyles = makeStyles({
label: {
textTransform: 'capitalize',
},
});
export type Component = {
className?: string;
name: string;
values?: string[];
defaultValue?: string[] | string | null;
};
export type Props = Component & {
component: (props: Component) => ReactElement;
debug?: boolean;
};
const CheckboxFilter = ({
className,
name,
defaultValue,
values = [],
}: Component) => {
const classes = useStyles();
const { filters, setFilters } = useSearch();
useEffect(() => {
if (Array.isArray(defaultValue)) {
setFilters(prevFilters => ({
...prevFilters,
[name]: defaultValue,
}));
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const handleChange = (e: ChangeEvent<HTMLInputElement>) => {
const {
target: { value, checked },
} = e;
setFilters(prevFilters => {
const { [name]: filter, ...others } = prevFilters;
const rest = ((filter as string[]) || []).filter(i => i !== value);
const items = checked ? [...rest, value] : rest;
return items.length ? { ...others, [name]: items } : others;
});
};
return (
<FormControl
className={className}
fullWidth
data-testid="search-checkboxfilter-next"
>
<FormLabel className={classes.label}>{name}</FormLabel>
{values.map((value: string) => (
<FormControlLabel
key={value}
control={
<Checkbox
color="primary"
tabIndex={-1}
inputProps={{ 'aria-labelledby': value }}
value={value}
name={value}
onChange={handleChange}
checked={((filters[name] as string[]) ?? []).includes(value)}
/>
}
label={value}
/>
))}
</FormControl>
);
};
const SelectFilter = ({
className,
name,
defaultValue,
values = [],
}: Component) => {
const classes = useStyles();
const { filters, setFilters } = useSearch();
useEffect(() => {
if (typeof defaultValue === 'string') {
setFilters(prevFilters => ({
...prevFilters,
[name]: defaultValue,
}));
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const handleChange = (e: ChangeEvent<{ value: unknown }>) => {
const {
target: { value },
} = e;
setFilters(prevFilters => {
const { [name]: filter, ...others } = prevFilters;
return value ? { ...others, [name]: value as string } : others;
});
};
return (
<FormControl
className={className}
variant="filled"
fullWidth
data-testid="search-selectfilter-next"
>
<InputLabel className={classes.label} margin="dense">
{name}
</InputLabel>
<Select
variant="outlined"
value={filters[name] || ''}
onChange={handleChange}
>
<MenuItem value="">
<em>All</em>
</MenuItem>
{values.map((value: string) => (
<MenuItem key={value} value={value}>
{value}
</MenuItem>
))}
</Select>
</FormControl>
);
};
const SearchFilterNext = ({ component: Element, ...props }: Props) => (
<Element {...props} />
);
SearchFilterNext.Checkbox = (props: Omit<Props, 'component'> & Component) => (
<SearchFilterNext {...props} component={CheckboxFilter} />
);
SearchFilterNext.Select = (props: Omit<Props, 'component'> & Component) => (
<SearchFilterNext {...props} component={SelectFilter} />
);
export { SearchFilterNext };
@@ -0,0 +1,17 @@
/*
* Copyright 2021 Spotify AB
*
* 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 { SearchFilterNext } from './SearchFilterNext';
@@ -0,0 +1,104 @@
/*
* Copyright 2021 Spotify AB
*
* 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 { renderInTestApp } from '@backstage/test-utils';
import { useLocation, Outlet } from 'react-router';
import { useSearch, SearchContextProvider } from '../SearchContext';
import { SearchPageNext } from './';
jest.mock('react-router', () => ({
...jest.requireActual('react-router'),
useLocation: jest.fn().mockReturnValue({
search: '',
}),
Outlet: jest.fn().mockReturnValue(null),
}));
jest.mock('../SearchContext', () => ({
...jest.requireActual('../SearchContext'),
SearchContextProvider: jest
.fn()
.mockImplementation(({ children }) => children),
useSearch: jest.fn().mockReturnValue({
term: '',
types: [],
filters: {},
pageCursor: '',
}),
}));
describe('SearchPage', () => {
const origReplaceState = window.history.replaceState;
beforeEach(() => {
window.history.replaceState = jest.fn();
});
afterEach(() => {
window.history.replaceState = origReplaceState;
});
it('uses initial term state from location', async () => {
// Given this initial location.search value...
const expectedFilterField = 'anyKey';
const expectedFilterValue = 'anyValue';
const expectedTerm = 'justin bieber';
const expectedTypes = ['software-catalog'];
const expectedFilters = { [expectedFilterField]: expectedFilterValue };
const expectedPageCursor = 'page2-or-something';
// e.g. ?query=petstore&pageCursor=1&filters[lifecycle][]=experimental&filters[kind]=Component
(useLocation as jest.Mock).mockReturnValueOnce({
search: `?query=${expectedTerm}&types[]=${expectedTypes[0]}&filters[${expectedFilterField}]=${expectedFilterValue}&pageCursor=${expectedPageCursor}`,
});
// When we render the page...
await renderInTestApp(<SearchPageNext />);
// Then search context should be initialized with these values...
const calls = (SearchContextProvider as jest.Mock).mock.calls[0];
const actualInitialState = calls[0].initialState;
expect(actualInitialState.term).toEqual(expectedTerm);
expect(actualInitialState.types).toEqual(expectedTypes);
expect(actualInitialState.pageCursor).toEqual(expectedPageCursor);
expect(actualInitialState.filters).toStrictEqual(expectedFilters);
});
it('renders provided router element', async () => {
await renderInTestApp(<SearchPageNext />);
expect(Outlet).toHaveBeenCalled();
});
it('replaces window history with expected query parameters', async () => {
(useSearch as jest.Mock).mockReturnValueOnce({
term: 'bieber',
types: ['software-catalog'],
pageCursor: 'page2-or-something',
filters: { anyKey: 'anyValue' },
});
const expectedLocation = encodeURI(
'?query=bieber&types[]=software-catalog&pageCursor=page2-or-something&filters[anyKey]=anyValue',
);
await renderInTestApp(<SearchPageNext />);
const calls = (window.history.replaceState as jest.Mock).mock.calls[0];
expect(calls[2]).toContain(expectedLocation);
});
});
@@ -13,59 +13,55 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import {
Content,
Header,
Lifecycle,
Page,
useQueryParamState,
} from '@backstage/core';
import { Grid } from '@material-ui/core';
import React, { useEffect, useState } from 'react';
import { useDebounce } from 'react-use';
import { SearchBar } from '../SearchBar';
import { SearchResult } from '../SearchResult';
import React from 'react';
import qs from 'qs';
import { Outlet, useLocation } from 'react-router';
import { SearchContextProvider, useSearch } from '../SearchContext';
import { JsonObject } from '@backstage/config';
export const UrlUpdater = () => {
const { term, types, pageCursor, filters } = useSearch();
const newParams = qs.stringify(
{
query: term,
types,
pageCursor,
filters,
},
{ arrayFormat: 'brackets' },
);
const newUrl = `${window.location.pathname}?${newParams}`;
// We directly manipulate window history here in order to not re-render
// infinitely (state => location => state => etc). The intention of this
// code is just to ensure the right query/filters are loaded when a user
// clicks the "back" button after clicking a result.
window.history.replaceState(null, document.title, newUrl);
return null;
};
export const SearchPageNext = () => {
const [queryString, setQueryString] = useQueryParamState<string>('query');
const [searchQuery, setSearchQuery] = useState(queryString ?? '');
const location = useLocation();
const query = qs.parse(location.search.substring(1), { arrayLimit: 0 }) || {};
const filters = (query.filters as JsonObject) || {};
const queryString = (query.query as string) || '';
const pageCursor = (query.pageCursor as string) || '';
const types = (query.types as string[]) || [];
const handleSearch = (event: React.ChangeEvent<HTMLInputElement>) => {
event.preventDefault();
setSearchQuery(event.target.value);
};
useEffect(() => setSearchQuery(queryString ?? ''), [queryString]);
useDebounce(
() => {
setQueryString(searchQuery);
},
200,
[searchQuery],
);
const handleClearSearchBar = () => {
setSearchQuery('');
const initialState = {
term: queryString || '',
types,
pageCursor,
filters,
};
return (
<Page themeId="home">
<Header title="Search" subtitle={<Lifecycle alpha />} />
<Content>
<Grid container direction="row">
<Grid item xs={12}>
<SearchBar
handleSearch={handleSearch}
handleClearSearchBar={handleClearSearchBar}
searchQuery={searchQuery}
/>
</Grid>
<Grid item xs={12}>
<SearchResult searchQuery={(queryString ?? '').toLowerCase()} />
</Grid>
</Grid>
</Content>
</Page>
<SearchContextProvider initialState={initialState}>
<UrlUpdater />
<Outlet />
</SearchContextProvider>
);
};
@@ -0,0 +1,92 @@
/*
* Copyright 2021 Spotify AB
*
* 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, waitFor } from '@testing-library/react';
import { SearchResultNext } from './SearchResultNext';
import { useSearch } from '../SearchContext';
jest.mock('../SearchContext', () => ({
...jest.requireActual('../SearchContext'),
useSearch: jest.fn().mockReturnValue({
result: {},
}),
}));
describe('SearchResultNext', () => {
it('Progress rendered on Loading state', async () => {
(useSearch as jest.Mock).mockReturnValueOnce({
result: { loading: true },
});
const { getByRole } = render(
<SearchResultNext>{() => <></>}</SearchResultNext>,
);
await waitFor(() => {
expect(getByRole('progressbar')).toBeInTheDocument();
});
});
it('Alert rendered on Error state', async () => {
const error = 'error';
(useSearch as jest.Mock).mockReturnValueOnce({
result: { loading: false, error },
});
const { getByRole } = render(
<SearchResultNext>{() => <></>}</SearchResultNext>,
);
await waitFor(() => {
expect(getByRole('alert')).toHaveTextContent(
`Error encountered while fetching search results. ${error}`,
);
});
});
it('On empty result value state', async () => {
(useSearch as jest.Mock).mockReturnValueOnce({
result: { loading: false, error: '', value: undefined },
});
const { getByRole } = render(
<SearchResultNext>{() => <></>}</SearchResultNext>,
);
await waitFor(() => {
expect(
getByRole('heading', { name: 'Sorry, no results were found' }),
).toBeInTheDocument();
});
});
it('Calls children with results set to result.value', () => {
(useSearch as jest.Mock).mockReturnValueOnce({
result: { loading: false, error: '', value: { results: [] } },
});
render(
<SearchResultNext>
{({ results }) => {
expect(results).toEqual([]);
return <></>;
}}
</SearchResultNext>,
);
});
});
@@ -0,0 +1,49 @@
/*
* Copyright 2021 Spotify AB
*
* 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 { EmptyState, Progress } from '@backstage/core';
import { SearchResult } from '@backstage/search-common';
import { Alert } from '@material-ui/lab';
import { useSearch } from '../SearchContext';
type Props = {
children: (results: { results: SearchResult[] }) => JSX.Element;
};
export const SearchResultNext = ({ children }: Props) => {
const {
result: { loading, error, value },
} = useSearch();
if (loading) {
return <Progress />;
}
if (error) {
return (
<Alert severity="error">
Error encountered while fetching search results. {error.toString()}
</Alert>
);
}
if (!value) {
return <EmptyState missing="data" title="Sorry, no results were found" />;
}
return children({ results: value.results });
};
@@ -0,0 +1,17 @@
/*
* Copyright 2021 Spotify AB
*
* 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 { SearchResultNext } from './SearchResultNext';
+5
View File
@@ -15,8 +15,13 @@
*/
export * from './Filters';
export * from './SearchFilterNext';
export * from './SearchBar';
export * from './SearchBarNext';
export * from './SearchPage';
export * from './SearchPageNext';
export * from './SearchResult';
export * from './SearchResultNext';
export * from './DefaultResultListItem';
export * from './SidebarSearch';
export * from './SearchContext';
+6
View File
@@ -20,12 +20,18 @@ export {
searchPlugin as plugin,
SearchPage,
SearchPageNext,
SearchBarNext,
SearchResultNext,
DefaultResultListItem,
} from './plugin';
export {
Filters,
FiltersButton,
SearchBar,
SearchContextProvider,
useSearch,
SearchPage as Router,
SearchFilterNext,
SearchResult,
SidebarSearch,
} from './components';
+30
View File
@@ -19,6 +19,7 @@ import {
createRouteRef,
createRoutableExtension,
discoveryApiRef,
createComponentExtension,
} from '@backstage/core';
import { SearchClient, searchApiRef } from './apis';
import { catalogApiRef } from '@backstage/plugin-catalog-react';
@@ -70,3 +71,32 @@ export const SearchPageNext = searchPlugin.provide(
mountPoint: rootNextRouteRef,
}),
);
export const SearchBarNext = searchPlugin.provide(
createComponentExtension({
component: {
lazy: () =>
import('./components/SearchBarNext').then(m => m.SearchBarNext),
},
}),
);
export const SearchResultNext = searchPlugin.provide(
createComponentExtension({
component: {
lazy: () =>
import('./components/SearchResultNext').then(m => m.SearchResultNext),
},
}),
);
export const DefaultResultListItem = searchPlugin.provide(
createComponentExtension({
component: {
lazy: () =>
import('./components/DefaultResultListItem').then(
m => m.DefaultResultListItem,
),
},
}),
);