diff --git a/.changeset/search-new-fantastic-pov.md b/.changeset/search-new-fantastic-pov.md new file mode 100644 index 0000000000..0baba045b7 --- /dev/null +++ b/.changeset/search-new-fantastic-pov.md @@ -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)); +// ... +``` diff --git a/.changeset/search-whole-new-world.md b/.changeset/search-whole-new-world.md new file mode 100644 index 0000000000..3dee24a371 --- /dev/null +++ b/.changeset/search-whole-new-world.md @@ -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 ( + +
} /> + + + + + + + + + + + + + + + + {({ results }) => ( + + {results.map(({ type, document }) => { + switch (type) { + case 'software-catalog': + return ( + + ); + default: + return ( + + ); + } + })} + + )} + + + + + + ); +}; + +export const searchPage = ; +``` + +Then in `App.tsx`, import this new `searchPage` component, and set it as a +child of the existing `` route so that it looks like this: + +```tsx +import { searchPage } from './components/search/SearchPage'; +// ... +}> + {searchPage} +; +``` + +You will also need to update your backend. For details, check the changeset for +`v0.2.0` of `@backstage/plugin-search-backend`. diff --git a/docs/features/search/README.md b/docs/features/search/README.md index bc0c9b3908..9f5a5d7009 100644 --- a/docs/features/search/README.md +++ b/docs/features/search/README.md @@ -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 don’t 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 | diff --git a/packages/app/src/App.tsx b/packages/app/src/App.tsx index 5874465ef6..b14f9a3163 100644 --- a/packages/app/src/App.tsx +++ b/packages/app/src/App.tsx @@ -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 = ( } /> } /> } /> - } /> - }> + }> {searchPage} } /> diff --git a/packages/app/src/components/search/SearchPage.tsx b/packages/app/src/components/search/SearchPage.tsx index 221d4a44c4..7b94e876e6 100644 --- a/packages/app/src/components/search/SearchPage.tsx +++ b/packages/app/src/components/search/SearchPage.tsx @@ -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'; diff --git a/packages/backend/src/plugins/search.ts b/packages/backend/src/plugins/search.ts index 6688b9c158..e587cdb606 100644 --- a/packages/backend/src/plugins/search.ts +++ b/packages/backend/src/plugins/search.ts @@ -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({ diff --git a/plugins/search-backend-node/README.md b/plugins/search-backend-node/README.md index 126e8b9988..264780fe93 100644 --- a/plugins/search-backend-node/README.md +++ b/plugins/search-backend-node/README.md @@ -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. diff --git a/plugins/search-backend-node/src/Scheduler.test.ts b/plugins/search-backend-node/src/Scheduler.test.ts index 43fdb7ea92..53a7e418e3 100644 --- a/plugins/search-backend-node/src/Scheduler.test.ts +++ b/plugins/search-backend-node/src/Scheduler.test.ts @@ -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(); + }); + }); }); diff --git a/plugins/search-backend-node/src/Scheduler.ts b/plugins/search-backend-node/src/Scheduler.ts index e54a837cee..a5c5712999 100644 --- a/plugins/search-backend-node/src/Scheduler.ts +++ b/plugins/search-backend-node/src/Scheduler.ts @@ -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(); diff --git a/plugins/search-backend/README.md b/plugins/search-backend/README.md index 6c5e8c1bae..b89c6acf68 100644 --- a/plugins/search-backend/README.md +++ b/plugins/search-backend/README.md @@ -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. diff --git a/plugins/search/README.md b/plugins/search/README.md index 04abee06ee..f4a32c9597 100644 --- a/plugins/search/README.md +++ b/plugins/search/README.md @@ -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 `` routable extension. +- Exposing various search-related components (like ``, + ``, etc), which can be composed by a Backstage App or by + other Backstage Plugins to power search experiences of all kinds. +- Exposing a ``, 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 diff --git a/plugins/search/src/apis.test.ts b/plugins/search/src/apis.test.ts index 32ec0f0d0e..64a5207d6d 100644 --- a/plugins/search/src/apis.test.ts +++ b/plugins/search/src/apis.test.ts @@ -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); }); }); diff --git a/plugins/search/src/apis.ts b/plugins/search/src/apis.ts index b15be349cf..5e039cbb84 100644 --- a/plugins/search/src/apis.ts +++ b/plugins/search/src/apis.ts @@ -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({ 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; - export interface SearchApi { - getSearchResult(): Promise; - _alphaPerformSearch(query: SearchQuery): Promise; + query(query: SearchQuery): Promise; } 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 { - return this.entities(); - } - - // TODO: Productionalize as we implement search milestones. - async _alphaPerformSearch(query: SearchQuery): Promise { + async query(query: SearchQuery): Promise { const queryString = qs.stringify(query); const url = `${await this.discoveryApi.getBaseUrl( 'search/query', diff --git a/plugins/search/src/components/LegacySearchPage/Filters/Filters.tsx b/plugins/search/src/components/LegacySearchPage/Filters/Filters.tsx new file mode 100644 index 0000000000..888a583547 --- /dev/null +++ b/plugins/search/src/components/LegacySearchPage/Filters/Filters.tsx @@ -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; +}; + +export type FilterOptions = { + kind: Array; + lifecycle: Array; +}; + +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 ( + + Filters} + action={ + + } + /> + + {filterOptions.kind.length === 0 && filterOptions.lifecycle.length === 0 && ( + + + Filters cannot be applied to available results + + + )} + {filterOptions.kind.length > 0 && ( + + Kind + + + )} + {filterOptions.lifecycle.length > 0 && ( + + Lifecycle + + {filterOptions.lifecycle.map(filter => ( + updateChecked(filter)} + > + + + + ))} + + + )} + + ); +}; diff --git a/plugins/search/src/components/LegacySearchPage/Filters/FiltersButton.tsx b/plugins/search/src/components/LegacySearchPage/Filters/FiltersButton.tsx new file mode 100644 index 0000000000..4775e9d8b8 --- /dev/null +++ b/plugins/search/src/components/LegacySearchPage/Filters/FiltersButton.tsx @@ -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 ( +
+ + + + + Filters ({numberOfSelectedFilters ? numberOfSelectedFilters : 0}) + +
+ ); +}; diff --git a/plugins/search/src/components/SearchPageNext/index.tsx b/plugins/search/src/components/LegacySearchPage/Filters/index.ts similarity index 81% rename from plugins/search/src/components/SearchPageNext/index.tsx rename to plugins/search/src/components/LegacySearchPage/Filters/index.ts index 464ba28750..ea431a3c01 100644 --- a/plugins/search/src/components/SearchPageNext/index.tsx +++ b/plugins/search/src/components/LegacySearchPage/Filters/index.ts @@ -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'; diff --git a/plugins/search/src/components/LegacySearchPage/LegacySearchBar.tsx b/plugins/search/src/components/LegacySearchPage/LegacySearchBar.tsx new file mode 100644 index 0000000000..9aa48e7284 --- /dev/null +++ b/plugins/search/src/components/LegacySearchPage/LegacySearchBar.tsx @@ -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 ( + handleSearch(e)} + className={classes.root} + > + + + + handleSearch(e)} + inputProps={{ 'aria-label': 'search backstage' }} + /> + handleClearSearchBar()}> + + + + ); +}; diff --git a/plugins/search/src/components/LegacySearchPage/LegacySearchPage.tsx b/plugins/search/src/components/LegacySearchPage/LegacySearchPage.tsx new file mode 100644 index 0000000000..7e9d7b205c --- /dev/null +++ b/plugins/search/src/components/LegacySearchPage/LegacySearchPage.tsx @@ -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('query'); + const [searchQuery, setSearchQuery] = useState(queryString ?? ''); + + const handleSearch = (event: React.ChangeEvent) => { + event.preventDefault(); + setSearchQuery(event.target.value); + }; + + useEffect(() => setSearchQuery(queryString ?? ''), [queryString]); + + useDebounce( + () => { + setQueryString(searchQuery); + }, + 200, + [searchQuery], + ); + + const handleClearSearchBar = () => { + setSearchQuery(''); + }; + + return ( + +
+ + + + + + + + + + + + ); +}; diff --git a/plugins/search/src/components/LegacySearchPage/LegacySearchResult.tsx b/plugins/search/src/components/LegacySearchPage/LegacySearchResult.tsx new file mode 100644 index 0000000000..b89a05e276 --- /dev/null +++ b/plugins/search/src/components/LegacySearchPage/LegacySearchResult.tsx @@ -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; + +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.name} + ), + }, + { + 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 ( +
+ + + + {searchQuery ? ( + + {`${numberOfResults} `} + {numberOfResults > 1 ? `results for ` : `result for `} + "{searchQuery}"{' '} + + ) : ( + {`${numberOfResults} results`} + )} + +
+ ); +}; + +export const SearchResult = ({ searchQuery }: SearchResultProps) => { + const catalogApi = useApi(catalogApiRef); + + const [showFilters, toggleFilters] = useState(false); + const [selectedFilters, setSelectedFilters] = useState({ + selected: '', + checked: [], + }); + + const [filteredResults, setFilteredResults] = useState([]); + + 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 ; + } + if (error) { + return ( + + Error encountered while fetching search results. {error.toString()} + + ); + } + if (!results || results.length === 0) { + return ; + } + + 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, + lifecycle: [] as Array, + }, + ); + + return ( + <> + + {showFilters && ( + + + + )} + + toggleFilters(!showFilters)} + /> + } + /> + + + + ); +}; diff --git a/plugins/search/src/components/SearchFilterNext/index.ts b/plugins/search/src/components/LegacySearchPage/index.ts similarity index 91% rename from plugins/search/src/components/SearchFilterNext/index.ts rename to plugins/search/src/components/LegacySearchPage/index.ts index 322abe64d7..b28b8ba9e5 100644 --- a/plugins/search/src/components/SearchFilterNext/index.ts +++ b/plugins/search/src/components/LegacySearchPage/index.ts @@ -14,4 +14,4 @@ * limitations under the License. */ -export { SearchFilterNext } from './SearchFilterNext'; +export { LegacySearchPage } from './LegacySearchPage'; diff --git a/plugins/search/src/components/SearchBarNext/SearchBarNext.test.tsx b/plugins/search/src/components/SearchBar/SearchBar.test.tsx similarity index 84% rename from plugins/search/src/components/SearchBarNext/SearchBarNext.test.tsx rename to plugins/search/src/components/SearchBar/SearchBar.test.tsx index a5075c5593..ca85275a5c 100644 --- a/plugins/search/src/components/SearchBarNext/SearchBarNext.test.tsx +++ b/plugins/search/src/components/SearchBar/SearchBar.test.tsx @@ -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( - + , ); @@ -60,7 +60,7 @@ describe('SearchBarNext', () => { it('Renders based on initial search', async () => { render( - + , ); @@ -72,7 +72,7 @@ describe('SearchBarNext', () => { it('Updates term state when text is entered', async () => { render( - + , ); @@ -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( - + , ); @@ -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( - + , ); @@ -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 }), ); }); diff --git a/plugins/search/src/components/SearchBar/SearchBar.tsx b/plugins/search/src/components/SearchBar/SearchBar.tsx index 9aa48e7284..483ea0ed28 100644 --- a/plugins/search/src/components/SearchBar/SearchBar.tsx +++ b/plugins/search/src/components/SearchBar/SearchBar.tsx @@ -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(term); + + useDebounce(() => setTerm(value), debounceTime, [value]); + + const handleQuery = (e: ChangeEvent) => { + setValue(e.target.value); + }; + + const handleClear = () => setValue(''); return ( - handleSearch(e)} - className={classes.root} - > - - - - handleSearch(e)} - inputProps={{ 'aria-label': 'search backstage' }} - /> - handleClearSearchBar()}> - - - + + + + + + } + endAdornment={ + + + + + + } + /> ); }; diff --git a/plugins/search/src/components/SearchBar/index.tsx b/plugins/search/src/components/SearchBar/index.tsx index e2b8af1946..065e3aaaa1 100644 --- a/plugins/search/src/components/SearchBar/index.tsx +++ b/plugins/search/src/components/SearchBar/index.tsx @@ -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. diff --git a/plugins/search/src/components/SearchBarNext/SearchBarNext.tsx b/plugins/search/src/components/SearchBarNext/SearchBarNext.tsx deleted file mode 100644 index 76bc9bf4f9..0000000000 --- a/plugins/search/src/components/SearchBarNext/SearchBarNext.tsx +++ /dev/null @@ -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(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/SearchContext/SearchContext.test.tsx b/plugins/search/src/components/SearchContext/SearchContext.test.tsx index 1b79e62698..e4394abe70 100644 --- a/plugins/search/src/components/SearchContext/SearchContext.test.tsx +++ b/plugins/search/src/components/SearchContext/SearchContext.test.tsx @@ -28,7 +28,7 @@ jest.mock('@backstage/core', () => ({ })); describe('SearchContext', () => { - const _alphaPerformSearch = jest.fn(); + const query = jest.fn(); const wrapper = ({ children, initialState }: any) => ( @@ -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, }); diff --git a/plugins/search/src/components/SearchContext/SearchContext.tsx b/plugins/search/src/components/SearchContext/SearchContext.tsx index 55ccd096a1..36a5ccde37 100644 --- a/plugins/search/src/components/SearchContext/SearchContext.tsx +++ b/plugins/search/src/components/SearchContext/SearchContext.tsx @@ -65,7 +65,7 @@ export const SearchContextProvider = ({ const result = useAsync( () => - searchApi._alphaPerformSearch({ + searchApi.query({ term, filters, pageCursor, diff --git a/plugins/search/src/components/SearchFilterNext/SearchFilterNext.test.tsx b/plugins/search/src/components/SearchFilter/SearchFilter.test.tsx similarity index 86% rename from plugins/search/src/components/SearchFilterNext/SearchFilterNext.test.tsx rename to plugins/search/src/components/SearchFilter/SearchFilter.test.tsx index 1a35bf54d4..2742197d32 100644 --- a/plugins/search/src/components/SearchFilterNext/SearchFilterNext.test.tsx +++ b/plugins/search/src/components/SearchFilter/SearchFilter.test.tsx @@ -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 }) =>
{props.name}
; - render(); + render(); expect(screen.getByRole('heading', { name })).toBeInTheDocument(); }); @@ -58,7 +58,7 @@ describe('SearchFilterNext', () => { it('Renders field name and values when provided as props', async () => { render( - + , ); @@ -84,7 +84,7 @@ describe('SearchFilterNext', () => { }, }} > - +
, ); @@ -101,7 +101,7 @@ describe('SearchFilterNext', () => { it('Renders correctly based on defaultValue', async () => { render( - { it('Checking / unchecking a value sets filter state', async () => { render( - + , ); @@ -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( - + , ); @@ -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( - + , ); @@ -218,7 +218,7 @@ describe('SearchFilterNext', () => { }, }} > - + , ); @@ -247,7 +247,7 @@ describe('SearchFilterNext', () => { it('Renders correctly based on defaultValue', async () => { render( - { it('Selecting a value sets filter state', async () => { render( - + , ); @@ -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, }} > - + , ); @@ -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 }), ); }); diff --git a/plugins/search/src/components/SearchFilterNext/SearchFilterNext.tsx b/plugins/search/src/components/SearchFilter/SearchFilter.tsx similarity index 85% rename from plugins/search/src/components/SearchFilterNext/SearchFilterNext.tsx rename to plugins/search/src/components/SearchFilter/SearchFilter.tsx index 7beef58bc9..579d4af8df 100644 --- a/plugins/search/src/components/SearchFilterNext/SearchFilterNext.tsx +++ b/plugins/search/src/components/SearchFilter/SearchFilter.tsx @@ -164,16 +164,24 @@ const SelectFilter = ({ ); }; -const SearchFilterNext = ({ component: Element, ...props }: Props) => ( +const SearchFilter = ({ component: Element, ...props }: Props) => ( ); -SearchFilterNext.Checkbox = (props: Omit & Component) => ( - +SearchFilter.Checkbox = (props: Omit & Component) => ( + ); -SearchFilterNext.Select = (props: Omit & Component) => ( - +SearchFilter.Select = (props: Omit & Component) => ( + ); -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 + * component instead. This component will be removed in an + * upcoming release. + */ +const SearchFilterNext = SearchFilter; + +export { SearchFilter, SearchFilterNext }; diff --git a/plugins/search/src/components/SearchBarNext/index.tsx b/plugins/search/src/components/SearchFilter/index.ts similarity index 90% rename from plugins/search/src/components/SearchBarNext/index.tsx rename to plugins/search/src/components/SearchFilter/index.ts index 5f46a6dda6..c12591493d 100644 --- a/plugins/search/src/components/SearchBarNext/index.tsx +++ b/plugins/search/src/components/SearchFilter/index.ts @@ -14,4 +14,4 @@ * limitations under the License. */ -export { SearchBarNext } from './SearchBarNext'; +export { SearchFilter, SearchFilterNext } from './SearchFilter'; diff --git a/plugins/search/src/components/SearchPageNext/SearchPageNext.test.tsx b/plugins/search/src/components/SearchPage/SearchPage.test.tsx similarity index 80% rename from plugins/search/src/components/SearchPageNext/SearchPageNext.test.tsx rename to plugins/search/src/components/SearchPage/SearchPage.test.tsx index 5890f8cf41..4b65466866 100644 --- a/plugins/search/src/components/SearchPageNext/SearchPageNext.test.tsx +++ b/plugins/search/src/components/SearchPage/SearchPage.test.tsx @@ -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(); + await renderInTestApp(); // 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(); + const { getByText } = await renderInTestApp(); - 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(); + + 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(); + 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/SearchPage/SearchPage.tsx b/plugins/search/src/components/SearchPage/SearchPage.tsx index 7c216fc908..25687c72ce 100644 --- a/plugins/search/src/components/SearchPage/SearchPage.tsx +++ b/plugins/search/src/components/SearchPage/SearchPage.tsx @@ -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('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) => { - 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 ( - -
- - - - - - - - - - - + + + {outlet || } + ); }; diff --git a/plugins/search/src/components/SearchPageNext/SearchPageNext.tsx b/plugins/search/src/components/SearchPageNext/SearchPageNext.tsx deleted file mode 100644 index f851778ddf..0000000000 --- a/plugins/search/src/components/SearchPageNext/SearchPageNext.tsx +++ /dev/null @@ -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 ( - - - - - ); -}; diff --git a/plugins/search/src/components/SearchResultNext/SearchResultNext.test.tsx b/plugins/search/src/components/SearchResult/SearchResult.test.tsx similarity index 82% rename from plugins/search/src/components/SearchResultNext/SearchResultNext.test.tsx rename to plugins/search/src/components/SearchResult/SearchResult.test.tsx index 98fc285265..2beb47b157 100644 --- a/plugins/search/src/components/SearchResultNext/SearchResultNext.test.tsx +++ b/plugins/search/src/components/SearchResult/SearchResult.test.tsx @@ -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( - {() => <>}, - ); + const { getByRole } = render({() => <>}); await waitFor(() => { expect(getByRole('progressbar')).toBeInTheDocument(); @@ -48,9 +46,7 @@ describe('SearchResultNext', () => { result: { loading: false, error }, }); - const { getByRole } = render( - {() => <>}, - ); + const { getByRole } = render({() => <>}); await waitFor(() => { expect(getByRole('alert')).toHaveTextContent( @@ -64,9 +60,7 @@ describe('SearchResultNext', () => { result: { loading: false, error: '', value: undefined }, }); - const { getByRole } = render( - {() => <>}, - ); + const { getByRole } = render({() => <>}); await waitFor(() => { expect( @@ -81,12 +75,12 @@ describe('SearchResultNext', () => { }); render( - + {({ results }) => { expect(results).toEqual([]); return <>; }} - , + , ); }); }); diff --git a/plugins/search/src/components/SearchResult/SearchResult.tsx b/plugins/search/src/components/SearchResult/SearchResult.tsx index 0aa209e6fe..24b582f7e9 100644 --- a/plugins/search/src/components/SearchResult/SearchResult.tsx +++ b/plugins/search/src/components/SearchResult/SearchResult.tsx @@ -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.name} - ), - }, - { - 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 ( -
- - - - {searchQuery ? ( - - {`${numberOfResults} `} - {numberOfResults > 1 ? `results for ` : `result for `} - "{searchQuery}"{' '} - - ) : ( - {`${numberOfResults} results`} - )} - -
- ); -}; - -export const SearchResult = ({ searchQuery }: SearchResultProps) => { - const searchApi = useApi(searchApiRef); - - const [showFilters, toggleFilters] = useState(false); - const [selectedFilters, setSelectedFilters] = useState({ - selected: '', - checked: [], - }); - - const [filteredResults, setFilteredResults] = useState([]); - - 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 ; } @@ -179,88 +40,12 @@ export const SearchResult = ({ searchQuery }: SearchResultProps) => { ); } - if (!results || results.length === 0) { + + if (!value) { return ; } - 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, - lifecycle: [] as Array, - }, - ); - - return ( - <> - - {showFilters && ( - - - - )} - -
toggleFilters(!showFilters)} - /> - } - /> - - - - ); + return children({ results: value.results }); }; + +export { SearchResultComponent as SearchResult }; diff --git a/plugins/search/src/components/SearchResult/index.tsx b/plugins/search/src/components/SearchResult/index.tsx index cf10135fd0..407700c19c 100644 --- a/plugins/search/src/components/SearchResult/index.tsx +++ b/plugins/search/src/components/SearchResult/index.tsx @@ -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. diff --git a/plugins/search/src/components/SearchResultNext/SearchResultNext.tsx b/plugins/search/src/components/SearchResultNext/SearchResultNext.tsx deleted file mode 100644 index 84d3759ad3..0000000000 --- a/plugins/search/src/components/SearchResultNext/SearchResultNext.tsx +++ /dev/null @@ -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 ; - } - 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 deleted file mode 100644 index 73fabfdc7d..0000000000 --- a/plugins/search/src/components/SearchResultNext/index.tsx +++ /dev/null @@ -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'; diff --git a/plugins/search/src/components/index.tsx b/plugins/search/src/components/index.tsx index 92e24b60df..72bf9713ec 100644 --- a/plugins/search/src/components/index.tsx +++ b/plugins/search/src/components/index.tsx @@ -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'; diff --git a/plugins/search/src/index.ts b/plugins/search/src/index.ts index 27fd1706fc..75868d12fd 100644 --- a/plugins/search/src/index.ts +++ b/plugins/search/src/index.ts @@ -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'; diff --git a/plugins/search/src/plugin.ts b/plugins/search/src/plugin.ts index 6ff40ebaf7..45987a3749 100644 --- a/plugins/search/src/plugin.ts +++ b/plugins/search/src/plugin.ts @@ -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 + * 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 + * 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 + * 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), }, }), );