Merge pull request #5927 from backstage/iameap/search-alpha-release

[Search] Initial, Alpha Release
This commit is contained in:
Eric Peterson
2021-06-09 19:21:15 +02:00
committed by GitHub
40 changed files with 1189 additions and 726 deletions
+76
View File
@@ -0,0 +1,76 @@
---
'@backstage/plugin-search-backend': minor
'@backstage/plugin-search-backend-node': minor
---
This release represents a move out of a pre-alpha phase of the Backstage Search
plugin, into an alpha phase. With this release, you gain more control over the
layout of your search page on the frontend, as well as the ability to extend
search on the backend to encompass everything Backstage users may want to find.
If you are updating to version `v0.4.0` of `@backstage/plugin-search` from a
prior release, you will need to make modifications to your app backend.
First, navigate to your backend package and install the two related search
backend packages:
```sh
cd packages/backend
yarn add @backstage/plugin-search-backend @backstage/plugin-search-backend-node
```
Wire up these new packages into your app backend by first creating a new
`search.ts` file at `src/plugins/search.ts` with contents like the following:
```typescript
import { useHotCleanup } from '@backstage/backend-common';
import { createRouter } from '@backstage/plugin-search-backend';
import {
IndexBuilder,
LunrSearchEngine,
} from '@backstage/plugin-search-backend-node';
import { PluginEnvironment } from '../types';
import { DefaultCatalogCollator } from '@backstage/plugin-catalog-backend';
export default async function createPlugin({
logger,
discovery,
}: PluginEnvironment) {
// Initialize a connection to a search engine.
const searchEngine = new LunrSearchEngine({ logger });
const indexBuilder = new IndexBuilder({ logger, searchEngine });
// Collators are responsible for gathering documents known to plugins. This
// particular collator gathers entities from the software catalog.
indexBuilder.addCollator({
defaultRefreshIntervalSeconds: 600,
collator: new DefaultCatalogCollator({ discovery }),
});
// The scheduler controls when documents are gathered from collators and sent
// to the search engine for indexing.
const { scheduler } = await indexBuilder.build();
// A 3 second delay gives the backend server a chance to initialize before
// any collators are executed, which may attempt requests against the API.
setTimeout(() => scheduler.start(), 3000);
useHotCleanup(module, () => scheduler.stop());
return await createRouter({
engine: indexBuilder.getSearchEngine(),
logger,
});
}
```
Then, ensure the search plugin you configured above is initialized by modifying
your backend's `index.ts` file in the following ways:
```diff
+import search from './plugins/search';
// ...
+const searchEnv = useHotMemoize(module, () => createEnv('search'));
// ...
+apiRouter.use('/search', await search(searchEnv));
// ...
```
+118
View File
@@ -0,0 +1,118 @@
---
'@backstage/plugin-search': minor
---
This release represents a move out of a pre-alpha phase of the Backstage Search
plugin, into an alpha phase. With this release, you gain more control over the
layout of your search page on the frontend, as well as the ability to extend
search on the backend to encompass everything Backstage users may want to find.
If you are updating to this version of `@backstage/plugin-search` from a prior
release, you will need to make the following modifications to your App:
In your app package, create a new `searchPage` component at, for example,
`packages/app/src/components/search/SearchPage.tsx` with contents like the
following:
```tsx
import React from 'react';
import { makeStyles, Theme, Grid, List, Paper } from '@material-ui/core';
import { Content, Header, Lifecycle, Page } from '@backstage/core';
import { CatalogResultListItem } from '@backstage/plugin-catalog';
import {
SearchBar,
SearchFilter,
SearchResult,
DefaultResultListItem,
} from '@backstage/plugin-search';
const useStyles = makeStyles((theme: Theme) => ({
bar: {
padding: theme.spacing(1, 0),
},
filters: {
padding: theme.spacing(2),
},
filter: {
'& + &': {
marginTop: theme.spacing(2.5),
},
},
}));
const SearchPage = () => {
const classes = useStyles();
return (
<Page themeId="home">
<Header title="Search" subtitle={<Lifecycle alpha />} />
<Content>
<Grid container direction="row">
<Grid item xs={12}>
<Paper className={classes.bar}>
<SearchBar debounceTime={100} />
</Paper>
</Grid>
<Grid item xs={3}>
<Paper className={classes.filters}>
<SearchFilter.Select
className={classes.filter}
name="kind"
values={['Component', 'Template']}
/>
<SearchFilter.Checkbox
className={classes.filter}
name="lifecycle"
values={['experimental', 'production']}
/>
</Paper>
</Grid>
<Grid item xs={9}>
<SearchResult>
{({ results }) => (
<List>
{results.map(({ type, document }) => {
switch (type) {
case 'software-catalog':
return (
<CatalogResultListItem
key={document.location}
result={document}
/>
);
default:
return (
<DefaultResultListItem
key={document.location}
result={document}
/>
);
}
})}
</List>
)}
</SearchResult>
</Grid>
</Grid>
</Content>
</Page>
);
};
export const searchPage = <SearchPage />;
```
Then in `App.tsx`, import this new `searchPage` component, and set it as a
child of the existing `<SearchPage />` route so that it looks like this:
```tsx
import { searchPage } from './components/search/SearchPage';
// ...
<Route path="/search" element={<SearchPage />}>
{searchPage}
</Route>;
```
You will also need to update your backend. For details, check the changeset for
`v0.2.0` of `@backstage/plugin-search-backend`.
+56 -29
View File
@@ -24,22 +24,22 @@ Backstage ecosystem.
## Project roadmap
| Version | Description |
| --------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| Backstage Search v0 ✅ | Search Frontend letting you search through the entities of the software catalog. [See v0 Use Cases.](#backstage-search-v0) |
| [Backstage Search V0.5 ✅ ][v0.5] | Foundations for the architecture. |
| [Backstage Search v1 ⌛][v1] | Basic “out-of-the-box” in-memory indexing process of entities, and their metadata, registered to the Software Catalog. [See v1 Use Cases.](#backstage-search-v1) |
| [Backstage Search v2 ⌛][v2] | Search Backend responsible for the indexing process of entities, and their metadata, registered to the Software Catalog. [See v2 Use Cases.](#backstage-search-v2) |
| [Backstage Search v3 ⌛][v3] | Standardized Search API lets you index other plugins data to the search engine of choice. [See v3 Use Cases.](#backstage-search-v3) |
| Version | Description |
| -------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Backstage Search Pre-Alpha ✅ | Search Frontend letting you search through the entities of the software catalog. [See Pre-Alpha Use Cases.](#backstage-search-pre-alpha) |
| Backstage Search Alpha ✅ | Basic “out-of-the-box” in-memory indexing process of entities, and their metadata, registered to the Software Catalog. [See Alpha Use Cases](#backstage-search-alpha). |
| [Backstage Search Beta ⌛][beta] | At least one production-ready search engine that supports the same use-cases as in the alpha. [See Beta Use Cases](#backstage-search-beta). |
| [Backstage Search GA ⌛][ga] | A stable Search API for plugin developers to add search to their plugins, and app integrators to expose that to their users. [See GA Use Cases](#backstage-search-ga). |
[v0.5]: https://github.com/backstage/backstage/milestone/25
[v1]: https://github.com/backstage/backstage/milestone/26
[v2]: https://github.com/backstage/backstage/milestone/27
[v3]: https://github.com/backstage/backstage/milestone/28
[beta]: https://github.com/backstage/backstage/milestone/27
[ga]: https://github.com/backstage/backstage/milestone/28
## Use Cases
#### Backstage Search V.0
#### Backstage Search Pre-Alpha
The pre-alpha is intended to solve for the following user stories, but will get
there by means of a front-end only, non-extensible MVP.
- As a software engineer I should be able to navigate to a search page and
search for entities registered in the Software Catalog.
@@ -52,28 +52,48 @@ Backstage ecosystem.
- As a software engineer I should be able to hide the filters if I dont need to
use them.
#### Backstage Search V.1
#### Backstage Search Alpha
- As a software engineer I should be able to get a match of a search on all
entity metadata (e.g. owner, name, description, kind).
- As an integrator I should not have to plug in any search engine, instead I can
use the out of the box in-memory indexing process to index entities and their
metadata registered in the Software Catalog.
We will consider Backstage Search to be in alpha when the above use-cases are
met, but built on top of a flexible, extensible platform.
#### Backstage Search V.2
- As an integrator, I should be able to provide all of the pre-alpha experiences
to my users if I choose, but also be able to customize the experience using a
composable set of components.
- As a plugin developer, I should have a standard way to expose my plugin's data
to Backstage Search.
- As an integrator, I should still be able to expose everything in the Software
Catalog in search, but it should be possible to customize what is searchable.
- As an integrator, although I should be able to customize all of the above, it
should be possible to have the pre-alpha user experiences covered without
having to set up and configure a search engine.
- As an integrator I should be able to spin up an instance of ElasticSearch.
- As an integrator I should be able to define a ElasticSearch cluster in my
app_config.yaml where my data gets indexed to.
#### Backstage Search Beta
more to come...
We will consider Backstage Search to be in a beta phase when the above use-cases
are met, and can be deployed using a production-ready search engine.
#### Backstage Search V.3
- As an integrator, I should be able to power my Backstage Search experience
(including querying and indexing) using a production-ready search engine like
ElasticSearch.
- As an integrator, I should be able to configure the connection to my search
engine in `app_config.yaml`.
- As an integrator, I should be able to tune the queries sent to my chosen
search engine according to my organization's needs, but a sensible default
query should be in place so that I am not required to do so.
- As a contributor I should be able to integrate plugin data to the indexing
process of Backstage Search by using the standardized API.
- As a software engineer I should be able to search for all content (for
example, entities, metadata, documentation) in Backstage search.
#### Backstage Search GA
We will consider Backstage Search to be generally available (GA) when the above
use-cases are met, and an ecosystem of search-enabled plugins are available and
stable.
- As a plugin developer, there should be at least one example of a Backstage
plugin that integrates with search that I can use as inspiration for my own
plugin's search capabilities (for example, the TechDocs plugin).
- As an app integrator, there should be plenty of examples and documentation on
how to customize and extend search in my Backstage instance to meet my
organization's needs.
more to come...
@@ -84,12 +104,19 @@ the search engines are used.
| Search Engine | Support Status |
| ------------- | -------------- |
| Basic (lunr) | Not yet ❌ |
| Basic (lunr) | |
| ElasticSearch | Not yet ❌ |
[Reach out to us](#feedback) if you want to chat about support for more search
engines.
## Plugins Integrated with Search
| Plugin | Support Status |
| -------- | -------------- |
| Catalog | ✅ |
| TechDocs | Not yet ❌ |
## Tech Stack
| Stack | Location |
+2 -3
View File
@@ -43,7 +43,7 @@ import { GraphiQLPage } from '@backstage/plugin-graphiql';
import { LighthousePage } from '@backstage/plugin-lighthouse';
import { NewRelicPage } from '@backstage/plugin-newrelic';
import { ScaffolderPage, scaffolderPlugin } from '@backstage/plugin-scaffolder';
import { SearchPage, SearchPageNext } from '@backstage/plugin-search';
import { SearchPage } from '@backstage/plugin-search';
import { TechRadarPage } from '@backstage/plugin-tech-radar';
import { TechdocsPage } from '@backstage/plugin-techdocs';
import { UserSettingsPage } from '@backstage/plugin-user-settings';
@@ -119,8 +119,7 @@ const routes = (
<Route path="/api-docs" element={<ApiExplorerPage />} />
<Route path="/gcp-projects" element={<GcpProjectsPage />} />
<Route path="/newrelic" element={<NewRelicPage />} />
<Route path="/search" element={<SearchPage />} />
<Route path="/search-next" element={<SearchPageNext />}>
<Route path="/search" element={<SearchPage />}>
{searchPage}
</Route>
<Route path="/cost-insights" element={<CostInsightsPage />} />
@@ -20,9 +20,9 @@ import { makeStyles, Theme, Grid, List, Paper } from '@material-ui/core';
import { Content, Header, Lifecycle, Page } from '@backstage/core';
import { CatalogResultListItem } from '@backstage/plugin-catalog';
import {
SearchBarNext as SearchBar,
SearchFilterNext as SearchFilter,
SearchResultNext as SearchResult,
SearchBar,
SearchFilter,
SearchResult,
DefaultResultListItem,
} from '@backstage/plugin-search';
+8 -1
View File
@@ -26,17 +26,24 @@ export default async function createPlugin({
logger,
discovery,
}: PluginEnvironment) {
// Initialize a connection to a search engine.
const searchEngine = new LunrSearchEngine({ logger });
const indexBuilder = new IndexBuilder({ logger, searchEngine });
// Collators are responsible for gathering documents known to plugins. This
// particular collator gathers entities from the software catalog.
indexBuilder.addCollator({
defaultRefreshIntervalSeconds: 600,
collator: new DefaultCatalogCollator({ discovery }),
});
// The scheduler controls when documents are gathered from collators and sent
// to the search engine for indexing.
const { scheduler } = await indexBuilder.build();
scheduler.start();
// A 3 second delay gives the backend server a chance to initialize before
// any collators are executed, which may attempt requests against the API.
setTimeout(() => scheduler.start(), 3000);
useHotCleanup(module, () => scheduler.stop());
return await createRouter({
+11 -9
View File
@@ -1,17 +1,19 @@
# search-backend-node
This plugin is part of a suite of plugins that comprise the Backstage search
platform, which is still very much under development. This plugin specifically
is responsible for:
platform. This particular plugin is responsible for all aspects of the search
indexing process, including:
- Allowing other backend plugins to register the fact that they have documents
that they'd like to be indexed by a search engine (known as `collators`)
- Allowing other backend plugins to register the fact that they have metadata
that they'd like to augment existing documents in the search index with
(known as `decorators`)
- Providing connections to search engines where actual document indices live
and queries can be made.
- Defining a mechanism for plugins to expose documents that they'd like to be
indexed (called `collators`).
- Defining a mechanism for plugins to add extra metadata to documents that the
source plugin may not be aware of (known as `decorators`).
- A scheduler that, at configurable intervals, compiles documents to be indexed
and passes them to a search engine for indexing
- Types for all of the above
and passes them to a search engine for indexing.
- A builder class to wire up all of the above.
- Naturally, types for all of the above.
Documentation on how to develop and improve the search platform is currently
centralized in the `search` plugin README.md.
@@ -74,4 +74,21 @@ describe('Scheduler', () => {
expect(mockTask2).toHaveBeenCalled();
});
});
describe('start', () => {
it('should execute tasks on start', () => {
const mockTask1 = jest.fn();
const mockTask2 = jest.fn();
// Add tasks and interval to schedule
testScheduler.addToSchedule(mockTask1, 2);
testScheduler.addToSchedule(mockTask2, 2);
// Starts scheduling process
testScheduler.start();
expect(mockTask1).toHaveBeenCalled();
expect(mockTask2).toHaveBeenCalled();
});
});
});
@@ -54,6 +54,8 @@ export class Scheduler {
start() {
this.logger.info('Starting all scheduled search tasks.');
this.schedule.forEach(({ task, interval }) => {
// Fire the task immediately, then schedule it.
task();
this.intervalTimeouts.push(
setInterval(() => {
task();
+2 -2
View File
@@ -1,8 +1,8 @@
# search-backend
This plugin is part of a suite of plugins that comprise the Backstage search
platform, which is still very much under development. This plugin specifically
is responsible for exposing a JSON API for querying a search engine
platform. This particular plugin responsible for exposing a JSON API for
querying a search engine.
Documentation on how to develop and improve the search platform is currently
centralized in the `search` plugin README.md.
+15 -12
View File
@@ -1,23 +1,26 @@
# Backstage Search
**This plugin is still under development.**
A flexible, extensible search across your whole Backstage ecosystem.
You can follow the progress and contribute at the Backstage [Search Project Board](https://github.com/backstage/backstage/projects/6) or reach out to us in the [`#search` Discord channel](https://discord.com/channels/687207715902193673/770283289327566848).
Development is ongoing. You can follow the progress and contribute at the Backstage [Search Project Board](https://github.com/backstage/backstage/projects/6) or reach out to us in the [`#search` Discord channel](https://discord.com/channels/687207715902193673/770283289327566848).
## Getting started
Run `yarn start` in the root directory, and then navigate to [/search](http://localhost:3000/search) to check out the plugin.
Run `yarn dev` in the root directory, and then navigate to [/search](http://localhost:3000/search) to check out the plugin.
### Working on the search platform
### Areas of Responsibility
The above search experience is 100% client-side and only looks at the Software Catalog. We are actively developing [a more complete search platform](https://backstage.io/docs/features/search/search-overview), which will replace the current experience when ready.
This search plugin is primarily responsible for the following:
In order to work on this new search platform, you will need to be aware of the following:
- Providing a `<SearchPage />` routable extension.
- Exposing various search-related components (like `<SearchBar />`,
`<SearchFilter />`, etc), which can be composed by a Backstage App or by
other Backstage Plugins to power search experiences of all kinds.
- Exposing a `<SearchContextProvider />`, which manages search state and API
communication with the Backstage backend.
- **In-development app search route**: The new search platform will move the primary search page to the App-level, out of the search plugin. For now, to ensure the old experience is still easy to test, you can work on the new platform at `/search-next` instead of `/search`.
- **App SearchPage Component**: You'll find a new search page in this plugin `SearchPageNext`. When sufficiently stable, we'll replace `SearchPage` with `SearchPageNext`
- **Backend**: Don't forget, a lot of functionality will be made available in backend plugins:
- `@backstage/plugin-search-backend-node`, which is responsible for the search index management
- `@backstage/plugin-search-backend`, which is responsible for query processing
Don't forget, a lot of functionality is available in backend plugins:
As you work, be sure not to break the existing, frontend-only search page.
- `@backstage/plugin-search-backend-node`, which is responsible for the search
index management
- `@backstage/plugin-search-backend`, which is responsible for query processing
+2 -5
View File
@@ -14,8 +14,6 @@
* limitations under the License.
*/
import { CatalogApi } from '@backstage/plugin-catalog-react';
import { SearchClient } from './apis';
describe('apis', () => {
@@ -29,7 +27,6 @@ describe('apis', () => {
const baseUrl = 'https://base-url.com/';
const getBaseUrl = jest.fn().mockResolvedValue(baseUrl);
const client = new SearchClient({
catalogApi: {} as CatalogApi,
discoveryApi: { getBaseUrl },
});
@@ -42,7 +39,7 @@ describe('apis', () => {
});
it('Fetch is called with expected URL (including stringified Q params)', async () => {
await client._alphaPerformSearch(query);
await client.query(query);
expect(getBaseUrl).toHaveBeenLastCalledWith('search/query');
expect(fetch).toHaveBeenLastCalledWith(`${baseUrl}?term=&pageCursor=`);
});
@@ -50,6 +47,6 @@ describe('apis', () => {
it('Resolves JSON from fetch response', async () => {
const result = { loading: false, error: '', value: {} };
json.mockReturnValueOnce(result);
expect(await client._alphaPerformSearch(query)).toStrictEqual(result);
expect(await client.query(query)).toStrictEqual(result);
});
});
+3 -43
View File
@@ -15,9 +15,6 @@
*/
import { createApiRef, DiscoveryApi } from '@backstage/core';
import { Entity, ENTITY_DEFAULT_NAMESPACE } from '@backstage/catalog-model';
import { CatalogApi } from '@backstage/plugin-catalog-react';
import { SearchQuery, SearchResultSet } from '@backstage/search-common';
import qs from 'qs';
@@ -26,55 +23,18 @@ export const searchApiRef = createApiRef<SearchApi>({
description: 'Used to make requests against the search API',
});
export type Result = {
name: string;
description: string | undefined;
owner: string | undefined;
kind: string;
lifecycle: string | undefined;
url: string;
};
export type SearchResults = Array<Result>;
export interface SearchApi {
getSearchResult(): Promise<SearchResults>;
_alphaPerformSearch(query: SearchQuery): Promise<SearchResultSet>;
query(query: SearchQuery): Promise<SearchResultSet>;
}
export class SearchClient implements SearchApi {
private readonly catalogApi: CatalogApi;
private readonly discoveryApi: DiscoveryApi;
constructor(options: { catalogApi: CatalogApi; discoveryApi: DiscoveryApi }) {
this.catalogApi = options.catalogApi;
constructor(options: { discoveryApi: DiscoveryApi }) {
this.discoveryApi = options.discoveryApi;
}
private async entities() {
const entities = await this.catalogApi.getEntities();
return entities.items.map((entity: Entity) => ({
name: entity.metadata.name,
description: entity.metadata.description,
owner:
typeof entity.spec?.owner === 'string' ? entity.spec?.owner : undefined,
kind: entity.kind,
lifecycle:
typeof entity.spec?.lifecycle === 'string'
? entity.spec?.lifecycle
: undefined,
url: `/catalog/${
entity.metadata.namespace?.toLowerCase() || ENTITY_DEFAULT_NAMESPACE
}/${entity.kind.toLowerCase()}/${entity.metadata.name}`,
}));
}
getSearchResult(): Promise<SearchResults> {
return this.entities();
}
// TODO: Productionalize as we implement search milestones.
async _alphaPerformSearch(query: SearchQuery): Promise<SearchResultSet> {
async query(query: SearchQuery): Promise<SearchResultSet> {
const queryString = qs.stringify(query);
const url = `${await this.discoveryApi.getBaseUrl(
'search/query',
@@ -0,0 +1,146 @@
/*
* Copyright 2020 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 {
makeStyles,
Typography,
Divider,
Card,
CardHeader,
Button,
CardContent,
Select,
Checkbox,
List,
ListItem,
ListItemText,
MenuItem,
} from '@material-ui/core';
const useStyles = makeStyles(theme => ({
filters: {
background: 'transparent',
boxShadow: '0px 0px 0px 0px',
},
checkbox: {
padding: theme.spacing(0, 1, 0, 1),
},
dropdown: {
width: '100%',
},
}));
export type FiltersState = {
selected: string;
checked: Array<string>;
};
export type FilterOptions = {
kind: Array<string>;
lifecycle: Array<string>;
};
type FiltersProps = {
filters: FiltersState;
filterOptions: FilterOptions;
resetFilters: () => void;
updateSelected: (filter: string) => void;
updateChecked: (filter: string) => void;
};
export const Filters = ({
filters,
filterOptions,
resetFilters,
updateSelected,
updateChecked,
}: FiltersProps) => {
const classes = useStyles();
return (
<Card className={classes.filters}>
<CardHeader
title={<Typography variant="h6">Filters</Typography>}
action={
<Button color="primary" onClick={() => resetFilters()}>
CLEAR ALL
</Button>
}
/>
<Divider />
{filterOptions.kind.length === 0 && filterOptions.lifecycle.length === 0 && (
<CardContent>
<Typography variant="subtitle2">
Filters cannot be applied to available results
</Typography>
</CardContent>
)}
{filterOptions.kind.length > 0 && (
<CardContent>
<Typography variant="subtitle2">Kind</Typography>
<Select
id="outlined-select"
onChange={(e: React.ChangeEvent<any>) =>
updateSelected(e?.target?.value)
}
variant="outlined"
className={classes.dropdown}
value={filters.selected}
>
{filterOptions.kind.map(filter => (
<MenuItem
selected={filter === ''}
dense
key={filter}
value={filter}
>
{filter}
</MenuItem>
))}
</Select>
</CardContent>
)}
{filterOptions.lifecycle.length > 0 && (
<CardContent>
<Typography variant="subtitle2">Lifecycle</Typography>
<List disablePadding dense>
{filterOptions.lifecycle.map(filter => (
<ListItem
key={filter}
dense
button
onClick={() => updateChecked(filter)}
>
<Checkbox
edge="start"
disableRipple
className={classes.checkbox}
color="primary"
checked={filters.checked.includes(filter)}
tabIndex={-1}
value={filter}
name={filter}
/>
<ListItemText id={filter} primary={filter} />
</ListItem>
))}
</List>
</CardContent>
)}
</Card>
);
};
@@ -0,0 +1,56 @@
/*
* Copyright 2020 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 FilterListIcon from '@material-ui/icons/FilterList';
import { makeStyles, IconButton, Typography } from '@material-ui/core';
const useStyles = makeStyles(theme => ({
filters: {
width: '250px',
display: 'flex',
},
icon: {
margin: theme.spacing(-1, 0, 0, 0),
},
}));
type FiltersButtonProps = {
numberOfSelectedFilters: number;
handleToggleFilters: () => void;
};
export const FiltersButton = ({
numberOfSelectedFilters,
handleToggleFilters,
}: FiltersButtonProps) => {
const classes = useStyles();
return (
<div className={classes.filters}>
<IconButton
className={classes.icon}
aria-label="settings"
onClick={handleToggleFilters}
>
<FilterListIcon />
</IconButton>
<Typography variant="h6">
Filters ({numberOfSelectedFilters ? numberOfSelectedFilters : 0})
</Typography>
</div>
);
};
@@ -14,4 +14,6 @@
* limitations under the License.
*/
export { SearchPageNext } from './SearchPageNext';
export { FiltersButton } from './FiltersButton';
export { Filters } from './Filters';
export type { FiltersState } from './Filters';
@@ -0,0 +1,69 @@
/*
* Copyright 2020 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 { makeStyles } from '@material-ui/core/styles';
import { Paper } from '@material-ui/core';
import InputBase from '@material-ui/core/InputBase';
import IconButton from '@material-ui/core/IconButton';
import SearchIcon from '@material-ui/icons/Search';
import ClearButton from '@material-ui/icons/Clear';
const useStyles = makeStyles(() => ({
root: {
display: 'flex',
alignItems: 'center',
},
input: {
flex: 1,
},
}));
type SearchBarProps = {
searchQuery: string;
handleSearch: any;
handleClearSearchBar: any;
};
export const SearchBar = ({
searchQuery,
handleSearch,
handleClearSearchBar,
}: SearchBarProps) => {
const classes = useStyles();
return (
<Paper
component="form"
onSubmit={e => handleSearch(e)}
className={classes.root}
>
<IconButton disabled type="submit" aria-label="search">
<SearchIcon />
</IconButton>
<InputBase
className={classes.input}
placeholder="Search in Backstage"
value={searchQuery}
onChange={e => handleSearch(e)}
inputProps={{ 'aria-label': 'search backstage' }}
/>
<IconButton aria-label="search" onClick={() => handleClearSearchBar()}>
<ClearButton />
</IconButton>
</Paper>
);
};
@@ -0,0 +1,71 @@
/*
* Copyright 2020 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 { Content, Header, 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 './LegacySearchBar';
import { SearchResult } from './LegacySearchResult';
/**
* @deprecated This SearchPage, powered directly by the Catalog API, will be
* removed from a future release of this plugin.
*/
export const LegacySearchPage = () => {
const [queryString, setQueryString] = useQueryParamState<string>('query');
const [searchQuery, setSearchQuery] = useState(queryString ?? '');
const handleSearch = (event: React.ChangeEvent<HTMLInputElement>) => {
event.preventDefault();
setSearchQuery(event.target.value);
};
useEffect(() => setSearchQuery(queryString ?? ''), [queryString]);
useDebounce(
() => {
setQueryString(searchQuery);
},
200,
[searchQuery],
);
const handleClearSearchBar = () => {
setSearchQuery('');
};
return (
<Page themeId="home">
<Header title="Search" />
<Content>
<Grid container direction="row">
<Grid item xs={12}>
<SearchBar
handleSearch={handleSearch}
handleClearSearchBar={handleClearSearchBar}
searchQuery={searchQuery}
/>
</Grid>
<Grid item xs={12}>
<SearchResult
searchQuery={(queryString ?? '').toLocaleLowerCase('en-US')}
/>
</Grid>
</Grid>
</Content>
</Page>
);
};
@@ -0,0 +1,291 @@
/*
* Copyright 2020 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 {
EmptyState,
Link,
Progress,
Table,
TableColumn,
useApi,
} from '@backstage/core';
import { Divider, Grid, makeStyles, Typography } from '@material-ui/core';
import { Alert } from '@material-ui/lab';
import React, { useEffect, useState } from 'react';
import { useAsync } from 'react-use';
import { catalogApiRef } from '@backstage/plugin-catalog-react';
import { Filters, FiltersButton, FiltersState } from './Filters';
import { Entity, ENTITY_DEFAULT_NAMESPACE } from '@backstage/catalog-model';
type Result = {
name: string;
description: string | undefined;
owner: string | undefined;
kind: string;
lifecycle: string | undefined;
url: string;
};
type SearchResults = Array<Result>;
const useStyles = makeStyles(theme => ({
searchQuery: {
color: theme.palette.text.primary,
background: theme.palette.background.default,
borderRadius: '10%',
},
tableHeader: {
margin: theme.spacing(1, 0, 0, 0),
display: 'flex',
},
divider: {
width: '1px',
margin: theme.spacing(0, 2),
padding: theme.spacing(2, 0),
},
}));
type SearchResultProps = {
searchQuery?: string;
};
type TableHeaderProps = {
searchQuery?: string;
numberOfSelectedFilters: number;
numberOfResults: number;
handleToggleFilters: () => void;
};
// TODO: move out column to make the search result component more generic
const columns: TableColumn[] = [
{
title: 'Name',
field: 'name',
highlight: true,
render: (result: Partial<Result>) => (
<Link to={result.url || ''}>{result.name}</Link>
),
},
{
title: 'Description',
field: 'description',
},
{
title: 'Owner',
field: 'owner',
},
{
title: 'Kind',
field: 'kind',
},
{
title: 'LifeCycle',
field: 'lifecycle',
},
];
const TableHeader = ({
searchQuery,
numberOfSelectedFilters,
numberOfResults,
handleToggleFilters,
}: TableHeaderProps) => {
const classes = useStyles();
return (
<div className={classes.tableHeader}>
<FiltersButton
numberOfSelectedFilters={numberOfSelectedFilters}
handleToggleFilters={handleToggleFilters}
/>
<Divider className={classes.divider} orientation="vertical" />
<Grid item xs={12}>
{searchQuery ? (
<Typography variant="h6">
{`${numberOfResults} `}
{numberOfResults > 1 ? `results for ` : `result for `}
<span className={classes.searchQuery}>"{searchQuery}"</span>{' '}
</Typography>
) : (
<Typography variant="h6">{`${numberOfResults} results`}</Typography>
)}
</Grid>
</div>
);
};
export const SearchResult = ({ searchQuery }: SearchResultProps) => {
const catalogApi = useApi(catalogApiRef);
const [showFilters, toggleFilters] = useState(false);
const [selectedFilters, setSelectedFilters] = useState<FiltersState>({
selected: '',
checked: [],
});
const [filteredResults, setFilteredResults] = useState<SearchResults>([]);
const { loading, error, value: results } = useAsync(async () => {
const entities = await catalogApi.getEntities();
return entities.items.map((entity: Entity) => ({
name: entity.metadata.name,
description: entity.metadata.description,
owner:
typeof entity.spec?.owner === 'string' ? entity.spec?.owner : undefined,
kind: entity.kind,
lifecycle:
typeof entity.spec?.lifecycle === 'string'
? entity.spec?.lifecycle
: undefined,
url: `/catalog/${
entity.metadata.namespace?.toLowerCase() || ENTITY_DEFAULT_NAMESPACE
}/${entity.kind.toLowerCase()}/${entity.metadata.name}`,
}));
}, []);
useEffect(() => {
if (results) {
let withFilters = results;
// apply filters
// filter on selected
if (selectedFilters.selected !== '') {
withFilters = results.filter((result: Result) =>
selectedFilters.selected.includes(result.kind),
);
}
// filter on checked
if (selectedFilters.checked.length > 0) {
withFilters = withFilters.filter(
(result: Result) =>
result.lifecycle &&
selectedFilters.checked.includes(result.lifecycle),
);
}
// filter on searchQuery
if (searchQuery) {
withFilters = withFilters.filter(
(result: Result) =>
result.name?.toLocaleLowerCase('en-US').includes(searchQuery) ||
result.name
?.toLocaleLowerCase('en-US')
.includes(searchQuery.split(' ').join('-')) ||
result.description
?.toLocaleLowerCase('en-US')
.includes(searchQuery),
);
}
setFilteredResults(withFilters);
}
}, [selectedFilters, searchQuery, results]);
if (loading) {
return <Progress />;
}
if (error) {
return (
<Alert severity="error">
Error encountered while fetching search results. {error.toString()}
</Alert>
);
}
if (!results || results.length === 0) {
return <EmptyState missing="data" title="Sorry, no results were found" />;
}
const resetFilters = () => {
setSelectedFilters({
selected: '',
checked: [],
});
};
const updateSelected = (filter: string) => {
setSelectedFilters(prevState => ({
...prevState,
selected: filter,
}));
};
const updateChecked = (filter: string) => {
if (selectedFilters.checked.includes(filter)) {
setSelectedFilters(prevState => ({
...prevState,
checked: prevState.checked.filter(item => item !== filter),
}));
return;
}
setSelectedFilters(prevState => ({
...prevState,
checked: [...prevState.checked, filter],
}));
};
const filterOptions = results.reduce(
(acc, curr) => {
if (curr.kind && acc.kind.indexOf(curr.kind) < 0) {
acc.kind.push(curr.kind);
}
if (curr.lifecycle && acc.lifecycle.indexOf(curr.lifecycle) < 0) {
acc.lifecycle.push(curr.lifecycle);
}
return acc;
},
{
kind: [] as Array<string>,
lifecycle: [] as Array<string>,
},
);
return (
<>
<Grid container>
{showFilters && (
<Grid item xs={3}>
<Filters
filters={selectedFilters}
filterOptions={filterOptions}
resetFilters={resetFilters}
updateSelected={updateSelected}
updateChecked={updateChecked}
/>
</Grid>
)}
<Grid item xs={showFilters ? 9 : 12}>
<Table
options={{ paging: true, pageSize: 20, search: false }}
data={filteredResults}
columns={columns}
title={
<TableHeader
searchQuery={searchQuery}
numberOfResults={filteredResults.length}
numberOfSelectedFilters={
(selectedFilters.selected !== '' ? 1 : 0) +
selectedFilters.checked.length
}
handleToggleFilters={() => toggleFilters(!showFilters)}
/>
}
/>
</Grid>
</Grid>
</>
);
};
@@ -14,4 +14,4 @@
* limitations under the License.
*/
export { SearchFilterNext } from './SearchFilterNext';
export { LegacySearchPage } from './LegacySearchPage';
@@ -20,14 +20,14 @@ import userEvent from '@testing-library/user-event';
import { useApi } from '@backstage/core';
import { SearchContextProvider } from '../SearchContext';
import { SearchBarNext } from './SearchBarNext';
import { SearchBar } from './SearchBar';
jest.mock('@backstage/core', () => ({
...jest.requireActual('@backstage/core'),
useApi: jest.fn().mockReturnValue({}),
}));
describe('SearchBarNext', () => {
describe('SearchBar', () => {
const initialState = {
term: '',
pageCursor: '',
@@ -38,8 +38,8 @@ describe('SearchBarNext', () => {
const name = 'Search term';
const term = 'term';
const _alphaPerformSearch = jest.fn().mockResolvedValue({});
(useApi as jest.Mock).mockReturnValue({ _alphaPerformSearch });
const query = jest.fn().mockResolvedValue({});
(useApi as jest.Mock).mockReturnValue({ query });
afterAll(() => {
jest.resetAllMocks();
@@ -48,7 +48,7 @@ describe('SearchBarNext', () => {
it('Renders without exploding', async () => {
render(
<SearchContextProvider initialState={initialState}>
<SearchBarNext />
<SearchBar />
</SearchContextProvider>,
);
@@ -60,7 +60,7 @@ describe('SearchBarNext', () => {
it('Renders based on initial search', async () => {
render(
<SearchContextProvider initialState={{ ...initialState, term }}>
<SearchBarNext />
<SearchBar />
</SearchContextProvider>,
);
@@ -72,7 +72,7 @@ describe('SearchBarNext', () => {
it('Updates term state when text is entered', async () => {
render(
<SearchContextProvider initialState={initialState}>
<SearchBarNext />
<SearchBar />
</SearchContextProvider>,
);
@@ -86,7 +86,7 @@ describe('SearchBarNext', () => {
expect(textbox).toHaveValue(value);
});
expect(_alphaPerformSearch).toHaveBeenLastCalledWith(
expect(query).toHaveBeenLastCalledWith(
expect.objectContaining({ term: value }),
);
});
@@ -94,7 +94,7 @@ describe('SearchBarNext', () => {
it('Clear button clears term state', async () => {
render(
<SearchContextProvider initialState={{ ...initialState, term }}>
<SearchBarNext />
<SearchBar />
</SearchContextProvider>,
);
@@ -108,7 +108,7 @@ describe('SearchBarNext', () => {
expect(screen.getByRole('textbox', { name })).toHaveValue('');
});
expect(_alphaPerformSearch).toHaveBeenLastCalledWith(
expect(query).toHaveBeenLastCalledWith(
expect.objectContaining({ term: '' }),
);
});
@@ -120,7 +120,7 @@ describe('SearchBarNext', () => {
render(
<SearchContextProvider initialState={initialState}>
<SearchBarNext debounceTime={debounceTime} />
<SearchBar debounceTime={debounceTime} />
</SearchContextProvider>,
);
@@ -134,7 +134,7 @@ describe('SearchBarNext', () => {
userEvent.type(textbox, value);
expect(_alphaPerformSearch).not.toHaveBeenLastCalledWith(
expect(query).not.toHaveBeenLastCalledWith(
expect.objectContaining({ term: value }),
);
@@ -146,7 +146,7 @@ describe('SearchBarNext', () => {
expect(textbox).toHaveValue(value);
});
expect(_alphaPerformSearch).toHaveBeenLastCalledWith(
expect(query).toHaveBeenLastCalledWith(
expect.objectContaining({ term: value }),
);
});
@@ -1,5 +1,5 @@
/*
* Copyright 2020 Spotify AB
* 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.
@@ -14,56 +14,54 @@
* limitations under the License.
*/
import React from 'react';
import { makeStyles } from '@material-ui/core/styles';
import { Paper } from '@material-ui/core';
import InputBase from '@material-ui/core/InputBase';
import IconButton from '@material-ui/core/IconButton';
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';
const useStyles = makeStyles(() => ({
root: {
display: 'flex',
alignItems: 'center',
},
input: {
flex: 1,
},
}));
import { useSearch } from '../SearchContext';
type SearchBarProps = {
searchQuery: string;
handleSearch: any;
handleClearSearchBar: any;
type Props = {
className?: string;
debounceTime?: number;
};
export const SearchBar = ({
searchQuery,
handleSearch,
handleClearSearchBar,
}: SearchBarProps) => {
const classes = useStyles();
export const SearchBar = ({ className, debounceTime = 0 }: Props) => {
const { term, setTerm } = useSearch();
const [value, setValue] = useState<string>(term);
useDebounce(() => setTerm(value), debounceTime, [value]);
const handleQuery = (e: ChangeEvent<HTMLInputElement>) => {
setValue(e.target.value);
};
const handleClear = () => setValue('');
return (
<Paper
component="form"
onSubmit={e => handleSearch(e)}
className={classes.root}
>
<IconButton disabled type="submit" aria-label="search">
<SearchIcon />
</IconButton>
<InputBase
className={classes.input}
placeholder="Search in Backstage"
value={searchQuery}
onChange={e => handleSearch(e)}
inputProps={{ 'aria-label': 'search backstage' }}
/>
<IconButton aria-label="search" onClick={() => handleClearSearchBar()}>
<ClearButton />
</IconButton>
</Paper>
<InputBase
className={className}
data-testid="search-bar-next"
fullWidth
placeholder="Search in Backstage"
value={value}
onChange={handleQuery}
inputProps={{ 'aria-label': 'Search term' }}
startAdornment={
<InputAdornment position="start">
<IconButton aria-label="Query term" disabled>
<SearchIcon />
</IconButton>
</InputAdornment>
}
endAdornment={
<InputAdornment position="end">
<IconButton aria-label="Clear term" onClick={handleClear}>
<ClearButton />
</IconButton>
</InputAdornment>
}
/>
);
};
@@ -1,5 +1,5 @@
/*
* Copyright 2020 Spotify AB
* 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.
@@ -1,67 +0,0 @@
/*
* Copyright 2021 Spotify AB
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import React, { ChangeEvent, useState } from 'react';
import { useDebounce } from 'react-use';
import { InputBase, InputAdornment, IconButton } from '@material-ui/core';
import SearchIcon from '@material-ui/icons/Search';
import ClearButton from '@material-ui/icons/Clear';
import { useSearch } from '../SearchContext';
type Props = {
className?: string;
debounceTime?: number;
};
export const SearchBarNext = ({ className, debounceTime = 0 }: Props) => {
const { term, setTerm } = useSearch();
const [value, setValue] = useState<string>(term);
useDebounce(() => setTerm(value), debounceTime, [value]);
const handleQuery = (e: ChangeEvent<HTMLInputElement>) => {
setValue(e.target.value);
};
const handleClear = () => setValue('');
return (
<InputBase
className={className}
data-testid="search-bar-next"
fullWidth
placeholder="Search in Backstage"
value={value}
onChange={handleQuery}
inputProps={{ 'aria-label': 'Search term' }}
startAdornment={
<InputAdornment position="start">
<IconButton aria-label="Query term" disabled>
<SearchIcon />
</IconButton>
</InputAdornment>
}
endAdornment={
<InputAdornment position="end">
<IconButton aria-label="Clear term" onClick={handleClear}>
<ClearButton />
</IconButton>
</InputAdornment>
}
/>
);
};
@@ -28,7 +28,7 @@ jest.mock('@backstage/core', () => ({
}));
describe('SearchContext', () => {
const _alphaPerformSearch = jest.fn();
const query = jest.fn();
const wrapper = ({ children, initialState }: any) => (
<SearchContextProvider initialState={initialState}>
@@ -44,8 +44,8 @@ describe('SearchContext', () => {
};
beforeEach(() => {
_alphaPerformSearch.mockResolvedValue({});
(useApi as jest.Mock).mockReturnValue({ _alphaPerformSearch });
query.mockResolvedValue({});
(useApi as jest.Mock).mockReturnValue({ query: query });
});
afterAll(() => {
@@ -138,7 +138,7 @@ describe('SearchContext', () => {
await waitForNextUpdate();
expect(_alphaPerformSearch).toHaveBeenLastCalledWith({
expect(query).toHaveBeenLastCalledWith({
...initialState,
term,
});
@@ -162,7 +162,7 @@ describe('SearchContext', () => {
await waitForNextUpdate();
expect(_alphaPerformSearch).toHaveBeenLastCalledWith({
expect(query).toHaveBeenLastCalledWith({
...initialState,
filters,
});
@@ -186,7 +186,7 @@ describe('SearchContext', () => {
await waitForNextUpdate();
expect(_alphaPerformSearch).toHaveBeenLastCalledWith({
expect(query).toHaveBeenLastCalledWith({
...initialState,
pageCursor,
});
@@ -210,7 +210,7 @@ describe('SearchContext', () => {
await waitForNextUpdate();
expect(_alphaPerformSearch).toHaveBeenLastCalledWith({
expect(query).toHaveBeenLastCalledWith({
...initialState,
types,
});
@@ -65,7 +65,7 @@ export const SearchContextProvider = ({
const result = useAsync(
() =>
searchApi._alphaPerformSearch({
searchApi.query({
term,
filters,
pageCursor,
@@ -19,7 +19,7 @@ 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 { SearchFilter } from './SearchFilter';
import { SearchContextProvider } from '../SearchContext';
jest.mock('@backstage/core', () => ({
@@ -27,7 +27,7 @@ jest.mock('@backstage/core', () => ({
useApi: jest.fn().mockReturnValue({}),
}));
describe('SearchFilterNext', () => {
describe('SearchFilter', () => {
const initialState = {
term: '',
filters: {},
@@ -39,8 +39,8 @@ describe('SearchFilterNext', () => {
const values = ['value1', 'value2'];
const filters = { unrelated: 'unrelated' };
const _alphaPerformSearch = jest.fn().mockResolvedValue({});
(useApi as jest.Mock).mockReturnValue({ _alphaPerformSearch });
const query = jest.fn().mockResolvedValue({});
(useApi as jest.Mock).mockReturnValue({ query: query });
afterAll(() => {
jest.resetAllMocks();
@@ -49,7 +49,7 @@ describe('SearchFilterNext', () => {
it('Check that element was rendered and received props', async () => {
const CustomFilter = (props: { name: string }) => <h6>{props.name}</h6>;
render(<SearchFilterNext name={name} component={CustomFilter} />);
render(<SearchFilter name={name} component={CustomFilter} />);
expect(screen.getByRole('heading', { name })).toBeInTheDocument();
});
@@ -58,7 +58,7 @@ describe('SearchFilterNext', () => {
it('Renders field name and values when provided as props', async () => {
render(
<SearchContextProvider initialState={initialState}>
<SearchFilterNext.Checkbox name={name} values={values} />
<SearchFilter.Checkbox name={name} values={values} />
</SearchContextProvider>,
);
@@ -84,7 +84,7 @@ describe('SearchFilterNext', () => {
},
}}
>
<SearchFilterNext.Checkbox name={name} values={values} />
<SearchFilter.Checkbox name={name} values={values} />
</SearchContextProvider>,
);
@@ -101,7 +101,7 @@ describe('SearchFilterNext', () => {
it('Renders correctly based on defaultValue', async () => {
render(
<SearchContextProvider initialState={initialState}>
<SearchFilterNext.Checkbox
<SearchFilter.Checkbox
name={name}
values={values}
defaultValue={[values[0]]}
@@ -122,7 +122,7 @@ describe('SearchFilterNext', () => {
it('Checking / unchecking a value sets filter state', async () => {
render(
<SearchContextProvider initialState={initialState}>
<SearchFilterNext.Checkbox name={name} values={values} />
<SearchFilter.Checkbox name={name} values={values} />
</SearchContextProvider>,
);
@@ -135,7 +135,7 @@ describe('SearchFilterNext', () => {
// Check the box.
userEvent.click(checkBox);
await waitFor(() => {
expect(_alphaPerformSearch).toHaveBeenLastCalledWith(
expect(query).toHaveBeenLastCalledWith(
expect.objectContaining({ filters: { field: [values[0]] } }),
);
});
@@ -143,7 +143,7 @@ describe('SearchFilterNext', () => {
// Uncheck the box.
userEvent.click(checkBox);
await waitFor(() => {
expect(_alphaPerformSearch).toHaveBeenLastCalledWith(
expect(query).toHaveBeenLastCalledWith(
expect.objectContaining({ filters: {} }),
);
});
@@ -152,7 +152,7 @@ describe('SearchFilterNext', () => {
it('Checking / unchecking a value maintains unrelated filter state', async () => {
render(
<SearchContextProvider initialState={{ ...initialState, filters }}>
<SearchFilterNext.Checkbox name={name} values={values} />
<SearchFilter.Checkbox name={name} values={values} />
</SearchContextProvider>,
);
@@ -165,7 +165,7 @@ describe('SearchFilterNext', () => {
// Check the box.
userEvent.click(checkBox);
await waitFor(() => {
expect(_alphaPerformSearch).toHaveBeenLastCalledWith(
expect(query).toHaveBeenLastCalledWith(
expect.objectContaining({
filters: { ...filters, field: [values[0]] },
}),
@@ -175,7 +175,7 @@ describe('SearchFilterNext', () => {
// Uncheck the box.
userEvent.click(checkBox);
await waitFor(() => {
expect(_alphaPerformSearch).toHaveBeenLastCalledWith(
expect(query).toHaveBeenLastCalledWith(
expect.objectContaining({ filters }),
);
});
@@ -186,7 +186,7 @@ describe('SearchFilterNext', () => {
it('Renders field name and values when provided as props', async () => {
render(
<SearchContextProvider initialState={initialState}>
<SearchFilterNext.Select name={name} values={values} />
<SearchFilter.Select name={name} values={values} />
</SearchContextProvider>,
);
@@ -218,7 +218,7 @@ describe('SearchFilterNext', () => {
},
}}
>
<SearchFilterNext.Select name={name} values={values} />
<SearchFilter.Select name={name} values={values} />
</SearchContextProvider>,
);
@@ -247,7 +247,7 @@ describe('SearchFilterNext', () => {
it('Renders correctly based on defaultValue', async () => {
render(
<SearchContextProvider initialState={initialState}>
<SearchFilterNext.Select
<SearchFilter.Select
name={name}
values={values}
defaultValue={values[0]}
@@ -280,7 +280,7 @@ describe('SearchFilterNext', () => {
it('Selecting a value sets filter state', async () => {
render(
<SearchContextProvider initialState={initialState}>
<SearchFilterNext.Select name={name} values={values} />
<SearchFilter.Select name={name} values={values} />
</SearchContextProvider>,
);
@@ -299,7 +299,7 @@ describe('SearchFilterNext', () => {
userEvent.click(screen.getByRole('option', { name: values[0] }));
await waitFor(() => {
expect(_alphaPerformSearch).toHaveBeenLastCalledWith(
expect(query).toHaveBeenLastCalledWith(
expect.objectContaining({
filters: { [name]: values[0] },
}),
@@ -315,7 +315,7 @@ describe('SearchFilterNext', () => {
userEvent.click(screen.getByRole('option', { name: 'All' }));
await waitFor(() => {
expect(_alphaPerformSearch).toHaveBeenLastCalledWith(
expect(query).toHaveBeenLastCalledWith(
expect.objectContaining({
filters: {},
}),
@@ -331,7 +331,7 @@ describe('SearchFilterNext', () => {
filters,
}}
>
<SearchFilterNext.Select name={name} values={values} />
<SearchFilter.Select name={name} values={values} />
</SearchContextProvider>,
);
@@ -350,7 +350,7 @@ describe('SearchFilterNext', () => {
userEvent.click(screen.getByRole('option', { name: values[0] }));
await waitFor(() => {
expect(_alphaPerformSearch).toHaveBeenLastCalledWith(
expect(query).toHaveBeenLastCalledWith(
expect.objectContaining({
filters: { ...filters, [name]: values[0] },
}),
@@ -366,7 +366,7 @@ describe('SearchFilterNext', () => {
userEvent.click(screen.getByRole('option', { name: 'All' }));
await waitFor(() => {
expect(_alphaPerformSearch).toHaveBeenLastCalledWith(
expect(query).toHaveBeenLastCalledWith(
expect.objectContaining({ filters }),
);
});
@@ -164,16 +164,24 @@ const SelectFilter = ({
);
};
const SearchFilterNext = ({ component: Element, ...props }: Props) => (
const SearchFilter = ({ component: Element, ...props }: Props) => (
<Element {...props} />
);
SearchFilterNext.Checkbox = (props: Omit<Props, 'component'> & Component) => (
<SearchFilterNext {...props} component={CheckboxFilter} />
SearchFilter.Checkbox = (props: Omit<Props, 'component'> & Component) => (
<SearchFilter {...props} component={CheckboxFilter} />
);
SearchFilterNext.Select = (props: Omit<Props, 'component'> & Component) => (
<SearchFilterNext {...props} component={SelectFilter} />
SearchFilter.Select = (props: Omit<Props, 'component'> & Component) => (
<SearchFilter {...props} component={SelectFilter} />
);
export { SearchFilterNext };
/**
* @deprecated This component was used for rapid prototyping of the Backstage
* Search platform. Now that the API has stabilized, you should use the
* <SearchFilter /> component instead. This component will be removed in an
* upcoming release.
*/
const SearchFilterNext = SearchFilter;
export { SearchFilter, SearchFilterNext };
@@ -14,4 +14,4 @@
* limitations under the License.
*/
export { SearchBarNext } from './SearchBarNext';
export { SearchFilter, SearchFilterNext } from './SearchFilter';
@@ -16,17 +16,17 @@
import React from 'react';
import { renderInTestApp } from '@backstage/test-utils';
import { useLocation, Outlet } from 'react-router';
import { useLocation, useOutlet } from 'react-router';
import { useSearch, SearchContextProvider } from '../SearchContext';
import { SearchPageNext } from './';
import { SearchPage } from './';
jest.mock('react-router', () => ({
...jest.requireActual('react-router'),
useLocation: jest.fn().mockReturnValue({
search: '',
}),
Outlet: jest.fn().mockReturnValue(null),
useOutlet: jest.fn().mockReturnValue('Route Children'),
}));
jest.mock('../SearchContext', () => ({
@@ -42,6 +42,11 @@ jest.mock('../SearchContext', () => ({
}),
}));
jest.mock('../LegacySearchPage', () => ({
...jest.requireActual('../SearchContext'),
LegacySearchPage: jest.fn().mockReturnValue('LegacySearchPageMock'),
}));
describe('SearchPage', () => {
const origReplaceState = window.history.replaceState;
@@ -68,7 +73,7 @@ describe('SearchPage', () => {
});
// When we render the page...
await renderInTestApp(<SearchPageNext />);
await renderInTestApp(<SearchPage />);
// Then search context should be initialized with these values...
const calls = (SearchContextProvider as jest.Mock).mock.calls[0];
@@ -80,9 +85,16 @@ describe('SearchPage', () => {
});
it('renders provided router element', async () => {
await renderInTestApp(<SearchPageNext />);
const { getByText } = await renderInTestApp(<SearchPage />);
expect(Outlet).toHaveBeenCalled();
expect(getByText('Route Children')).toBeInTheDocument();
});
it('renders legacy search when no router children are provided', async () => {
(useOutlet as jest.Mock).mockReturnValueOnce(null);
const { getByText } = await renderInTestApp(<SearchPage />);
expect(getByText('LegacySearchPageMock')).toBeInTheDocument();
});
it('replaces window history with expected query parameters', async () => {
@@ -96,7 +108,7 @@ describe('SearchPage', () => {
'?query=bieber&types[]=software-catalog&pageCursor=page2-or-something&filters[anyKey]=anyValue',
);
await renderInTestApp(<SearchPageNext />);
await renderInTestApp(<SearchPage />);
const calls = (window.history.replaceState as jest.Mock).mock.calls[0];
expect(calls[2]).toContain(expectedLocation);
@@ -13,55 +13,57 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { Content, Header, 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 { useLocation, useOutlet } from 'react-router';
import { SearchContextProvider, useSearch } from '../SearchContext';
import { JsonObject } from '@backstage/config';
import { LegacySearchPage } from '../LegacySearchPage';
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 SearchPage = () => {
const [queryString, setQueryString] = useQueryParamState<string>('query');
const [searchQuery, setSearchQuery] = useState(queryString ?? '');
const location = useLocation();
const outlet = useOutlet();
const query = qs.parse(location.search.substring(1), { arrayLimit: 0 }) || {};
const filters = (query.filters as JsonObject) || {};
const queryString = (query.query as string) || '';
const pageCursor = (query.pageCursor as string) || '';
const types = (query.types as string[]) || [];
const handleSearch = (event: React.ChangeEvent<HTMLInputElement>) => {
event.preventDefault();
setSearchQuery(event.target.value);
};
useEffect(() => setSearchQuery(queryString ?? ''), [queryString]);
useDebounce(
() => {
setQueryString(searchQuery);
},
200,
[searchQuery],
);
const handleClearSearchBar = () => {
setSearchQuery('');
const initialState = {
term: queryString || '',
types,
pageCursor,
filters,
};
return (
<Page themeId="home">
<Header title="Search" />
<Content>
<Grid container direction="row">
<Grid item xs={12}>
<SearchBar
handleSearch={handleSearch}
handleClearSearchBar={handleClearSearchBar}
searchQuery={searchQuery}
/>
</Grid>
<Grid item xs={12}>
<SearchResult
searchQuery={(queryString ?? '').toLocaleLowerCase('en-US')}
/>
</Grid>
</Grid>
</Content>
</Page>
<SearchContextProvider initialState={initialState}>
<UrlUpdater />
{outlet || <LegacySearchPage />}
</SearchContextProvider>
);
};
@@ -1,67 +0,0 @@
/*
* Copyright 2020 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 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 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 initialState = {
term: queryString || '',
types,
pageCursor,
filters,
};
return (
<SearchContextProvider initialState={initialState}>
<UrlUpdater />
<Outlet />
</SearchContextProvider>
);
};
@@ -17,7 +17,7 @@
import React from 'react';
import { render, waitFor } from '@testing-library/react';
import { SearchResultNext } from './SearchResultNext';
import { SearchResult } from './SearchResult';
import { useSearch } from '../SearchContext';
jest.mock('../SearchContext', () => ({
@@ -27,15 +27,13 @@ jest.mock('../SearchContext', () => ({
}),
}));
describe('SearchResultNext', () => {
describe('SearchResult', () => {
it('Progress rendered on Loading state', async () => {
(useSearch as jest.Mock).mockReturnValueOnce({
result: { loading: true },
});
const { getByRole } = render(
<SearchResultNext>{() => <></>}</SearchResultNext>,
);
const { getByRole } = render(<SearchResult>{() => <></>}</SearchResult>);
await waitFor(() => {
expect(getByRole('progressbar')).toBeInTheDocument();
@@ -48,9 +46,7 @@ describe('SearchResultNext', () => {
result: { loading: false, error },
});
const { getByRole } = render(
<SearchResultNext>{() => <></>}</SearchResultNext>,
);
const { getByRole } = render(<SearchResult>{() => <></>}</SearchResult>);
await waitFor(() => {
expect(getByRole('alert')).toHaveTextContent(
@@ -64,9 +60,7 @@ describe('SearchResultNext', () => {
result: { loading: false, error: '', value: undefined },
});
const { getByRole } = render(
<SearchResultNext>{() => <></>}</SearchResultNext>,
);
const { getByRole } = render(<SearchResult>{() => <></>}</SearchResult>);
await waitFor(() => {
expect(
@@ -81,12 +75,12 @@ describe('SearchResultNext', () => {
});
render(
<SearchResultNext>
<SearchResult>
{({ results }) => {
expect(results).toEqual([]);
return <></>;
}}
</SearchResultNext>,
</SearchResult>,
);
});
});
@@ -1,5 +1,5 @@
/*
* Copyright 2020 Spotify AB
* 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.
@@ -13,162 +13,23 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import {
EmptyState,
Link,
Progress,
Table,
TableColumn,
useApi,
} from '@backstage/core';
import { Divider, Grid, makeStyles, Typography } from '@material-ui/core';
import React from 'react';
import { EmptyState, Progress } from '@backstage/core';
import { SearchResult } from '@backstage/search-common';
import { Alert } from '@material-ui/lab';
import React, { useEffect, useState } from 'react';
import { useAsync } from 'react-use';
import { Result, SearchResults, searchApiRef } from '../../apis';
import { Filters, FiltersButton, FiltersState } from '../Filters';
import { useSearch } from '../SearchContext';
const useStyles = makeStyles(theme => ({
searchQuery: {
color: theme.palette.text.primary,
background: theme.palette.background.default,
borderRadius: '10%',
},
tableHeader: {
margin: theme.spacing(1, 0, 0, 0),
display: 'flex',
},
divider: {
width: '1px',
margin: theme.spacing(0, 2),
padding: theme.spacing(2, 0),
},
}));
type SearchResultProps = {
searchQuery?: string;
type Props = {
children: (results: { results: SearchResult[] }) => JSX.Element;
};
type TableHeaderProps = {
searchQuery?: string;
numberOfSelectedFilters: number;
numberOfResults: number;
handleToggleFilters: () => void;
};
const SearchResultComponent = ({ children }: Props) => {
const {
result: { loading, error, value },
} = useSearch();
// TODO: move out column to make the search result component more generic
const columns: TableColumn[] = [
{
title: 'Name',
field: 'name',
highlight: true,
render: (result: Partial<Result>) => (
<Link to={result.url || ''}>{result.name}</Link>
),
},
{
title: 'Description',
field: 'description',
},
{
title: 'Owner',
field: 'owner',
},
{
title: 'Kind',
field: 'kind',
},
{
title: 'LifeCycle',
field: 'lifecycle',
},
];
const TableHeader = ({
searchQuery,
numberOfSelectedFilters,
numberOfResults,
handleToggleFilters,
}: TableHeaderProps) => {
const classes = useStyles();
return (
<div className={classes.tableHeader}>
<FiltersButton
numberOfSelectedFilters={numberOfSelectedFilters}
handleToggleFilters={handleToggleFilters}
/>
<Divider className={classes.divider} orientation="vertical" />
<Grid item xs={12}>
{searchQuery ? (
<Typography variant="h6">
{`${numberOfResults} `}
{numberOfResults > 1 ? `results for ` : `result for `}
<span className={classes.searchQuery}>"{searchQuery}"</span>{' '}
</Typography>
) : (
<Typography variant="h6">{`${numberOfResults} results`}</Typography>
)}
</Grid>
</div>
);
};
export const SearchResult = ({ searchQuery }: SearchResultProps) => {
const searchApi = useApi(searchApiRef);
const [showFilters, toggleFilters] = useState(false);
const [selectedFilters, setSelectedFilters] = useState<FiltersState>({
selected: '',
checked: [],
});
const [filteredResults, setFilteredResults] = useState<SearchResults>([]);
const { loading, error, value: results } = useAsync(() => {
return searchApi.getSearchResult();
}, []);
useEffect(() => {
if (results) {
let withFilters = results;
// apply filters
// filter on selected
if (selectedFilters.selected !== '') {
withFilters = results.filter((result: Result) =>
selectedFilters.selected.includes(result.kind),
);
}
// filter on checked
if (selectedFilters.checked.length > 0) {
withFilters = withFilters.filter(
(result: Result) =>
result.lifecycle &&
selectedFilters.checked.includes(result.lifecycle),
);
}
// filter on searchQuery
if (searchQuery) {
withFilters = withFilters.filter(
(result: Result) =>
result.name?.toLocaleLowerCase('en-US').includes(searchQuery) ||
result.name
?.toLocaleLowerCase('en-US')
.includes(searchQuery.split(' ').join('-')) ||
result.description
?.toLocaleLowerCase('en-US')
.includes(searchQuery),
);
}
setFilteredResults(withFilters);
}
}, [selectedFilters, searchQuery, results]);
if (loading) {
return <Progress />;
}
@@ -179,88 +40,12 @@ export const SearchResult = ({ searchQuery }: SearchResultProps) => {
</Alert>
);
}
if (!results || results.length === 0) {
if (!value) {
return <EmptyState missing="data" title="Sorry, no results were found" />;
}
const resetFilters = () => {
setSelectedFilters({
selected: '',
checked: [],
});
};
const updateSelected = (filter: string) => {
setSelectedFilters(prevState => ({
...prevState,
selected: filter,
}));
};
const updateChecked = (filter: string) => {
if (selectedFilters.checked.includes(filter)) {
setSelectedFilters(prevState => ({
...prevState,
checked: prevState.checked.filter(item => item !== filter),
}));
return;
}
setSelectedFilters(prevState => ({
...prevState,
checked: [...prevState.checked, filter],
}));
};
const filterOptions = results.reduce(
(acc, curr) => {
if (curr.kind && acc.kind.indexOf(curr.kind) < 0) {
acc.kind.push(curr.kind);
}
if (curr.lifecycle && acc.lifecycle.indexOf(curr.lifecycle) < 0) {
acc.lifecycle.push(curr.lifecycle);
}
return acc;
},
{
kind: [] as Array<string>,
lifecycle: [] as Array<string>,
},
);
return (
<>
<Grid container>
{showFilters && (
<Grid item xs={3}>
<Filters
filters={selectedFilters}
filterOptions={filterOptions}
resetFilters={resetFilters}
updateSelected={updateSelected}
updateChecked={updateChecked}
/>
</Grid>
)}
<Grid item xs={showFilters ? 9 : 12}>
<Table
options={{ paging: true, pageSize: 20, search: false }}
data={filteredResults}
columns={columns}
title={
<TableHeader
searchQuery={searchQuery}
numberOfResults={filteredResults.length}
numberOfSelectedFilters={
(selectedFilters.selected !== '' ? 1 : 0) +
selectedFilters.checked.length
}
handleToggleFilters={() => toggleFilters(!showFilters)}
/>
}
/>
</Grid>
</Grid>
</>
);
return children({ results: value.results });
};
export { SearchResultComponent as SearchResult };
@@ -1,5 +1,5 @@
/*
* Copyright 2020 Spotify AB
* 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.
@@ -1,49 +0,0 @@
/*
* Copyright 2021 Spotify AB
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import React from 'react';
import { EmptyState, Progress } from '@backstage/core';
import { SearchResult } from '@backstage/search-common';
import { Alert } from '@material-ui/lab';
import { useSearch } from '../SearchContext';
type Props = {
children: (results: { results: SearchResult[] }) => JSX.Element;
};
export const SearchResultNext = ({ children }: Props) => {
const {
result: { loading, error, value },
} = useSearch();
if (loading) {
return <Progress />;
}
if (error) {
return (
<Alert severity="error">
Error encountered while fetching search results. {error.toString()}
</Alert>
);
}
if (!value) {
return <EmptyState missing="data" title="Sorry, no results were found" />;
}
return children({ results: value.results });
};
@@ -1,17 +0,0 @@
/*
* 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';
+1 -4
View File
@@ -15,13 +15,10 @@
*/
export * from './Filters';
export * from './SearchFilterNext';
export * from './SearchFilter';
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';
+3 -3
View File
@@ -14,14 +14,14 @@
* limitations under the License.
*/
// TODO: export searchApiRef from ./apis once interface is stable and settled.
export { searchApiRef } from './apis';
export {
searchPlugin,
searchPlugin as plugin,
SearchPage,
SearchPageNext,
SearchBarNext,
SearchResultNext,
SearchResult,
DefaultResultListItem,
} from './plugin';
export {
@@ -31,8 +31,8 @@ export {
SearchContextProvider,
useSearch,
SearchPage as Router,
SearchFilter,
SearchFilterNext,
SearchResult,
SidebarSearch,
} from './components';
export type { FiltersState } from './components';
+41 -17
View File
@@ -22,9 +22,6 @@ import {
createComponentExtension,
} from '@backstage/core';
import { SearchClient, searchApiRef } from './apis';
import { catalogApiRef } from '@backstage/plugin-catalog-react';
import { SearchPage as SearchPageComponent } from './components/SearchPage';
import { SearchPageNext as SearchPageNextComponent } from './components/SearchPageNext';
export const rootRouteRef = createRouteRef({
path: '/search',
@@ -41,16 +38,12 @@ export const searchPlugin = createPlugin({
apis: [
createApiFactory({
api: searchApiRef,
deps: { catalogApi: catalogApiRef, discoveryApi: discoveryApiRef },
factory: ({ catalogApi, discoveryApi }) => {
return new SearchClient({ catalogApi, discoveryApi });
deps: { discoveryApi: discoveryApiRef },
factory: ({ discoveryApi }) => {
return new SearchClient({ discoveryApi });
},
}),
],
register({ router }) {
router.addRoute(rootRouteRef, SearchPageComponent);
router.addRoute(rootNextRouteRef, SearchPageNextComponent);
},
routes: {
root: rootRouteRef,
nextRoot: rootNextRouteRef,
@@ -64,28 +57,59 @@ export const SearchPage = searchPlugin.provide(
}),
);
/**
* @deprecated This component was used for rapid prototyping of the Backstage
* Search platform. Now that the API has stabilized, you should use the
* <SearchPage /> component instead. This component will be removed in an
* upcoming release.
*/
export const SearchPageNext = searchPlugin.provide(
createRoutableExtension({
component: () =>
import('./components/SearchPageNext').then(m => m.SearchPageNext),
component: () => import('./components/SearchPage').then(m => m.SearchPage),
mountPoint: rootNextRouteRef,
}),
);
export const SearchBarNext = searchPlugin.provide(
export const SearchBar = searchPlugin.provide(
createComponentExtension({
component: {
lazy: () =>
import('./components/SearchBarNext').then(m => m.SearchBarNext),
lazy: () => import('./components/SearchBar').then(m => m.SearchBar),
},
}),
);
/**
* @deprecated This component was used for rapid prototyping of the Backstage
* Search platform. Now that the API has stabilized, you should use the
* <SearchBar /> component instead. This component will be removed in an
* upcoming release.
*/
export const SearchBarNext = searchPlugin.provide(
createComponentExtension({
component: {
lazy: () => import('./components/SearchBar').then(m => m.SearchBar),
},
}),
);
export const SearchResult = searchPlugin.provide(
createComponentExtension({
component: {
lazy: () => import('./components/SearchResult').then(m => m.SearchResult),
},
}),
);
/**
* @deprecated This component was used for rapid prototyping of the Backstage
* Search platform. Now that the API has stabilized, you should use the
* <SearchResult /> component instead. This component will be removed in an
* upcoming release.
*/
export const SearchResultNext = searchPlugin.provide(
createComponentExtension({
component: {
lazy: () =>
import('./components/SearchResultNext').then(m => m.SearchResultNext),
lazy: () => import('./components/SearchResult').then(m => m.SearchResult),
},
}),
);