Merge pull request #5786 from backstage/mob/search-fe
[Search] V1 Frontend
This commit is contained in:
@@ -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';
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>;
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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';
|
||||
@@ -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';
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user