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==