diff --git a/.changeset/catalog-search-item.md b/.changeset/catalog-search-item.md new file mode 100644 index 0000000000..c1a12ec161 --- /dev/null +++ b/.changeset/catalog-search-item.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-catalog': patch +--- + +A `` component is now available for use in custom Search Experiences. diff --git a/.changeset/search-cross-the-goal.md b/.changeset/search-cross-the-goal.md new file mode 100644 index 0000000000..580b28ac23 --- /dev/null +++ b/.changeset/search-cross-the-goal.md @@ -0,0 +1,9 @@ +--- +'@backstage/search-common': patch +'@backstage/plugin-search-backend-node': patch +'@backstage/plugin-search': patch +--- + +The ` set of components exported by the Search Plugin are now updated to use the Search Backend API. These will be made available as the default non-"next" versions in a follow-up release. + +The interfaces for decorators and collators in the Search Backend have also seen minor, breaking revisions ahead of a general release. If you happen to be building on top of these interfaces, check and update your implementations accordingly. The APIs will be considered more stable in a follow-up release. diff --git a/packages/app/cypress/integration/components/search/SearchPage.js b/packages/app/cypress/integration/components/search/SearchPage.js new file mode 100644 index 0000000000..4e13fc8a0f --- /dev/null +++ b/packages/app/cypress/integration/components/search/SearchPage.js @@ -0,0 +1,121 @@ +/* + * 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. + */ + +const API_ENDPOINT = 'http://localhost:7000/api/search/query'; + +describe('SearchPage', () => { + describe('Given a search context with a term, results, and filter values', () => { + it('The results are rendered as expected', () => { + const results = [ + { + type: 'software-catalog', + document: { + title: 'backstage', + text: 'Backstage system documentation', + location: '/result/location/path', + }, + }, + ]; + + cy.enterAsGuest(); + cy.visit('/search-next', { + onBeforeLoad(win) { + cy.stub(win, 'fetch') + .withArgs(`${API_ENDPOINT}?term=&pageCursor=`) + .resolves({ + ok: true, + json: () => ({ results }), + }); + }, + }); + cy.contains('Search'); + + cy.contains(results[0].document.title); + cy.contains(results[0].document.text); + cy.get(`a[href="${results[0].document.location}"]`).should('be.visible'); + }); + + it('The filters are rendered as expected', () => { + cy.enterAsGuest(); + cy.visit( + '/search-next?filters%5Bkind%5D=Component&filters%5Blifecycle%5D%5B%5D=experimental', + { + onBeforeLoad(win) { + cy.stub(win, 'fetch') + .withArgs( + `${API_ENDPOINT}?term=&filters%5Bkind%5D=Component&filters%5Blifecycle%5D%5B0%5D=experimental&pageCursor=`, + ) + .resolves({ + ok: true, + json: () => ({ results: [] }), + }); + }, + }, + ); + cy.contains('Search'); + + // lifecycle + cy.contains('lifecycle'); + + cy.contains('experimental'); + cy.get( + '[data-testid="search-checkboxfilter-next"] input[value="experimental"]', + ).should('have.attr', 'checked'); + + cy.contains('production'); + cy.get( + '[data-testid="search-checkboxfilter-next"] input[value="production"]', + ).should('not.have.attr', 'checked'); + + // kind + cy.contains('kind'); + cy.get( + '[data-testid="search-selectfilter-next"] [role="button"][aria-haspopup="listbox"]', + ).click(); + + cy.contains('All'); + cy.contains('Template'); + cy.contains('Component'); + + cy.get('[role="option"][data-value="Component"]').should( + 'have.attr', + 'aria-selected', + 'true', + ); + }); + + it('The search bar is rendered as expected', () => { + cy.enterAsGuest(); + cy.visit('/search-next?query=backstage', { + onBeforeLoad(win) { + cy.stub(win, 'fetch') + .withArgs(`${API_ENDPOINT}?term=backstage&pageCursor=`) + .resolves({ + ok: true, + json: () => ({ results: [] }), + }); + }, + }); + cy.contains('Search'); + + cy.get('[data-testid="search-bar-next"] input').should( + 'have.attr', + 'value', + 'backstage', + ); + }); + }); +}); diff --git a/packages/app/cypress/support/commands.js b/packages/app/cypress/support/commands.js new file mode 100644 index 0000000000..dd2b26634d --- /dev/null +++ b/packages/app/cypress/support/commands.js @@ -0,0 +1,19 @@ +/* + * 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. + */ +Cypress.Commands.add('enterAsGuest', () => { + cy.visit('/'); + cy.get('button').contains('Enter').click(); +}); diff --git a/packages/app/cypress/support/index.js b/packages/app/cypress/support/index.js index 8fc8ca91f1..c1f930027a 100644 --- a/packages/app/cypress/support/index.js +++ b/packages/app/cypress/support/index.js @@ -14,3 +14,4 @@ * limitations under the License. */ import '@testing-library/cypress/add-commands'; +import './commands'; diff --git a/packages/app/src/App.tsx b/packages/app/src/App.tsx index c5e5033973..5874465ef6 100644 --- a/packages/app/src/App.tsx +++ b/packages/app/src/App.tsx @@ -54,6 +54,7 @@ import { Navigate, Route } from 'react-router'; import { apis } from './apis'; import { Root } from './components/Root'; import { entityPage } from './components/catalog/EntityPage'; +import { searchPage } from './components/search/SearchPage'; import { providers } from './identityProviders'; import * as plugins from './plugins'; @@ -119,10 +120,9 @@ const routes = ( } /> } /> } /> - } - /> + }> + {searchPage} + } /> ({ + bar: { + padding: theme.spacing(1, 0), + }, + filters: { + padding: theme.spacing(2), + }, + filter: { + '& + &': { + marginTop: theme.spacing(2.5), + }, + }, +})); + +const SearchPage = () => { + const classes = useStyles(); + + return ( + +
} /> + + + + + + + + + + + + + + + + {({ results }) => ( + + {results.map(({ type, document }) => { + switch (type) { + case 'software-catalog': + return ( + + ); + default: + return ( + + ); + } + })} + + )} + + + + + + ); +}; + +export const searchPage = ; diff --git a/packages/backend/src/plugins/search.ts b/packages/backend/src/plugins/search.ts index 1691f53485..6688b9c158 100644 --- a/packages/backend/src/plugins/search.ts +++ b/packages/backend/src/plugins/search.ts @@ -30,7 +30,6 @@ export default async function createPlugin({ const indexBuilder = new IndexBuilder({ logger, searchEngine }); indexBuilder.addCollator({ - type: 'software-catalog', defaultRefreshIntervalSeconds: 600, collator: new DefaultCatalogCollator({ discovery }), }); diff --git a/packages/search-common/api-report.md b/packages/search-common/api-report.md index 76bd937a1c..a3de27b5a9 100644 --- a/packages/search-common/api-report.md +++ b/packages/search-common/api-report.md @@ -10,12 +10,14 @@ import { JsonObject } from '@backstage/config'; export interface DocumentCollator { // (undocumented) execute(): Promise; + readonly type: string; } // @public export interface DocumentDecorator { // (undocumented) execute(documents: IndexableDocument[]): Promise; + readonly types?: string[]; } // @public @@ -41,6 +43,8 @@ export interface SearchQuery { export interface SearchResult { // (undocumented) document: IndexableDocument; + // (undocumented) + type: string; } // @public (undocumented) diff --git a/packages/search-common/src/types.ts b/packages/search-common/src/types.ts index daa5823424..2e7ca97edc 100644 --- a/packages/search-common/src/types.ts +++ b/packages/search-common/src/types.ts @@ -23,6 +23,7 @@ export interface SearchQuery { } export interface SearchResult { + type: string; document: IndexableDocument; } @@ -57,6 +58,11 @@ export interface IndexableDocument { * search. */ export interface DocumentCollator { + /** + * The type or name of the document set returned by this collator. Used as an + * index name by Search Engines. + */ + readonly type: string; execute(): Promise; } @@ -65,5 +71,11 @@ export interface DocumentCollator { * additional metadata. */ export interface DocumentDecorator { + /** + * An optional array of document/index types on which this decorator should + * be applied. If no types are provided, this decorator will be applied to + * all document/index types. + */ + readonly types?: string[]; execute(documents: IndexableDocument[]): Promise; } diff --git a/plugins/catalog-backend/src/search/DefaultCatalogCollator.ts b/plugins/catalog-backend/src/search/DefaultCatalogCollator.ts index 068b9e36ae..70b8010d87 100644 --- a/plugins/catalog-backend/src/search/DefaultCatalogCollator.ts +++ b/plugins/catalog-backend/src/search/DefaultCatalogCollator.ts @@ -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, diff --git a/plugins/catalog/src/components/CatalogResultListItem/CatalogResultListItem.tsx b/plugins/catalog/src/components/CatalogResultListItem/CatalogResultListItem.tsx new file mode 100644 index 0000000000..8533de22f7 --- /dev/null +++ b/plugins/catalog/src/components/CatalogResultListItem/CatalogResultListItem.tsx @@ -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 ( + + + + + {result.kind && } + {result.lifecycle && ( + + )} + + + + + ); +}; diff --git a/plugins/catalog/src/components/CatalogResultListItem/index.ts b/plugins/catalog/src/components/CatalogResultListItem/index.ts new file mode 100644 index 0000000000..8f418c1dc7 --- /dev/null +++ b/plugins/catalog/src/components/CatalogResultListItem/index.ts @@ -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'; diff --git a/plugins/catalog/src/index.ts b/plugins/catalog/src/index.ts index f7506b534a..052f055dc0 100644 --- a/plugins/catalog/src/index.ts +++ b/plugins/catalog/src/index.ts @@ -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'; diff --git a/plugins/search-backend-node/src/IndexBuilder.test.ts b/plugins/search-backend-node/src/IndexBuilder.test.ts index e1dc511b97..cac2975781 100644 --- a/plugins/search-backend-node/src/IndexBuilder.test.ts +++ b/plugins/search-backend-node/src/IndexBuilder.test.ts @@ -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 { 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, }); diff --git a/plugins/search-backend-node/src/IndexBuilder.ts b/plugins/search-backend-node/src/IndexBuilder.ts index 71d374e704..046eaf7f34 100644 --- a/plugins/search-backend-node/src/IndexBuilder.ts +++ b/plugins/search-backend-node/src/IndexBuilder.ts @@ -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( ', ', diff --git a/plugins/search-backend-node/src/engines/LunrSearchEngine.test.ts b/plugins/search-backend-node/src/engines/LunrSearchEngine.test.ts index 6c348235e0..969e579a53 100644 --- a/plugins/search-backend-node/src/engines/LunrSearchEngine.test.ts +++ b/plugins/search-backend-node/src/engines/LunrSearchEngine.test.ts @@ -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', }); }); }); diff --git a/plugins/search-backend-node/src/engines/LunrSearchEngine.ts b/plugins/search-backend-node/src/engines/LunrSearchEngine.ts index cc8c5bb982..be51738920 100644 --- a/plugins/search-backend-node/src/engines/LunrSearchEngine.ts +++ b/plugins/search-backend-node/src/engines/LunrSearchEngine.ts @@ -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 = {}; protected docStore: Record; @@ -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); } } diff --git a/plugins/search-backend-node/src/types.ts b/plugins/search-backend-node/src/types.ts index 4abfa77fb1..e7643ee5aa 100644 --- a/plugins/search-backend-node/src/types.ts +++ b/plugins/search-backend-node/src/types.ts @@ -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; } diff --git a/plugins/search/package.json b/plugins/search/package.json index 5483eda4c2..a74091e497 100644 --- a/plugins/search/package.json +++ b/plugins/search/package.json @@ -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", diff --git a/plugins/search/src/apis.test.ts b/plugins/search/src/apis.test.ts new file mode 100644 index 0000000000..32ec0f0d0e --- /dev/null +++ b/plugins/search/src/apis.test.ts @@ -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); + }); +}); diff --git a/plugins/search/src/components/DefaultResultListItem/DefaultResultListItem.test.jsx b/plugins/search/src/components/DefaultResultListItem/DefaultResultListItem.test.jsx new file mode 100644 index 0000000000..60ad453597 --- /dev/null +++ b/plugins/search/src/components/DefaultResultListItem/DefaultResultListItem.test.jsx @@ -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(); + expect(screen.getByRole('link')).toHaveAttribute('href', result.location); + }); + + it('Includes primary/secondary text (title / text)', async () => { + await renderInTestApp(); + expect(screen.getByRole('listitem')).toHaveTextContent( + result.title + result.text, + ); + }); +}); diff --git a/plugins/search/src/components/DefaultResultListItem/DefaultResultListItem.tsx b/plugins/search/src/components/DefaultResultListItem/DefaultResultListItem.tsx new file mode 100644 index 0000000000..5f2d4bf87f --- /dev/null +++ b/plugins/search/src/components/DefaultResultListItem/DefaultResultListItem.tsx @@ -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 ( + + + + + + + ); +}; diff --git a/plugins/search/src/components/DefaultResultListItem/index.ts b/plugins/search/src/components/DefaultResultListItem/index.ts new file mode 100644 index 0000000000..7562aff1af --- /dev/null +++ b/plugins/search/src/components/DefaultResultListItem/index.ts @@ -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'; diff --git a/plugins/search/src/components/SearchBarNext/SearchBarNext.test.tsx b/plugins/search/src/components/SearchBarNext/SearchBarNext.test.tsx new file mode 100644 index 0000000000..a5075c5593 --- /dev/null +++ b/plugins/search/src/components/SearchBarNext/SearchBarNext.test.tsx @@ -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( + + + , + ); + + await waitFor(() => { + expect(screen.getByRole('textbox', { name })).toBeInTheDocument(); + }); + }); + + it('Renders based on initial search', async () => { + render( + + + , + ); + + await waitFor(() => { + expect(screen.getByRole('textbox', { name })).toHaveValue(term); + }); + }); + + it('Updates term state when text is entered', async () => { + render( + + + , + ); + + 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( + + + , + ); + + 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( + + + , + ); + + 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 }), + ); + }); +}); diff --git a/plugins/search/src/components/SearchBarNext/SearchBarNext.tsx b/plugins/search/src/components/SearchBarNext/SearchBarNext.tsx new file mode 100644 index 0000000000..76bc9bf4f9 --- /dev/null +++ b/plugins/search/src/components/SearchBarNext/SearchBarNext.tsx @@ -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(term); + + useDebounce(() => setTerm(value), debounceTime, [value]); + + const handleQuery = (e: ChangeEvent) => { + setValue(e.target.value); + }; + + const handleClear = () => setValue(''); + + return ( + + + + + + } + endAdornment={ + + + + + + } + /> + ); +}; diff --git a/plugins/search/src/components/SearchBarNext/index.tsx b/plugins/search/src/components/SearchBarNext/index.tsx new file mode 100644 index 0000000000..5f46a6dda6 --- /dev/null +++ b/plugins/search/src/components/SearchBarNext/index.tsx @@ -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'; diff --git a/plugins/search/src/components/SearchContext/SearchContext.test.tsx b/plugins/search/src/components/SearchContext/SearchContext.test.tsx new file mode 100644 index 0000000000..1b79e62698 --- /dev/null +++ b/plugins/search/src/components/SearchContext/SearchContext.test.tsx @@ -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) => ( + + {children} + + ); + + 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( + + {text} + , + ); + + 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, + }); + }); + }); +}); diff --git a/plugins/search/src/components/SearchContext/SearchContext.tsx b/plugins/search/src/components/SearchContext/SearchContext.tsx new file mode 100644 index 0000000000..55ccd096a1 --- /dev/null +++ b/plugins/search/src/components/SearchContext/SearchContext.tsx @@ -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; + term: string; + setTerm: React.Dispatch>; + types: string[]; + setTypes: React.Dispatch>; + filters: JsonObject; + setFilters: React.Dispatch>; + pageCursor: string; + setPageCursor: React.Dispatch>; +}; + +type SettableSearchContext = Omit< + SearchContextValue, + 'result' | 'setTerm' | 'setTypes' | 'setFilters' | 'setPageCursor' +>; + +const SearchContext = createContext(undefined); + +export const SearchContextProvider = ({ + initialState = { + term: '', + pageCursor: '', + filters: {}, + types: ['*'], + }, + children, +}: PropsWithChildren<{ initialState?: SettableSearchContext }>) => { + const searchApi = useApi(searchApiRef); + const [pageCursor, setPageCursor] = useState(initialState.pageCursor); + const [filters, setFilters] = useState(initialState.filters); + const [term, setTerm] = useState(initialState.term); + const [types, setTypes] = useState(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 ; +}; + +export const useSearch = () => { + const context = useContext(SearchContext); + if (context === undefined) { + throw new Error('useSearch must be used within a SearchContextProvider'); + } + return context; +}; diff --git a/plugins/search/src/components/SearchContext/index.tsx b/plugins/search/src/components/SearchContext/index.tsx new file mode 100644 index 0000000000..b45c169879 --- /dev/null +++ b/plugins/search/src/components/SearchContext/index.tsx @@ -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'; diff --git a/plugins/search/src/components/SearchFilterNext/SearchFilterNext.test.tsx b/plugins/search/src/components/SearchFilterNext/SearchFilterNext.test.tsx new file mode 100644 index 0000000000..1a35bf54d4 --- /dev/null +++ b/plugins/search/src/components/SearchFilterNext/SearchFilterNext.test.tsx @@ -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 }) =>
{props.name}
; + + render(); + + expect(screen.getByRole('heading', { name })).toBeInTheDocument(); + }); + + describe('Checkbox', () => { + it('Renders field name and values when provided as props', async () => { + render( + + + , + ); + + 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( + + + , + ); + + 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( + + + , + ); + + 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( + + + , + ); + + 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( + + + , + ); + + 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( + + + , + ); + + 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( + + + , + ); + + 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( + + + , + ); + + 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( + + + , + ); + + 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( + + + , + ); + + 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 }), + ); + }); + }); + }); +}); diff --git a/plugins/search/src/components/SearchFilterNext/SearchFilterNext.tsx b/plugins/search/src/components/SearchFilterNext/SearchFilterNext.tsx new file mode 100644 index 0000000000..7beef58bc9 --- /dev/null +++ b/plugins/search/src/components/SearchFilterNext/SearchFilterNext.tsx @@ -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) => { + 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 ( + + {name} + {values.map((value: string) => ( + + } + label={value} + /> + ))} + + ); +}; + +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 ( + + + {name} + + + + ); +}; + +const SearchFilterNext = ({ component: Element, ...props }: Props) => ( + +); + +SearchFilterNext.Checkbox = (props: Omit & Component) => ( + +); + +SearchFilterNext.Select = (props: Omit & Component) => ( + +); + +export { SearchFilterNext }; diff --git a/plugins/search/src/components/SearchFilterNext/index.ts b/plugins/search/src/components/SearchFilterNext/index.ts new file mode 100644 index 0000000000..322abe64d7 --- /dev/null +++ b/plugins/search/src/components/SearchFilterNext/index.ts @@ -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'; diff --git a/plugins/search/src/components/SearchPageNext/SearchPageNext.test.tsx b/plugins/search/src/components/SearchPageNext/SearchPageNext.test.tsx new file mode 100644 index 0000000000..5890f8cf41 --- /dev/null +++ b/plugins/search/src/components/SearchPageNext/SearchPageNext.test.tsx @@ -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(); + + // 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(); + + 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(); + + const calls = (window.history.replaceState as jest.Mock).mock.calls[0]; + expect(calls[2]).toContain(expectedLocation); + }); +}); diff --git a/plugins/search/src/components/SearchPageNext/SearchPageNext.tsx b/plugins/search/src/components/SearchPageNext/SearchPageNext.tsx index a6655e37ac..f851778ddf 100644 --- a/plugins/search/src/components/SearchPageNext/SearchPageNext.tsx +++ b/plugins/search/src/components/SearchPageNext/SearchPageNext.tsx @@ -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('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) => { - 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 ( - -
} /> - - - - - - - - - - - + + + + ); }; diff --git a/plugins/search/src/components/SearchResultNext/SearchResultNext.test.tsx b/plugins/search/src/components/SearchResultNext/SearchResultNext.test.tsx new file mode 100644 index 0000000000..98fc285265 --- /dev/null +++ b/plugins/search/src/components/SearchResultNext/SearchResultNext.test.tsx @@ -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( + {() => <>}, + ); + + 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( + {() => <>}, + ); + + 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( + {() => <>}, + ); + + 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( + + {({ results }) => { + expect(results).toEqual([]); + return <>; + }} + , + ); + }); +}); diff --git a/plugins/search/src/components/SearchResultNext/SearchResultNext.tsx b/plugins/search/src/components/SearchResultNext/SearchResultNext.tsx new file mode 100644 index 0000000000..84d3759ad3 --- /dev/null +++ b/plugins/search/src/components/SearchResultNext/SearchResultNext.tsx @@ -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 ; + } + if (error) { + return ( + + Error encountered while fetching search results. {error.toString()} + + ); + } + + if (!value) { + return ; + } + + return children({ results: value.results }); +}; diff --git a/plugins/search/src/components/SearchResultNext/index.tsx b/plugins/search/src/components/SearchResultNext/index.tsx new file mode 100644 index 0000000000..73fabfdc7d --- /dev/null +++ b/plugins/search/src/components/SearchResultNext/index.tsx @@ -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'; diff --git a/plugins/search/src/components/index.tsx b/plugins/search/src/components/index.tsx index f571c61373..92e24b60df 100644 --- a/plugins/search/src/components/index.tsx +++ b/plugins/search/src/components/index.tsx @@ -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'; diff --git a/plugins/search/src/index.ts b/plugins/search/src/index.ts index 519d7fcf66..27fd1706fc 100644 --- a/plugins/search/src/index.ts +++ b/plugins/search/src/index.ts @@ -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'; diff --git a/plugins/search/src/plugin.ts b/plugins/search/src/plugin.ts index e3e953e07d..6ff40ebaf7 100644 --- a/plugins/search/src/plugin.ts +++ b/plugins/search/src/plugin.ts @@ -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, + ), + }, + }), +); diff --git a/yarn.lock b/yarn.lock index 24dac99bef..f2de6ed848 100644 --- a/yarn.lock +++ b/yarn.lock @@ -6732,17 +6732,18 @@ "@types/react" "*" "@types/react@*", "@types/react@^16.9": - version "16.9.37" - resolved "https://registry.npmjs.org/@types/react/-/react-16.9.37.tgz#8fb93e7dbd5b1d3796f69aa979a7fe0439bc7bea" - integrity sha512-ZqnAXallQiZ08LTSqMfWMNvAfJEzRLOxdlbbbCIJlYGjU98BEU6bE2uBpKPGeWn+v3hIgCraHKtqUcKZBzMP/Q== + version "16.14.8" + resolved "https://registry.npmjs.org/@types/react/-/react-16.14.8.tgz#4aee3ab004cb98451917c9b7ada3c7d7e52db3fe" + integrity sha512-QN0/Qhmx+l4moe7WJuTxNiTsjBwlBGHqKGvInSQCBdo7Qio0VtOqwsC0Wq7q3PbJlB0cR4Y4CVo1OOe6BOsOmA== dependencies: "@types/prop-types" "*" - csstype "^2.2.0" + "@types/scheduler" "*" + csstype "^3.0.2" "@types/react@16.4.6": version "16.4.6" - resolved "https://registry.npmjs.org/@types/react/-/react-16.4.6.tgz#5024957c6bcef4f02823accf5974faba2e54fada" - integrity sha512-9LDZdhsuKSc+DjY65SjBkA958oBWcTWSVWAd2cD9XqKBjhGw1KzAkRhWRw2eIsXvaIE/TOTjjKMFVC+JA1iU4g== + resolved "https://artifactory.spotify.net/artifactory/api/npm/virtual-npm/@types/react/-/react-16.4.6.tgz#5024957c6bcef4f02823accf5974faba2e54fada" + integrity sha1-UCSVfGvO9PAoI6zPWXT6ui5U+to= dependencies: csstype "^2.2.0" @@ -6827,6 +6828,11 @@ "@types/node" "*" rollup "^0.63.4" +"@types/scheduler@*": + version "0.16.1" + resolved "https://registry.npmjs.org/@types/scheduler/-/scheduler-0.16.1.tgz#18845205e86ff0038517aab7a18a62a6b9f71275" + integrity sha512-EaCxbanVeyxDRTQBkdLb3Bvl/HK7PBK6UJjsSixB0iHKoWxE5uu2Q/DgtpOhPIojN0Zl1whvOd7PoHs2P0s5eA== + "@types/semver@^6.0.0": version "6.2.1" resolved "https://registry.npmjs.org/@types/semver/-/semver-6.2.1.tgz#a236185670a7860f1597cf73bea2e16d001461ba" @@ -11043,7 +11049,12 @@ cssstyle@^2.2.0: dependencies: cssom "~0.3.6" -csstype@^2.2.0, csstype@^2.5.2, csstype@^2.5.7, csstype@^2.6.7: +csstype@^2.2.0: + version "2.6.17" + resolved "https://artifactory.spotify.net/artifactory/api/npm/virtual-npm/csstype/-/csstype-2.6.17.tgz#4cf30eb87e1d1a005d8b6510f95292413f6a1c0e" + integrity sha1-TPMOuH4dGgBdi2UQ+VKSQT9qHA4= + +csstype@^2.5.2, csstype@^2.5.7, csstype@^2.6.7: version "2.6.9" resolved "https://registry.npmjs.org/csstype/-/csstype-2.6.9.tgz#05141d0cd557a56b8891394c1911c40c8a98d098" integrity sha512-xz39Sb4+OaTsULgUERcCk+TJj8ylkL4aSVDQiX/ksxbELSqwkgt4d4RD7fovIdgJGSuNYqwZEiVjYY5l0ask+Q==